diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml index c623b4ff50..d8a1d0b8d7 100644 --- a/.github/workflows/auto-pr.yml +++ b/.github/workflows/auto-pr.yml @@ -1,13 +1,13 @@ -name: Merge branch rel-10.6 with rel-10.5 +name: Merge branch dev with rel-10.6 on: push: branches: - - rel-10.5 + - rel-10.6 permissions: contents: read jobs: - merge-rel-10-6-with-rel-10-5: + merge-dev-with-rel-10-6: permissions: contents: write # for peter-evans/create-pull-request to create branch pull-requests: write # for peter-evans/create-pull-request to create a PR @@ -15,17 +15,17 @@ jobs: steps: - uses: actions/checkout@v2 with: - ref: rel-10.6 + ref: dev - name: Reset promotion branch run: | - git fetch origin rel-10.5:rel-10.5 - git reset --hard rel-10.5 + git fetch origin rel-10.6:rel-10.6 + git reset --hard rel-10.6 - name: Create Pull Request uses: peter-evans/create-pull-request@v3 with: - branch: auto-merge/rel-10-5/${{github.run_number}} - title: Merge branch rel-10.6 with rel-10.5 - body: This PR generated automatically to merge rel-10.6 with rel-10.5. Please review the changed files before merging to prevent any errors that may occur. + branch: auto-merge/rel-10-6/${{github.run_number}} + title: Merge branch dev with rel-10.6 + body: This PR generated automatically to merge dev with rel-10.6. Please review the changed files before merging to prevent any errors that may occur. draft: true token: ${{ github.token }} - name: Merge Pull Request @@ -33,5 +33,5 @@ jobs: GH_TOKEN: ${{ secrets.BOT_SECRET }} run: | gh pr ready - gh pr review auto-merge/rel-10-5/${{github.run_number}} --approve - gh pr merge auto-merge/rel-10-5/${{github.run_number}} --merge --auto --delete-branch + gh pr review auto-merge/rel-10-6/${{github.run_number}} --approve + gh pr merge auto-merge/rel-10-6/${{github.run_number}} --merge --auto --delete-branch diff --git a/Directory.Packages.props b/Directory.Packages.props index f809304e31..5fc14be5c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,82 +48,82 @@ - + - - + + - - - - - - - - - - + + + + + + + + + + - - - - + + + + - - - + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - + @@ -170,17 +170,17 @@ - + - + - - - + + + @@ -195,6 +195,6 @@ - + \ No newline at end of file diff --git a/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json b/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json index 61cf14b67c..d3f150be7a 100644 --- a/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json +++ b/abp_io/AbpIoLocalization/AbpIoLocalization/Admin/Localization/Resources/en.json @@ -785,6 +785,7 @@ "Enum:UiFramework:5": "Blazor Server", "Enum:UiFramework:6": "Blazor WebApp", "Enum:UiFramework:7": "Blazor MAUI", + "Enum:UiFramework:8": "React", "Enum:DatabaseProvider:0": "Unknown", "Enum:DatabaseProvider:1": "None", "Enum:DatabaseProvider:2": "EfCore", diff --git a/common.props b/common.props index d8e0b39c5c..facbf7b479 100644 --- a/common.props +++ b/common.props @@ -1,8 +1,8 @@ latest - 10.5.0 - 5.5.0 + 10.6.0 + 5.6.0 $(NoWarn);CS1591;CS0436 https://abp.io/assets/abp_nupkg.png https://abp.io/ diff --git a/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/POST.md b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/POST.md new file mode 100644 index 0000000000..1cc3253523 --- /dev/null +++ b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/POST.md @@ -0,0 +1,71 @@ +# ABP.IO Platform 10.5 Final Has Been Released! + +We are glad to announce that [ABP](https://abp.io/) 10.5 stable version has been released. + +## What's New With Version 10.5? + +All the new features were explained in detail in the [10.5 RC Announcement Post](https://abp.io/community/announcements/announcing-abp-10-5-release-candidate-k6oxdfle), so there is no need to review them again. You can check it out for more details. + +## Getting Started with 10.5 + +### How to Upgrade an Existing Solution + +You can upgrade your existing solutions with either ABP Studio or ABP CLI. In the following sections, both approaches are explained: + +### Upgrading via ABP Studio + +If you are already using the ABP Studio, you can upgrade it to the latest version. ABP Studio periodically checks for updates in the background, and when a new version of ABP Studio is available, you will be notified through a modal. Then, you can update it by confirming the opened modal. See [the documentation](https://abp.io/docs/latest/studio/installation#upgrading) for more info. + +After upgrading the ABP Studio, then you can open your solution in the application, and simply click the **Upgrade ABP Packages** action button to instantly upgrade your solution: + +![](upgrade-abp-packages.png) + +### Upgrading via ABP CLI + +Alternatively, you can upgrade your existing solution via ABP CLI. First, you need to install the ABP CLI or upgrade it to the latest version. + +If you haven't installed it yet, you can run the following command: + +```bash +dotnet tool install -g Volo.Abp.Studio.Cli +``` + +Or to update the existing CLI, you can run the following command: + +```bash +dotnet tool update -g Volo.Abp.Studio.Cli +``` + +After installing/updating the ABP CLI, you can use the [`update` command](https://abp.io/docs/latest/CLI#update) to update all the ABP related NuGet and NPM packages in your solution as follows: + +```bash +abp update +``` + +You can run this command in the root folder of your solution to update all ABP related packages. + +## Migration Guides + +There are no explicitly marked breaking changes in this version. However, there are still some important migration notes for specific scenarios. Please read the migration guide carefully, if you are upgrading from v10.4 or earlier versions: [ABP Version 10.5 Migration Guide](https://abp.io/docs/10.5/release-info/migration-guides/abp-10-5) + +## Community News + +### New ABP Community Articles + +As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: + +- [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus) has published 2 new articles: + - [Angular 22 State Management: Signals, SignalStore, or NgRx?](https://abp.io/community/articles/angular-22-state-management-signals-signalstore-or-ngrx-yq8zg0nw) + - [Customizing the ABP Framework: A Developer's Guide to LeptonX Theme Overrides in Angular and the Transition to React UI](https://abp.io/community/articles/customizing-the-abp-framework-a-developers-guide-to-nklweri3) +- [Working with Dapr Workflows in the ABP Framework](https://abp.io/community/articles/working-with-dapr-workflows-in-the-abp-framework-6476or18) by [Engincan Veske](https://abp.io/community/members/EngincanV) +- [Alper Ebicoglu](https://abp.io/community/members/alper) has published 2 new articles: + - [My Speaker's View of CONVEX Summit 2026](https://abp.io/community/articles/my-speakers-view-of-convex-summit-2026-ai-net-conference-3uk6ln1l) + - [AI Isn't Replacing Developers - It's Changing What Good Developers Spend Time On](https://abp.io/community/articles/ai-isnt-replacing-developers-its-changing-what-good-2016q6ng) +- [Deep Dive on ABP AI Agent: The Complete Series](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-the-complete-series-f7jute7n) by [Berkan Sasmaz](https://abp.io/community/members/berkansasmaz) + - We have created a deep-dive series for ABP Studio's AI Coding Agent. You can read this series to learn the main features of the AI Coding Agent and how it can help you while developing ABP-based solutions. + +Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. + +## About the Next Version + +The next feature version will be 10.6. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problems with this version. diff --git a/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/cover-image.png b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/cover-image.png new file mode 100644 index 0000000000..2fb7ca6dd3 Binary files /dev/null and b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/cover-image.png differ diff --git a/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/upgrade-abp-packages.png b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/upgrade-abp-packages.png new file mode 100644 index 0000000000..4ec1d19589 Binary files /dev/null and b/docs/en/Blog-Posts/2026-06-30 v10_5_Release_Stable/upgrade-abp-packages.png differ diff --git a/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md b/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md new file mode 100644 index 0000000000..f7203e5dce --- /dev/null +++ b/docs/en/Blog-Posts/2026-07-06-ABP-Summer-Campaign/post.md @@ -0,0 +1,55 @@ +Summer is here, and so is one of the best times to start building with ABP. + +From **July 6 to July 20**, we're offering exclusive summer savings on **ABP licenses and renewals**. Save **20% on new licenses** or **10% on license renewals**, and receive **up to $300 in AI credits** to power the **ABP AI Agent** in **ABP Studio**. + +Whether you're starting a new project or upgrading your development workflow, this campaign helps you save on your license while accelerating development with AI. + +### **What's Included?** + +**During the campaign period, you'll receive:** + +* **20% off new ABP licenses** +* **10% off license renewals** +* **Up to $300 in AI credits** for the **ABP AI Agent** + +The AI credits can be used with the **ABP AI Agent** in **ABP Studio**, allowing you to automate repetitive development tasks and build applications faster. + +### **Build Faster with the ABP AI Agent** + +The ABP AI Agent is designed specifically for ABP developers. Rather than acting as a generic coding assistant, it understands your ABP solution and helps automate common development workflows. + +With the included AI credits, you can: + +* Generate application features with AI assistance +* Create entities, services, and UI components faster +* Run automated development workflows +* Generate database migrations and update projects +* Inspect exceptions and troubleshoot issues +* Execute development tasks directly from ABP Studio + +The result is less time spent on repetitive work and more time focused on building your application's business value. + +### **Why Choose ABP?** + +ABP is a complete application development platform for building modern, maintainable, and scalable .NET applications. + +With ABP, you can: + +* Build enterprise-grade ASP.NET Core applications faster +* Follow Domain-Driven Design (DDD) and clean architecture principles +* Develop modular, reusable, and maintainable application modules +* Leverage built-in capabilities such as multi-tenancy, authentication, authorization, localization, auditing, and more +* Scale from modular monoliths to microservice architectures +* Boost developer productivity with ABP Studio and the ABP AI Agent + +Instead of spending weeks building common infrastructure, your team can focus on delivering business value and shipping features faster. + +## **Don't Miss This Limited-Time Offer** + +This campaign is available **only between July 6 and July 20**. + +Whether you're purchasing your first ABP license or renewing your existing one, now is the perfect time to save. Get **20% off new licenses** or **10% off renewals**, plus receive **up to $300 in AI credits** to accelerate development with the **ABP AI Agent**. + +**Claim your summer discount before July 20 and start building faster with ABP.** + +**Get your discount now:** [https://abp.io/pricing](https://abp.io/pricing) diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md new file mode 100644 index 0000000000..ec8e9257c9 --- /dev/null +++ b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/POST.md @@ -0,0 +1,180 @@ +# ABP Platform 10.6 RC Has Been Released + +We are happy to release [ABP](https://abp.io) version **10.6 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version. + +Try this version and provide feedback for a more stable version of ABP v10.6! Thanks to you in advance. + +## Get Started with the 10.6 RC + +You can check the [Get Started page](https://abp.io/get-started) to see how to get started with ABP. You can either download [ABP Studio](https://abp.io/get-started#abp-studio-tab) (**recommended**, if you prefer a user-friendly GUI application - desktop application) or use the [ABP CLI](https://abp.io/docs/latest/cli). + +By default, ABP Studio uses stable versions to create solutions. Therefore, if you want to create a solution with a preview version, first you need to create a solution and then switch your solution to the preview version from the ABP Studio UI: + +![studio-switch-to-preview](studio-switch-to-preview.png) + +## Migration Guide + +You can check the migration guide if you are upgrading from v10.5 or earlier: [ABP Version 10.6 Migration Guide](https://abp.io/docs/10.6/release-info/migration-guides/abp-10-6). + +## What's New with ABP v10.6? + +In this section, I will introduce some major features released in this version. +Here is a brief list of titles explained in the next sections: + +- Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention +- API Definition and Proxy Improvements for Content Types and Multipart Uploads +- Angular UI: Upgrade to Angular 22 +- Antiforgery and OpenIddict Security Improvements +- OpenIddict: Generate Access Token from the UI +- Dependency Updates + +### Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention + +ABP v10.6 adds three opt-in enhancements to the default background job worker. All of them are disabled by default, so existing applications keep the current behavior unless you enable them explicitly. + +**Storing successful jobs** + +By default, a job is deleted as soon as it runs successfully. You can now set `StoreSuccessfulJobs = true` to keep completed jobs in the store. A new `CompletionTime` column marks completed jobs, and a cleanup worker prunes them after `SuccessfulJobRetentionTime` (default: 7 days). + +**Dedicated workers per job type** + +`AddDedicatedWorker(...)` registers a worker that processes only the configured job argument types, each with its own distributed lock. The default worker continues handling all remaining job types. + +**Parallel job execution** + +Set `MaxParallelJobExecutionCount` greater than 1 to execute multiple jobs in the same poll cycle. In this mode, each job is claimed with its own distributed lock so different application instances can process different jobs concurrently without running the same job twice. + +Example configuration: + +```csharp +Configure(options => +{ + options.StoreSuccessfulJobs = true; + options.SuccessfulJobRetentionTime = TimeSpan.FromDays(30); + + options.AddDedicatedWorker("NotificationWorkerLock"); + options.AddDedicatedWorker("ReportWorkerLock"); + + options.MaxParallelJobExecutionCount = 4; +}); +``` + +These options are useful when you need better isolation between job types, higher throughput in clustered deployments, or an audit trail of successfully completed jobs. + +> See the [Background Jobs](https://abp.io/docs/10.6/framework/infrastructure/background-jobs) documentation and [#25742](https://github.com/abpframework/abp/pull/25742) for details. + +### API Definition and Proxy Improvements for Content Types and Multipart Uploads + +ABP v10.6 improves API definition generation and client proxies for file upload scenarios and non-JSON response types. + +The API definition now exposes response `ContentTypes` and an `IsRemoteStream` flag. C#, jQuery, and Angular proxies can use the declared media type instead of collapsing everything to `application/json` and `text/plain`. + +For upload DTOs containing `IRemoteStreamContent`, generated Angular and jQuery proxies now forward `FormData` as multipart requests instead of silently dropping the file payload or trying to serialize the stream as JSON. + +Server-side setup still follows the existing ABP pattern: + +```csharp +Configure(options => +{ + options.ConventionalControllers.FormBodyBindingIgnoredTypes.Add(typeof(UploadFileDto)); +}); +``` + +Angular client example after proxy regeneration: + +```typescript +const fd = new FormData(); +fd.append('Name', 'logo'); +fd.append('File', fileInput.files[0], 'logo.png'); +this.fileService.uploadFile(fd).subscribe(result => ...); +``` + +This closes long-standing gaps in generated proxies for stream-based uploads and improves support for text, blob, and custom response types. + +> See [#25639](https://github.com/abpframework/abp/pull/25639) for details. + +### Angular UI: Upgrade to Angular 22 + +ABP v10.6 upgrades the Angular UI stack to **Angular 22.0.x**. + +This release also improves the locale loading mechanism with a fallback path, so culture resources load more reliably when optional locale files are missing or partially available. + +If you maintain a custom Angular UI on top of ABP, plan for the Angular 22 upgrade together with your ABP package update and regenerate proxies after upgrading. + +> See [#25690](https://github.com/abpframework/abp/pull/25690) and [#25734](https://github.com/abpframework/abp/pull/25734) for details. + +### Antiforgery and OpenIddict Security Improvements + +ABP v10.6 includes several security-focused fixes for mixed authentication scenarios. + +**Antiforgery claim issuer normalization** + +When an application serves a token-authenticated SPA and cookie-authenticated MVC pages on the same origin, antiforgery validation could fail because the user id claim issuer differed between JWT and cookie authentication schemes. ABP now normalizes the user id claim issuer while generating and validating antiforgery tokens. + +This behavior is enabled by default through `AbpAntiForgeryOptions.NormalizeUserIdClaimIssuer`. Razor Pages antiforgery validation was also aligned with the same normalization logic, which fixes failures in modules such as Setting Management. + +**Prevent OpenIddict `client_id` from leaking into the interactive auth cookie** + +ABP fixed a case where an OpenIddict authorization request could stamp the requested `client_id` into the interactive authentication cookie during security-stamp refresh. That could corrupt audit logs and make later cookie-authenticated requests appear to belong to the OAuth client. + +The fix strips `client_id` when the interactive cookie is refreshed. Tokens are unaffected, and cookies that were already corrupted self-heal on the next refresh. + +**Forward the current access token for authenticated client requests** + +`HttpContextAbpAccessTokenProvider` now forwards the incoming access token whenever the request is authenticated, including `client_credentials` requests. This prevents unnecessary fallback to configured identity clients in machine-to-machine scenarios. + +> See [#25655](https://github.com/abpframework/abp/pull/25655), [#25669](https://github.com/abpframework/abp/pull/25669), [#25711](https://github.com/abpframework/abp/pull/25711), and [#25740](https://github.com/abpframework/abp/pull/25740) for details. + +### OpenIddict: Generate Access Token from the UI + +ABP Commercial v10.6 RC adds a **Generate Access Token** action to OpenIddict application management pages across MVC, Blazor, MudBlazor, and Angular UIs. + +Administrators can request a token for an OpenIddict application directly from the UI. The backend forwards a `client_credentials` request to `/connect/token` and returns the generated access token to the caller. + +This is especially useful for testing integrations, validating scopes, and troubleshooting machine-to-machine authentication without leaving the admin UI. + +### Dependency Updates + +ABP v10.6 RC includes several dependency and package updates: + +- Angular packages upgraded to **22.0.x** +- `Microsoft.*` and `System.*` packages upgraded to **10.0.9** +- `Microsoft.Data.SqlClient` upgraded to **7.0.2** +- `Swashbuckle.AspNetCore` upgraded to **10.2.3** + +> Check the [Package Version Changes](https://abp.io/docs/10.6/package-version-changes) document for all updates. + +### Other Improvements and Enhancements + +- **Permission management**: Skip dynamic permission initialization during migration runs to avoid noisy logs when the database is unavailable ([#25743](https://github.com/abpframework/abp/pull/25743)). +- **Security / principal access**: `ThreadCurrentPrincipalAccessor` now returns an anonymous principal instead of `null` in non-web contexts ([#25752](https://github.com/abpframework/abp/pull/25752)). +- **Angular proxy generation**: Array parameters are now generated as `readonly` in Angular proxies ([#25687](https://github.com/abpframework/abp/pull/25687)). +- **Date/time normalization**: Removed misleading warnings when normalizing `Unspecified` `DateTime` values near range boundaries ([#25703](https://github.com/abpframework/abp/pull/25703)). +- **AI Management**: Indexing is more resilient under memory pressure in the commercial module. + +## Community News + +### New ABP Community Articles + +As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: + +- [ABP 10.5.0 Expands Blazor UI Options with MudBlazor Support](https://abp.io/community/articles/abp-10.5.0-expands-blazor-ui-options-with-mudblazor-support-03rzmlpm) by [Liming Ma](https://abp.io/community/members/maliming) +- [Angular 22 State Management: Signals, SignalStore, or NgRx?](https://abp.io/community/articles/angular-22-state-management-signals-signalstore-or-ngrx-yq8zg0nw) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus) +- [Working with Dapr Workflows in the ABP Framework](https://abp.io/community/articles/working-with-dapr-workflows-in-the-abp-framework-6476or18) by [Engincan Veske](https://abp.io/community/members/EngincanV) +- [My Speaker's View of CONVEX Summit 2026](https://abp.io/community/articles/my-speakers-view-of-convex-summit-2026-ai-net-conference-3uk6ln1l) by [Alper Ebiçoğlu](https://abp.io/community/members/alper) + +Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. + +### ABP Summer Campaign: Get Up To 20% Off + $300 in AI Credits + +![summer-sale](summer-sale.png) + +Summer is a great time to start building with ABP. From **July 6 to July 20**, we're offering exclusive summer savings on **ABP licenses and renewals**: **20% off new licenses**, **10% off renewals**, and **up to $300 in AI credits** for the **ABP AI Agent** in **ABP Studio**. Whether you're starting a new project or upgrading your development workflow, this limited-time offer helps you save on your license while accelerating development with AI. + +> You can read the announcement here: [ABP Summer Campaign: Get Up To 20% Off + $300 in AI Credits](https://abp.io/community/announcements/abp-summer-campaign-get-up-to-20-off-300-in-ai-credits-r5lqtpg9). + +## Conclusion + +This version comes with some new features and a lot of enhancements to the existing features. You can see the [Road Map](https://abp.io/docs/10.6/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.6 RC and provide feedback to help us release a more stable version. + +Thanks for being a part of this community! \ No newline at end of file diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png new file mode 100644 index 0000000000..efb4466e5d Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/cover-image.png differ diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png new file mode 100644 index 0000000000..ad73877834 Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/studio-switch-to-preview.png differ diff --git a/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png new file mode 100644 index 0000000000..f8c5163e45 Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-07 v10_6_Preview/summer-sale.png differ diff --git a/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/POST.md b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/POST.md new file mode 100644 index 0000000000..0bd7f03378 --- /dev/null +++ b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/POST.md @@ -0,0 +1,80 @@ +# ABP.IO Platform 10.6 Final Has Been Released! + +We are glad to announce that [ABP](https://abp.io/) 10.6 stable version has been released. + +## What's New With Version 10.6? + +All the new features were explained in detail in the [10.6 RC Announcement Post](https://abp.io/community/announcements/abp-platform-10.6-rc-has-been-released-reoq6kzw), so there is no need to review them all again. You can check it out for more details. + +Here are some of the highlights of this version: + +- Background jobs now support dedicated workers, parallel execution, and successful job retention. +- API definition and generated proxies have better support for response content types, remote streams, and multipart uploads. +- Angular UI packages and templates have been upgraded to Angular 22. +- Antiforgery and OpenIddict flows include important security and reliability improvements. +- ABP Commercial adds OpenIddict access-token generation from the UI and React CRUD page generation support in ABP Suite. +- AI Management indexing is more resilient for large or memory-constrained workloads. +- The final release also includes dependency updates and stability fixes collected during the RC period. + +## Getting Started with 10.6 + +### How to Upgrade an Existing Solution + +You can upgrade your existing solutions with either ABP Studio or ABP CLI. In the following sections, both approaches are explained: + +### Upgrading via ABP Studio + +If you are already using the ABP Studio, you can upgrade it to the latest version. ABP Studio periodically checks for updates in the background, and when a new version of ABP Studio is available, you will be notified through a modal. Then, you can update it by confirming the opened modal. See [the documentation](https://abp.io/docs/latest/studio/installation#upgrading) for more info. + +After upgrading the ABP Studio, then you can open your solution in the application, and simply click the **Upgrade ABP Packages** action button to instantly upgrade your solution: + +![](upgrade-abp-packages.png) + +### Upgrading via ABP CLI + +Alternatively, you can upgrade your existing solution via ABP CLI. First, you need to install the ABP CLI or upgrade it to the latest version. + +If you haven't installed it yet, you can run the following command: + +```bash +dotnet tool install -g Volo.Abp.Studio.Cli +``` + +Or to update the existing CLI, you can run the following command: + +```bash +dotnet tool update -g Volo.Abp.Studio.Cli +``` + +After installing/updating the ABP CLI, you can use the [`update` command](https://abp.io/docs/latest/CLI#update) to update all the ABP related NuGet and NPM packages in your solution as follows: + +```bash +abp update +``` + +You can run this command in the root folder of your solution to update all ABP related packages. + +## Migration Guides + +This version includes explicitly marked migration-impacting changes for specific customization scenarios, especially custom background job stores/workers and custom AI Management document chunk repositories. The new background job runtime features are opt-in and existing applications keep the current behavior unless they enable them explicitly. + +Please read the migration guide carefully, if you are upgrading from v10.5 or earlier versions: [ABP Version 10.6 Migration Guide](https://abp.io/docs/10.6/release-info/migration-guides/abp-10-6) + +If you use the Angular UI, also check the dedicated [Angular 22 and ABP 10.6 Upgrade Guide](https://abp.io/docs/10.6/release-info/migration-guides/abp-10-6-angular-22). + +## Community News + +### New ABP Community Articles + +As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: + +- [Introducing ABP Low-Code: Build Real ABP Apps in Minutes](https://abp.io/community/announcements/introducing-abp-lowcode-build-real-abp-apps-in-minutes-647ymozi) by [Salih Ozkara](https://abp.io/community/members/salih) +- [Building a Vendor Onboarding Workflow with ABP Low-Code](https://abp.io/community/articles/building-a-vendor-onboarding-workflow-with-abp-lowcode-1wx0ckzc) by [Salih Ozkara](https://abp.io/community/members/salih) +- [Event Recap - WeAreDevelopers World Congress 2026](https://abp.io/community/articles/event-recap-wearedevelopers-world-congress-2026-v59t8vfn) by [Irem Demirci](https://abp.io/community/members/iremdemirci) +- [Empathy in the Workplace for Software Companies](https://abp.io/community/articles/empathy-in-the-workplace-for-software-companies-wsjjw9we) by [Alper Ebicoglu](https://abp.io/community/members/alper) + +Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. + +## About the Next Version + +The next feature version will be 10.7. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problems with this version. diff --git a/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/cover-image.png b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/cover-image.png new file mode 100644 index 0000000000..a1583a17f9 Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/cover-image.png differ diff --git a/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/upgrade-abp-packages.png b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/upgrade-abp-packages.png new file mode 100644 index 0000000000..4ec1d19589 Binary files /dev/null and b/docs/en/Blog-Posts/2026-07-27 v10_6_Release_Stable/upgrade-abp-packages.png differ diff --git a/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/Post.md b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/Post.md new file mode 100644 index 0000000000..8dcde9d11a --- /dev/null +++ b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/Post.md @@ -0,0 +1,581 @@ +Multi-tenancy sounds simple at first: one application, many customers. In practice, it changes how you design data access, authentication, configuration, monitoring, migrations, and even support workflows. + +If you get it right, you can serve many organizations efficiently from one platform. If you get it wrong, you risk the worst kind of bug in SaaS: one tenant seeing another tenant's data. + +This article explains how to implement multi-tenancy in a practical way, starting from the core patterns and ending with an ABP-based implementation approach. The goal is not just to define multi-tenancy, but to help you choose the right model and avoid the mistakes that usually show up after launch. + +## What multi-tenancy actually means + +A tenant is usually a customer organization, company, school, or business unit using your application. Multi-tenancy means multiple tenants use the same application platform while remaining isolated from each other. + +That isolation can include: + +- Data isolation +- Authentication and authorization boundaries +- Feature differences per tenant +- Performance protection +- Audit and compliance boundaries + +It is useful to separate two ideas that are often mixed up: + +- **Multi-tenant application**: one application instance serves many tenants +- **Multi-instance deployment**: each customer gets a separate deployment + +Multi-instance is simpler from an isolation perspective, but more expensive to operate. Multi-tenancy is usually more efficient, but it requires stronger architectural discipline. + +## Choose your data isolation model first + +Most multi-tenancy decisions become easier once you choose the data isolation model. There are three common patterns. + +### 1. Shared database, shared schema + +This is the most common starting point. All tenants use the same tables, and each tenant-owned row includes a `TenantId`. + +Example: + +```sql +Orders +- Id +- TenantId +- CustomerId +- TotalAmount +- CreationTime +``` + +Every query must be tenant-aware. + +**Pros** + +- Lowest infrastructure cost +- Simplest provisioning +- One migration path +- Fastest way to launch + +**Cons** + +- Highest risk of accidental data leakage if tenant filtering is missed +- Noisy neighbor problems are more likely +- Harder to isolate performance-heavy tenants +- Compliance requirements may rule it out + +**Best for** + +- MVPs +- Early-stage SaaS products +- Products with many small tenants +- Teams that want operational simplicity first + +### 2. Shared database, separate schema per tenant + +In this model, tenants share the same database server, but each tenant has its own schema. + +For example: + +- `tenant_a.Orders` +- `tenant_b.Orders` + +**Pros** + +- Better separation than shared schema +- Easier tenant-specific backup and restore +- Lower leakage risk than row-level isolation alone + +**Cons** + +- Migrations become harder +- Schema drift becomes a real risk +- Managing hundreds of schemas gets messy +- Connection and routing logic becomes more complex + +**Best for** + +- Moderate compliance needs +- Tens to a few hundreds of tenants +- Teams that need more isolation without going full database-per-tenant + +### 3. Database per tenant + +Each tenant gets its own database, and sometimes even its own server. + +**Pros** + +- Strongest isolation +- Easier compliance story +- Easier tenant-specific backup, restore, and residency rules +- Better performance isolation +- Easier to support premium tenants with custom SLAs + +**Cons** + +- Higher cost +- More provisioning automation required +- More complex migrations and monitoring +- Operational overhead grows quickly + +**Best for** + +- Enterprise SaaS +- Regulated industries +- High-value tenants +- Cases where data residency or strict isolation matters + +## How to choose the right pattern + +There is no universally correct model. The right choice depends on trade-offs. + +Use these questions to decide: + +- How many tenants do you expect in 6 to 24 months? +- Do you have compliance requirements like HIPAA, GDPR residency constraints, or strict audit demands? +- How much tenant customization do you need? +- Can your team operate many databases reliably? +- What happens if one tenant runs expensive reports all day? +- Do you need tenant-specific backup and restore? + +A practical rule: + +- Start with **shared schema** if speed and cost matter most +- Use **database per tenant** if isolation and compliance matter most +- Use a **hybrid model** if your tenant base is mixed + +A hybrid model is common in real SaaS systems: + +- Small tenants live in shared infrastructure +- Large enterprise tenants get dedicated databases + +That gives you a better cost curve without forcing every customer into the most expensive setup. + +## The core building blocks of a multi-tenant system + +Regardless of the storage model, most implementations need the same building blocks. + +### Tenant resolution + +Before your application can isolate anything, it must know which tenant the request belongs to. + +Common tenant resolution strategies: + +- Subdomain: `acme.yourapp.com` +- Custom domain: `portal.acme.com` +- Header: `X-Tenant-Id` +- JWT claim or token metadata +- URL segment: `/t/acme/orders` + +Subdomain-based resolution is usually the cleanest for SaaS products. Header-based resolution is common for internal APIs but easier to misuse if not protected. + +The important part is consistency. Resolve the tenant early in the request pipeline and make it available everywhere else. + +### Tenant context + +Once resolved, store the active tenant in a tenant context that application services, repositories, caches, and logs can access. + +Typical tenant context data includes: + +- Tenant ID +- Tenant name or slug +- Connection string or database mapping +- Enabled features +- Region or residency info + +### Data filtering + +If you use shared tables, tenant filtering must be automatic. Do not rely on developers remembering to add `where TenantId == currentTenantId` in every query. + +This is where frameworks matter. ABP helps a lot here because it has built-in multi-tenancy support and data filters for tenant-aware entities. + +### Tenant-aware configuration + +Sooner or later, tenants will ask for differences: + +- Feature A enabled only for premium plans +- Different email templates +- Different password policies +- Different integrations +- Different branding + +Do not solve this with `if (tenant == ...)` scattered across the codebase. + +Use: + +- Feature flags +- Tenant settings +- Edition or plan-based configuration +- Modular integration points + +### Tenant-aware logging and auditing + +Every log entry and audit event should include tenant context where possible. + +That makes it much easier to answer questions like: + +- Which tenant triggered this error? +- Which tenant is causing high database load? +- Who accessed this record? +- Which tenant had failed background jobs? + +## Implementing multi-tenancy in ASP.NET Core + +If you build multi-tenancy manually in ASP.NET Core, the implementation usually follows this flow: + +1. Resolve tenant from the request +2. Load tenant metadata from a tenant store +3. Set current tenant context +4. Route database access based on tenant +5. Apply tenant filters automatically +6. Include tenant info in logs, cache keys, and background jobs + +A minimal tenant resolver middleware might look like this: + +```csharp +public class TenantResolutionMiddleware +{ + private readonly RequestDelegate _next; + + public TenantResolutionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, ICurrentTenantAccessor tenantAccessor) + { + var host = context.Request.Host.Host; + var subdomain = host.Split('.').FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(subdomain) && subdomain != "www") + { + tenantAccessor.CurrentTenantId = await ResolveTenantIdAsync(subdomain); + } + + await _next(context); + } + + private Task ResolveTenantIdAsync(string subdomain) + { + return Task.FromResult(null); + } +} +``` + +That is only the beginning. The hard part is making the rest of the application consistently tenant-aware. + +For example, your repository layer must avoid this kind of bug: + +```csharp +var orders = await _dbContext.Orders + .Where(x => x.Status == OrderStatus.Pending) + .ToListAsync(); +``` + +In a shared-schema model, that query is dangerous because it ignores tenant isolation. + +Safer approaches include: + +- Global query filters +- Tenant-aware repositories +- Row-level security at the database level +- Framework-level multi-tenancy abstractions + +## Implementing multi-tenancy with ABP + +ABP is a strong fit for multi-tenant business applications because multi-tenancy is built into the framework rather than bolted on later. + +ABP gives you several useful pieces out of the box: + +- Tenant resolution pipeline +- `ICurrentTenant` abstraction +- Multi-tenant entity support via `IMultiTenant` +- Data filters for tenant isolation +- Tenant management module +- Per-tenant connection string support +- Feature and setting systems + +### Tenant-aware entities + +In ABP, tenant-owned entities typically implement `IMultiTenant`. + +```csharp +using System; +using Volo.Abp.MultiTenancy; + +public class Product : IMultiTenant +{ + public Guid Id { get; set; } + public Guid? TenantId { get; set; } + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } +} +``` + +This matters because ABP can automatically apply tenant filters for entities that belong to a tenant. + +### Accessing the current tenant + +ABP exposes the current tenant through `ICurrentTenant`. + +```csharp +public class ProductAppService : ApplicationService +{ + private readonly IRepository _productRepository; + private readonly ICurrentTenant _currentTenant; + + public ProductAppService( + IRepository productRepository, + ICurrentTenant currentTenant) + { + _productRepository = productRepository; + _currentTenant = currentTenant; + } + + public async Task> GetListAsync() + { + var products = await _productRepository.GetListAsync(); + return ObjectMapper.Map, List>(products); + } +} +``` + +In a tenant context, ABP automatically scopes the repository query to the current tenant for multi-tenant entities. + +That is exactly the kind of default you want in a multi-tenant system: safe behavior unless you intentionally opt out. + +### Switching tenant context + +Background jobs, admin tools, and migration workflows sometimes need to operate in a specific tenant context. + +ABP supports this with `ICurrentTenant.Change(...)`. + +```csharp +using (_currentTenant.Change(tenantId)) +{ + var count = await _productRepository.GetCountAsync(); +} +``` + +This is useful, but it should be used carefully. Tenant context switching is powerful and easy to abuse if you do not keep boundaries clear. + +### Per-tenant connection strings + +If you choose database-per-tenant, ABP supports tenant-specific connection strings. That lets you keep the same application code while routing different tenants to different databases. + +This is one of the biggest practical advantages of using ABP for multi-tenancy: you can start simple and evolve toward stronger isolation without rewriting your entire application model. + +## Shared schema vs database per tenant in ABP + +ABP supports both host and tenant concepts, which makes it flexible enough for different SaaS stages. + +### Shared schema with ABP + +This is usually the easiest starting point. + +Use it when: + +- You want fast delivery +- Your tenants are relatively small +- Compliance requirements are moderate +- Your team wants simpler operations + +What to watch: + +- Ensure all tenant-owned entities implement `IMultiTenant` +- Be careful with raw SQL and custom queries +- Include `TenantId` in indexes where needed +- Test cross-tenant isolation aggressively + +### Database per tenant with ABP + +Use it when: + +- You need stronger isolation +- You have enterprise customers +- You need tenant-specific restore or residency +- You expect some tenants to be much larger than others + +What to watch: + +- Automate provisioning +- Automate migrations across tenant databases +- Monitor connection usage and migration failures +- Keep tenant metadata accurate and centralized + +## Migrations and schema management + +This is where many multi-tenant systems become painful. + +With shared schema, migrations are straightforward: apply once. + +With schema-per-tenant or database-per-tenant, you need orchestration. + +A practical migration workflow includes: + +- Track tenant inventory centrally +- Apply migrations in batches +- Record migration status per tenant +- Retry safely on failure +- Alert on drift +- Avoid manual one-off fixes unless documented and automated later + +If you have 5 tenants, manual migration might feel acceptable. If you have 500, it becomes an operational risk. + +For ABP-based systems, treat tenant database migration as a first-class operational workflow, not an afterthought. + +## Security pitfalls to avoid + +Multi-tenancy failures are usually not caused by the concept itself. They are caused by inconsistent enforcement. + +The most common mistakes are: + +### 1. Missing tenant filters + +This is the classic bug. One query forgets tenant scoping, and data leaks. + +Reduce the risk with: + +- Framework-level filters +- Repository abstractions +- Database row-level security where appropriate +- Integration tests that verify isolation + +### 2. Unsafe admin features + +Support tools, exports, reporting endpoints, and background jobs often bypass normal application flows. That makes them common leakage points. + +Treat admin code as high-risk code. + +### 3. Tenant context lost in async or background processing + +If a background job processes tenant data, it must explicitly carry tenant context. + +Do not assume the request context still exists. + +### 4. Shared cache keys + +If your cache key is just `product-list`, you already have a bug. + +Use tenant-aware cache keys such as: + +```text +tenant:{tenantId}:product-list +``` + +### 5. Weak audit trails + +When something goes wrong, you need to know: + +- Which tenant was affected +- Which user triggered the action +- Which service handled it +- Which data store was involved + +Without tenant-aware auditing, incident response becomes much harder. + +## Performance and noisy neighbor control + +Shared infrastructure saves money, but it also creates contention. + +A single tenant can hurt others through: + +- Expensive reports +- Large imports +- Chatty integrations +- Poorly indexed queries +- Background jobs running at the wrong time + +Mitigations include: + +- Indexing by `TenantId` +- Query timeouts +- Rate limiting per tenant +- Queue isolation for heavy jobs +- Read replicas for reporting +- Partitioning large tables +- Moving heavy tenants to dedicated databases + +This is why hybrid multi-tenancy is so common. It gives you an escape hatch when one tenant outgrows the shared model. + +## A practical implementation plan + +If you are building a new SaaS product, this is a sensible rollout path. + +### Phase 1: Start with shared schema + +- Resolve tenant from subdomain or domain +- Store tenant metadata centrally +- Use framework-level tenant filters +- Make all tenant-owned entities explicit +- Add tenant-aware logging, caching, and auditing + +For ABP, this is usually the fastest path because the framework already supports the core abstractions. + +### Phase 2: Add tenant configuration and feature management + +- Per-tenant settings +- Feature flags +- Plan-based capabilities +- Branding and integration settings + +This keeps customization manageable without branching the codebase. + +### Phase 3: Prepare for tenant mobility + +Even if you start with shared schema, design for future migration. + +That means: + +- Stable tenant IDs +- Export/import or replication strategy +- Clear ownership boundaries in data +- No hidden cross-tenant joins + +### Phase 4: Move selected tenants to dedicated databases + +Use an expand-backfill-contract approach: + +- **Expand**: create the target database +- **Backfill**: copy tenant data +- **Dual-write**: temporarily write to both stores if needed +- **Contract**: switch traffic and retire the old location + +This is much safer than a big-bang migration. + +## When to use multi-tenancy and when not to + +### When to use multi-tenancy + +- You are building a SaaS platform for many organizations +- Tenants share most application behavior +- Operational efficiency matters +- You want centralized upgrades and deployment +- You need a scalable commercial model + +### When NOT to use multi-tenancy + +- Every customer needs deep infrastructure-level customization +- Compliance requires strict physical isolation from day one +- Your team cannot support the operational complexity safely +- You only have a few large customers and each behaves like a separate product + +In those cases, multi-instance deployment may be the better choice. + +## Final recommendations + +If you are unsure where to start, start simpler than you think, but not sloppier than you can afford. + +That usually means: + +- Shared schema first +- Strong tenant resolution +- Automatic tenant filtering +- Tenant-aware logs, cache, and jobs +- A migration path to dedicated databases later + +ABP is especially useful here because it gives you the right primitives early: current tenant context, tenant-aware entities, filters, settings, and connection string support. That reduces the amount of custom plumbing you need to build and maintain. + +The biggest mistake is not choosing the wrong pattern. It is choosing a pattern without planning how tenant isolation will be enforced everywhere. + +## TL;DR + +- Multi-tenancy is mostly about safe isolation of data, behavior, and operations across customers. +- Shared schema is the easiest starting point; database-per-tenant gives the strongest isolation but adds operational cost. +- In ASP.NET Core, tenant resolution, tenant context, automatic filtering, and tenant-aware caching/logging are essential. +- ABP makes multi-tenancy easier with `ICurrentTenant`, `IMultiTenant`, data filters, tenant management, and per-tenant connection strings. +- Design for evolution early so large tenants can move to dedicated databases without a painful rewrite. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/cover.png b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/cover.png new file mode 100644 index 0000000000..2fde29d221 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-1.png b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-1.png new file mode 100644 index 0000000000..47af7d55fd Binary files /dev/null and b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-2.png b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-2.png new file mode 100644 index 0000000000..a3aebbdbf7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-3.png b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-3.png new file mode 100644 index 0000000000..0f96c0835a Binary files /dev/null and b/docs/en/Community-Articles/2026-06-03-how-to-implement-multitenancy-in-asp.net-core-and-abp/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/Post.md b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/Post.md new file mode 100644 index 0000000000..909aed4a3a --- /dev/null +++ b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/Post.md @@ -0,0 +1,1713 @@ +# Implementing Multi-Tenancy in ABP Framework: A Complete Practical Guide + +Multi-tenancy is one of those architectural decisions that looks simple in a slide deck and becomes very real the moment your SaaS application gets its second serious customer. + +At that point, questions start piling up: + +- How do you isolate tenant data safely? +- How do you resolve the current tenant from a request? +- Should all tenants share one database, or should large customers get dedicated databases? +- How do authentication, background jobs, caching, and seeding behave in a tenant-aware system? + +ABP Framework gives you a strong foundation for all of this. Instead of building tenant resolution, data filters, tenant-aware repositories, and host/tenant boundaries from scratch, you get a consistent multi-tenancy model integrated into the framework. + +This guide explains both the concepts and the implementation details. We will start with the architecture, then move into configuration, entity design, tenant resolution, seeding, authentication, database-per-tenant setups, and advanced production concerns. Along the way, we will build a practical SaaS CRM example to show how the pieces fit together. + +## What Multi-Tenancy Means in Practice + +Multi-tenancy means a single application serves multiple customers, where each customer is a tenant. Those tenants share the application platform, but their data, configuration, users, and operational boundaries must remain isolated. + +In ABP terms: + +- A **tenant** is typically a customer organization using your SaaS product. +- The **host** is the application owner or platform operator. +- In host context, `CurrentTenant` is `null`. +- In tenant context, `CurrentTenant.Id` identifies the active tenant. + +### Single-tenant vs multi-tenant + +A **single-tenant** system usually means: + +- one deployment per customer, +- one database per customer, +- isolated infrastructure by default, +- higher operational cost. + +A **multi-tenant** system usually means: + +- one application serves many customers, +- data isolation is enforced logically or physically, +- lower operational overhead, +- more architectural responsibility. + +### Why SaaS teams care about multi-tenancy + +Multi-tenancy matters because it directly affects: + +- cost efficiency, +- onboarding speed, +- operational scalability, +- security boundaries, +- customization strategy, +- enterprise sales readiness. + +### Advantages + +- Lower infrastructure cost per customer +- Centralized deployment and upgrades +- Easier feature rollout +- Better operational consistency +- Faster tenant provisioning + +### Challenges + +- Strong data isolation is mandatory +- Noisy-neighbor performance issues can appear +- Authentication becomes tenant-aware +- Background processing must preserve tenant context +- Reporting across tenants requires deliberate design +- Database-per-tenant operations add migration complexity + +### Why ABP simplifies multi-tenant development + +ABP helps because multi-tenancy is not treated as an afterthought. It is built into the framework through: + +- `ICurrentTenant` +- `IMultiTenant` +- automatic data filters +- tenant resolution middleware +- tenant management abstractions +- tenant-aware repositories and unit of work integration +- support for shared database, database-per-tenant, and hybrid models + +That combination removes a lot of repetitive plumbing and reduces the chance of subtle isolation bugs. + +## Understanding ABP Multi-Tenancy Architecture + +ABP’s multi-tenancy architecture is centered around tenant context propagation. The framework resolves the tenant from the incoming request, stores it in the current execution context, and applies that context to repositories, filters, services, and modules. + +### Core concepts + +#### Tenant concept + +A tenant is a customer boundary. In a CRM SaaS product, each company using the system is a tenant. + +Examples: + +- `Acme Logistics` +- `Northwind Retail` +- `Contoso Health` + +Each tenant may have: + +- its own users, +- its own roles, +- its own settings, +- its own features, +- its own data, +- optionally its own database. + +#### Host side + +The host side is the platform owner context. + +Typical host responsibilities: + +- create and manage tenants, +- assign subscription plans, +- configure editions and features, +- monitor usage, +- run cross-tenant administration, +- manage billing and provisioning. + +In ABP, host-side entities often have `TenantId = null`. + +#### Tenant side + +The tenant side is the customer-facing application context. + +Typical tenant responsibilities: + +- manage tenant users, +- manage tenant roles, +- create business data, +- configure tenant settings, +- consume tenant-specific features. + +#### `ICurrentTenant` + +`ICurrentTenant` is the main runtime service for reading the active tenant context. + +It exposes: + +- `Id` +- `Name` +- `IsAvailable` + +This service is used everywhere from application services to background jobs. + +#### Tenant resolution pipeline + +ABP resolves the tenant before your application logic runs. It uses configured contributors such as: + +- current user claims, +- query string, +- route values, +- headers, +- domain or subdomain, +- custom resolvers. + +Then `app.UseMultiTenancy()` sets the current tenant context. + +#### `IMultiTenant` + +Entities implementing `IMultiTenant` gain a `Guid? TenantId` property. ABP uses this to apply automatic filtering. + +If `TenantId` is: + +- a tenant id: the entity belongs to that tenant, +- `null`: the entity belongs to the host side. + +#### Data filters + +ABP automatically filters `IMultiTenant` entities so tenant queries only see their own data by default. + +This is one of the most important safety features in the framework. + +#### Tenant Management Module + +ABP provides tenant management infrastructure through its tenant management module and `ITenantStore` abstraction. + +This is used to: + +- store tenant definitions, +- resolve tenant configuration, +- retrieve tenant-specific connection strings, +- support tenant provisioning workflows. + +### Architecture diagram + +```mermaid +flowchart LR + A[Incoming HTTP Request] --> B[Tenant Resolution Contributors] + B --> C[Multi-Tenancy Middleware] + C --> D[ICurrentTenant] + D --> E[Application Service] + E --> F[Repository] + F --> G[Data Filter IMultiTenant] + G --> H[(Database)] +``` + +### Host and tenant boundaries + +```mermaid +flowchart TD + H[Host Context CurrentTenant = null] + T1[Tenant A Context] + T2[Tenant B Context] + + H --> HM[Tenant Management] + H --> FM[Feature Management] + H --> SM[Subscription Management] + + T1 --> D1[Tenant A Data] + T1 --> U1[Tenant A Users] + + T2 --> D2[Tenant B Data] + T2 --> U2[Tenant B Users] +``` + +### Query isolation flow + +```mermaid +sequenceDiagram + participant Req as Request + participant MW as Multi-Tenancy Middleware + participant CT as ICurrentTenant + participant Repo as Repository + participant DB as Database + + Req->>MW: Resolve tenant + MW->>CT: Set TenantId + CT->>Repo: Current tenant context available + Repo->>DB: SELECT ... WHERE TenantId = @CurrentTenantId + DB-->>Repo: Tenant-scoped rows +``` + +## Multi-Tenancy Models Supported by ABP + +ABP supports the three models most SaaS teams actually use: shared database, database per tenant, and hybrid. + +### 1) Single Database / Shared Database + +All tenants share the same physical database. Tenant-specific rows are separated by `TenantId`. + +#### How it works + +- One database stores host and tenant data. +- Tenant-aware entities implement `IMultiTenant`. +- ABP filters rows automatically. + +#### Advantages + +- Lowest infrastructure cost +- Simplest migrations +- Easier backup strategy +- Easier reporting across tenants +- Faster onboarding for small SaaS products + +#### Disadvantages + +- Weaker isolation than dedicated databases +- Noisy-neighbor risk +- Large tenants can affect shared performance +- Cross-tenant leak risk if filters are bypassed incorrectly + +### 2) Database Per Tenant + +Each tenant gets its own physical database. The host may also have a separate database. + +#### How it works + +- Tenant metadata is stored centrally or in a host database. +- `ITenantStore` resolves tenant configuration. +- Connection strings are selected dynamically per tenant. + +#### Advantages + +- Strong isolation +- Easier compliance conversations +- Better performance isolation +- Easier tenant-specific backup and restore +- Large tenants can scale independently + +#### Disadvantages + +- More operational overhead +- More complex migrations +- Harder cross-tenant analytics +- More secrets and connection strings to manage +- More provisioning automation required + +### 3) Hybrid Model + +Some tenants share a database, while larger or regulated tenants get dedicated databases. + +#### Enterprise SaaS scenarios + +This is often the most realistic model when: + +- small tenants are cost-sensitive, +- enterprise tenants require stronger isolation, +- some customers need regional or compliance-specific storage, +- you want a migration path from shared to dedicated databases. + +#### Advantages + +- Flexible cost model +- Better fit for mixed customer sizes +- Easier enterprise upsell path + +#### Disadvantages + +- Highest architectural complexity +- More operational branching +- More migration and observability work + +### Comparison table + +| Model | Isolation | Cost | Operational Complexity | Best For | +|---|---|---:|---:|---| +| Shared Database | Logical | Low | Low | Early-stage SaaS, many small tenants | +| Database Per Tenant | Physical | High | High | Enterprise SaaS, regulated workloads | +| Hybrid | Mixed | Medium to High | High | Growing SaaS with mixed tenant profiles | + +| Concern | Shared Database | Database Per Tenant | Hybrid | +|---|---|---|---| +| Migrations | Simple | Complex | Complex | +| Cross-tenant reporting | Easy | Harder | Mixed | +| Performance isolation | Limited | Strong | Selective | +| Tenant onboarding | Fast | Slower | Depends on tier | +| Compliance flexibility | Moderate | Strong | Strong | + +### When to use / When NOT to use + +#### Use shared database when + +- you are building an MVP or early SaaS, +- tenants are relatively small, +- cost efficiency matters more than hard isolation, +- your team wants simpler operations. + +#### Do not use shared database when + +- tenants require strict physical isolation, +- you expect very uneven tenant load, +- compliance or contractual requirements demand dedicated storage. + +#### Use database per tenant when + +- enterprise customers require isolation, +- you need tenant-specific backup/restore, +- large tenants justify dedicated infrastructure. + +#### Do not use database per tenant when + +- your team is not ready for migration automation, +- you have many tiny tenants and limited ops capacity, +- cross-tenant analytics is a core requirement and you have no aggregation strategy. + +#### Use hybrid when + +- you need both efficiency and enterprise flexibility, +- you want to move premium tenants to dedicated databases over time. + +#### Do not use hybrid when + +- your operational tooling is immature, +- your team is still validating the product and needs simplicity first. + +## Enabling Multi-Tenancy in ABP + +In most ABP solutions, multi-tenancy is enabled through a shared constant and framework options. + +### `MultiTenancyConsts` + +A typical template includes a constant like this: + +```csharp +namespace Acme.Crm; + +public static class MultiTenancyConsts +{ + public const bool IsEnabled = true; +} +``` + +This constant is often referenced across layers so the application has one central switch. + +### Configure `AbpMultiTenancyOptions` + +In your module: + +```csharp +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.IsEnabled = MultiTenancyConsts.IsEnabled; + options.UserSharingStrategy = TenantUserSharingStrategy.Isolated; + }); +} +``` + +`UserSharingStrategy` matters when you decide whether users are isolated per tenant or shared across tenants. + +### Configure middleware + +In your HTTP pipeline: + +```csharp +public override void OnApplicationInitialization(ApplicationInitializationContext context) +{ + var app = context.GetApplicationBuilder(); + var env = context.GetEnvironment(); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseAuthentication(); + app.UseMultiTenancy(); + app.UseAuthorization(); + + app.UseConfiguredEndpoints(); +} +``` + +The exact middleware order can vary by solution template, but `UseMultiTenancy()` must be present so tenant resolution runs. + +### Example appsettings.json + +For a shared database setup: + +```json +{ + "ConnectionStrings": { + "Default": "Server=localhost;Database=CrmShared;Trusted_Connection=True;TrustServerCertificate=True" + } +} +``` + +For a host database plus tenant metadata: + +```json +{ + "ConnectionStrings": { + "Default": "Server=localhost;Database=CrmHost;Trusted_Connection=True;TrustServerCertificate=True" + } +} +``` + +### Full module example + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm; + +[DependsOn( + typeof(AbpMultiTenancyModule) +)] +public class CrmHttpApiHostModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IsEnabled = MultiTenancyConsts.IsEnabled; + options.UserSharingStrategy = TenantUserSharingStrategy.Isolated; + }); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + var app = context.GetApplicationBuilder(); + + app.UseRouting(); + app.UseAuthentication(); + app.UseMultiTenancy(); + app.UseAuthorization(); + app.UseConfiguredEndpoints(); + } +} +``` + +## Tenant Resolution Strategies + +Tenant resolution is where multi-tenancy becomes real. The framework must determine which tenant the request belongs to before your application logic executes. + +ABP supports multiple strategies, and you can combine them. + +### Default resolution contributors + +ABP can resolve tenants from: + +- current user claims, +- query string `__tenant`, +- route values, +- custom contributors. + +### Configure tenant resolution + +```csharp +using Volo.Abp.AspNetCore.MultiTenancy; +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.AddDomainTenantResolver("{0}.mycrm.com"); + options.TenantResolvers.Add(new HeaderTenantResolveContributor()); + }); +} +``` + +### Subdomain resolution + +This is common in SaaS products: + +- `acme.mycrm.com` +- `northwind.mycrm.com` + +Configuration: + +```csharp +Configure(options => +{ + options.AddDomainTenantResolver("{0}.mycrm.com"); +}); +``` + +ABP extracts `acme` or `northwind` and uses it as the tenant identifier. + +### Domain resolution + +You can also map full domains to tenants, especially for enterprise customers using custom domains. + +Examples: + +- `crm.acme-enterprise.com` +- `portal.contosohealth.io` + +This usually requires custom tenant lookup logic or a domain mapping table in your tenant store. + +### Header-based resolution + +Useful for APIs, gateways, or internal service-to-service calls. + +Example custom contributor: + +```csharp +using System.Threading.Tasks; +using Volo.Abp.MultiTenancy; + +public class HeaderTenantResolveContributor : TenantResolveContributorBase +{ + public const string HeaderName = "X-Tenant"; + + public override string Name => "Header"; + + public override Task ResolveAsync(ITenantResolveContext context) + { + var httpContext = context.GetHttpContext(); + if (httpContext == null) + { + return Task.CompletedTask; + } + + var tenant = httpContext.Request.Headers[HeaderName].ToString(); + if (!tenant.IsNullOrWhiteSpace()) + { + context.TenantIdOrName = tenant; + } + + return Task.CompletedTask; + } +} +``` + +### Query string resolution + +Useful for testing and some integration scenarios. + +Example: + +- `/api/products?__tenant=acme` + +This is convenient, but usually not the best primary strategy for production browser apps. + +### Route-based resolution + +Example: + +- `/t/acme/products` + +This can work well for APIs or apps where tenant identity is part of the route structure. + +### Custom tenant resolver + +If your tenant identification depends on something domain-specific, implement your own contributor. + +Example: resolve tenant from an API key prefix. + +```csharp +using System.Threading.Tasks; +using Volo.Abp.MultiTenancy; + +public class ApiKeyTenantResolveContributor : TenantResolveContributorBase +{ + public override string Name => "ApiKey"; + + public override Task ResolveAsync(ITenantResolveContext context) + { + var httpContext = context.GetHttpContext(); + if (httpContext == null) + { + return Task.CompletedTask; + } + + var apiKey = httpContext.Request.Headers["X-Api-Key"].ToString(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Task.CompletedTask; + } + + if (apiKey.StartsWith("acme_")) + { + context.TenantIdOrName = "acme"; + } + + return Task.CompletedTask; + } +} +``` + +### HTTP request flow + +```mermaid +sequenceDiagram + participant C as Client + participant R as Tenant Resolver Contributors + participant M as UseMultiTenancy Middleware + participant T as ITenantStore + participant CT as ICurrentTenant + participant A as Application Service + + C->>R: HTTP request with host/header/query/route + R->>M: TenantIdOrName resolved + M->>T: Load tenant configuration + T-->>M: Tenant info + connection strings + M->>CT: Set current tenant context + CT->>A: Tenant-aware execution begins +``` + +### Practical guidance + +- Use **subdomain resolution** for browser-based SaaS apps. +- Use **header-based resolution** for APIs behind gateways. +- Keep **query string resolution** mainly for testing or controlled integrations. +- Add **custom resolvers** only when the business rule is stable and well documented. + +## Creating Tenant-Aware Entities + +The most important rule in ABP multi-tenancy is simple: entities that belong to a tenant should implement `IMultiTenant`. + +### Basic entity design + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class Product : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; protected set; } + public string Name { get; private set; } + public decimal Price { get; private set; } + + protected Product() + { + } + + public Product(Guid id, Guid? tenantId, string name, decimal price) + : base(id) + { + TenantId = tenantId; + Name = name; + Price = price; + } +} +``` + +### Customer example + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Customers; + +public class Customer : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; protected set; } + public string CompanyName { get; private set; } + public string Email { get; private set; } + + protected Customer() + { + } + + public Customer(Guid id, Guid? tenantId, string companyName, string email) + : base(id) + { + TenantId = tenantId; + CompanyName = companyName; + Email = email; + } +} +``` + +### Order example + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Orders; + +public class Order : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; protected set; } + public Guid CustomerId { get; private set; } + public DateTime OrderDate { get; private set; } + public decimal TotalAmount { get; private set; } + + protected Order() + { + } + + public Order(Guid id, Guid? tenantId, Guid customerId, DateTime orderDate, decimal totalAmount) + : base(id) + { + TenantId = tenantId; + CustomerId = customerId; + OrderDate = orderDate; + TotalAmount = totalAmount; + } +} +``` + +### Aggregate root considerations + +A few practical rules help avoid trouble: + +- Put `TenantId` on aggregate roots that are tenant-owned. +- Keep child entities inside the same tenant boundary as the aggregate root. +- Avoid aggregates that reference entities from different tenants. +- Be explicit about whether an entity is host-owned or tenant-owned. + +### How ABP filters tenant data automatically + +Once an entity implements `IMultiTenant`, ABP applies the multi-tenant data filter automatically. + +That means this repository call: + +```csharp +var products = await _productRepository.GetListAsync(); +``` + +will only return rows for the current tenant when tenant context is active. + +### Generated SQL shape + +The exact SQL depends on your provider and query, but conceptually it looks like this: + +```sql +SELECT Id, TenantId, Name, Price +FROM CrmProducts +WHERE TenantId = @CurrentTenantId +``` + +In host context, behavior depends on the entity and query shape. Host-owned rows typically have `TenantId IS NULL`. + +### Important design note about `TenantId` + +`IMultiTenant` defines `Guid? TenantId`, which is nullable because host-owned entities may exist. + +If your entity must always belong to a tenant, you can enforce that in domain logic and EF configuration, but be careful. The framework’s default contract is nullable, and forcing non-null semantics requires deliberate mapping and validation. + +## Working with CurrentTenant + +`ICurrentTenant` is the runtime API you will use most often in multi-tenant services. + +### Reading current tenant information + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class ProductAppService : ApplicationService +{ + private readonly ICurrentTenant _currentTenant; + + public ProductAppService(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public Task GetTenantInfoAsync() + { + var tenantId = _currentTenant.Id?.ToString() ?? "Host"; + var tenantName = _currentTenant.Name ?? "Host"; + + return Task.FromResult($"TenantId: {tenantId}, TenantName: {tenantName}"); + } +} +``` + +### Creating tenant-owned data safely + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class ProductAppService : ApplicationService +{ + private readonly IRepository _productRepository; + private readonly ICurrentTenant _currentTenant; + + public ProductAppService( + IRepository productRepository, + ICurrentTenant currentTenant) + { + _productRepository = productRepository; + _currentTenant = currentTenant; + } + + public async Task CreateAsync(string name, decimal price) + { + if (!_currentTenant.IsAvailable) + { + throw new BusinessException("Products can only be created in tenant context."); + } + + var product = new Product(GuidGenerator.Create(), _currentTenant.Id, name, price); + await _productRepository.InsertAsync(product, autoSave: true); + return product.Id; + } +} +``` + +### Changing tenant context + +ABP allows temporary tenant switching with `CurrentTenant.Change(...)`. + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Reporting; + +public class TenantProductCounter : ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _productRepository; + + public TenantProductCounter( + ICurrentTenant currentTenant, + IRepository productRepository) + { + _currentTenant = currentTenant; + _productRepository = productRepository; + } + + public async Task CountForTenantAsync(Guid tenantId) + { + using (_currentTenant.Change(tenantId)) + { + return await _productRepository.GetCountAsync(); + } + } +} +``` + +### Nested tenant scopes + +```csharp +public async Task CompareTwoTenantsAsync(Guid tenantA, Guid tenantB) +{ + using (_currentTenant.Change(tenantA)) + { + var countA = await _productRepository.GetCountAsync(); + + using (_currentTenant.Change(tenantB)) + { + var countB = await _productRepository.GetCountAsync(); + } + } +} +``` + +Nested scopes are useful, but they should remain easy to reason about. If tenant switching becomes deeply nested, move that logic into dedicated services. + +### Practical rules for `ICurrentTenant` + +- Read it at the application or domain service boundary. +- Avoid passing raw tenant ids everywhere when the current context already exists. +- Use `Change(...)` sparingly and intentionally. +- Never assume tenant context exists in host-side operations. + +## Data Isolation Mechanisms + +ABP’s biggest multi-tenancy advantage is that data isolation is integrated into repositories and unit of work. + +### Automatic data filtering + +When an entity implements `IMultiTenant`, ABP applies the multi-tenant filter automatically. + +That means: + +- tenant A cannot see tenant B rows through normal repository queries, +- host operations can work in host context, +- the same repository code behaves differently depending on `ICurrentTenant`. + +### Tenant-specific repositories + +You usually do not need separate repositories per tenant. The same repository becomes tenant-aware because the current tenant context changes the filter behavior. + +Example: + +```csharp +var customers = await _customerRepository.GetListAsync(); +``` + +In tenant `Acme`, this returns only Acme customers. + +### Unit of Work integration + +ABP’s unit of work carries tenant context through the execution flow. This matters because: + +- repository queries stay tenant-scoped, +- transactional operations remain consistent, +- tenant switching inside a scope affects subsequent repository calls. + +### Disabling the filter carefully + +Sometimes host-side reporting or maintenance tasks need cross-tenant access. + +```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +public class CrossTenantProductReader : ITransientDependency +{ + private readonly IDataFilter _dataFilter; + private readonly IRepository _productRepository; + + public CrossTenantProductReader( + IDataFilter dataFilter, + IRepository productRepository) + { + _dataFilter = dataFilter; + _productRepository = productRepository; + } + + public async Task> GetAllAsync() + { + using (_dataFilter.Disable()) + { + return await _productRepository.GetListAsync(); + } + } +} +``` + +This is powerful and dangerous. + +### Security implications + +Disabling `IMultiTenant` filtering means you are bypassing one of your main isolation protections. + +Use it only when: + +- the operation is explicitly host-level, +- authorization is strong, +- the result is not accidentally exposed to tenant users, +- the code path is easy to audit. + +### SQL examples + +Normal tenant-scoped query: + +```sql +SELECT * +FROM CrmCustomers +WHERE TenantId = @CurrentTenantId +ORDER BY CompanyName +``` + +Cross-tenant query with filter disabled: + +```sql +SELECT * +FROM CrmCustomers +ORDER BY TenantId, CompanyName +``` + +### Isolation checklist + +- Tenant-owned entities implement `IMultiTenant` +- Tenant context is resolved before app logic +- Filters remain enabled by default +- Host-only operations are explicitly separated +- Cross-tenant reads are rare and audited + +## Seeding Tenant Data + +Seeding is where many multi-tenant applications become inconsistent. The host needs one set of seed data, while each tenant may need its own initialization. + +ABP’s `IDataSeedContributor` makes this manageable. + +### Host and tenant seeding model + +- Host seeding runs when `DataSeedContext.TenantId == null` +- Tenant seeding runs when `DataSeedContext.TenantId` has a value +- You can switch tenant context with `CurrentTenant.Change(...)` + +### Host seed contributor example + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Data; + +public class HostDataSeedContributor : IDataSeedContributor, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + + public HostDataSeedContributor(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) + { + return; + } + + using (_currentTenant.Change(null)) + { + await Task.CompletedTask; + // Seed host-side editions, plans, global settings, etc. + } + } +} +``` + +### Tenant seed contributor example + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Data; + +public class TenantDataSeedContributor : IDataSeedContributor, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _productRepository; + + public TenantDataSeedContributor( + ICurrentTenant currentTenant, + IRepository productRepository) + { + _currentTenant = currentTenant; + _productRepository = productRepository; + } + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId == null) + { + return; + } + + using (_currentTenant.Change(context.TenantId)) + { + if (await _productRepository.GetCountAsync() > 0) + { + return; + } + + await _productRepository.InsertAsync( + new Products.Product(Guid.NewGuid(), context.TenantId, "Starter Plan", 49), + autoSave: true + ); + + await _productRepository.InsertAsync( + new Products.Product(Guid.NewGuid(), context.TenantId, "Growth Plan", 99), + autoSave: true + ); + } + } +} +``` + +### Per-tenant initialization during provisioning + +A common SaaS flow looks like this: + +1. Host admin creates tenant +2. Tenant metadata is stored +3. Tenant database is created or assigned +4. Migrations run +5. Seed contributors initialize tenant data +6. Admin user is created +7. Features/settings are applied + +### Practical seeding advice + +- Keep seeders idempotent +- Separate host and tenant concerns clearly +- Avoid large business workflows inside seed contributors +- Use provisioning services for complex onboarding + +## Multi-Tenant Authentication and Identity + +Authentication is where many multi-tenant systems fail in subtle ways. If tenant resolution happens too late, users may authenticate against the wrong tenant context. + +ABP’s identity integration helps because users are tenant-aware. + +### Tenant-specific users and roles + +In ABP: + +- users can belong to a tenant, +- roles can be tenant-specific, +- host users can exist separately, +- identity behavior depends on the user sharing strategy. + +### Identity module integration + +ABP Identity integrates with multi-tenancy so that user and role management respects tenant boundaries. + +Typical outcomes: + +- tenant admins manage only their tenant users, +- host admins manage platform-level concerns, +- tenant users do not see host-level identity data. + +### Login flow + +The tenant should be resolved before authentication whenever possible. + +Typical browser flow: + +1. User visits `acme.mycrm.com` +2. Tenant resolver identifies `acme` +3. Multi-tenancy middleware sets current tenant +4. Authentication runs in tenant context +5. User is validated against tenant-aware identity data +6. Authorized application logic executes + +### Login request flow diagram + +```mermaid +sequenceDiagram + participant U as User Browser + participant D as Domain/Subdomain Resolver + participant MT as Multi-Tenancy Middleware + participant ID as Identity/Auth + participant APP as Application + + U->>D: Request acme.mycrm.com/login + D->>MT: Tenant = acme + MT->>ID: Authenticate in tenant context + ID-->>APP: Authenticated tenant user + APP-->>U: Tenant-scoped session +``` + +### Tenant switching + +Tenant switching can mean different things: + +- switching browser context from one tenant domain to another, +- host admin impersonating or managing a tenant, +- shared-user scenarios where one identity can access multiple tenants. + +In most SaaS applications, the cleanest approach is domain-based switching rather than trying to mutate tenant context inside a long-lived UI session. + +### Shared user accounts vs isolated users + +ABP supports user sharing strategies. + +#### Isolated users + +- usernames/emails are unique within tenant scope, +- simpler mental model, +- best default for most SaaS apps. + +#### Shared users + +- one user identity may exist across tenants, +- global uniqueness rules become important, +- database-per-tenant setups become more complex, +- host-side identity metadata may need replication or synchronization. + +Unless you have a strong product reason, isolated users are usually the safer choice. + +## Database Per Tenant Configuration + +Database-per-tenant is where ABP’s abstractions become especially valuable. + +### Connection string management + +At runtime, the application needs to know which database to use for the current tenant. + +That information typically comes from `ITenantStore`. + +### `ITenantStore` + +`ITenantStore` is the abstraction used to retrieve tenant configuration, including: + +- tenant id, +- tenant name, +- activation state, +- connection strings. + +ABP provides implementations through tenant management infrastructure. In simpler setups, configuration-based tenant stores can also be used. + +### Tenant-specific database setup + +For module-specific database usage, you can configure database options like this: + +```csharp +using Volo.Abp.Data; +using Volo.Abp.Modularity; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.Databases.Configure("Saas", database => + { + database.IsUsedByTenants = true; + }); + }); +} +``` + +### Example tenant configuration shape + +In a custom store or configuration source, you may keep tenant metadata like this: + +```json +{ + "Tenants": [ + { + "Id": "11111111-1111-1111-1111-111111111111", + "Name": "acme", + "ConnectionStrings": { + "Default": "Server=sql1;Database=Crm_Acme;User Id=app;Password=secret;TrustServerCertificate=True" + } + }, + { + "Id": "22222222-2222-2222-2222-222222222222", + "Name": "northwind", + "ConnectionStrings": { + "Default": "Server=sql2;Database=Crm_Northwind;User Id=app;Password=secret;TrustServerCertificate=True" + } + } + ] +} +``` + +### Production-ready custom tenant store example + +```csharp +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +public class AppTenantStore : ITenantStore, ITransientDependency +{ + private readonly ConcurrentDictionary _tenantsByName; + private readonly ConcurrentDictionary _tenantsById; + + public AppTenantStore(IOptions options) + { + _tenantsByName = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _tenantsById = new ConcurrentDictionary(); + + foreach (var tenant in options.Value.Tenants) + { + _tenantsByName[tenant.Name] = tenant; + if (tenant.Id.HasValue) + { + _tenantsById[tenant.Id.Value] = tenant; + } + } + } + + public Task FindAsync(string normalizedName) + { + _tenantsByName.TryGetValue(normalizedName, out var tenant); + return Task.FromResult(tenant); + } + + public Task FindAsync(Guid id) + { + _tenantsById.TryGetValue(id, out var tenant); + return Task.FromResult(tenant); + } +} + +public class AppTenantStoreOptions +{ + public TenantConfiguration[] Tenants { get; set; } = Array.Empty(); +} +``` + +### Provisioning flow for dedicated databases + +A production-grade tenant provisioning process usually includes: + +- create tenant record, +- generate or assign connection string, +- create database, +- run migrations, +- seed tenant data, +- create tenant admin, +- verify health checks. + +### Open source vs PRO note + +ABP supports database-per-tenant architecture in general, but UI-based connection string management for tenants is part of the commercial SaaS tooling. In open-source solutions, you typically implement your own management UI or provisioning workflow. + +## Advanced Scenarios in Real Applications + +Multi-tenancy affects more than repositories. In production systems, the tricky parts are usually background jobs, events, caching, features, settings, and observability. + +### Background jobs in tenant context + +If a background job processes tenant data, it must restore the correct tenant context. + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +public class RebuildCustomerStatsArgs +{ + public Guid TenantId { get; set; } +} + +public class RebuildCustomerStatsJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + + public RebuildCustomerStatsJob(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public override async Task ExecuteAsync(RebuildCustomerStatsArgs args) + { + using (_currentTenant.Change(args.TenantId)) + { + await Task.CompletedTask; + // Recalculate tenant-specific metrics here. + } + } +} +``` + +### Distributed events + +When publishing distributed events, include tenant context explicitly if consumers need it. + +Good practice: + +- include `TenantId` in event payloads, +- restore tenant context in handlers, +- avoid assuming ambient tenant context exists in asynchronous boundaries. + +### Caching per tenant + +Cache keys should include tenant identity. + +Bad: + +- `customer-list` + +Better: + +- `tenant:{tenantId}:customer-list` + +Without tenant-aware cache keys, cross-tenant data leaks become very possible. + +### Feature management + +Features are a natural fit for SaaS plans. + +Examples: + +- maximum users, +- advanced reporting, +- API access, +- custom branding. + +ABP feature management supports tenant-level configuration and edition-based grouping. + +### Setting management + +Settings can be scoped globally, per tenant, or per user. + +Examples: + +- default currency, +- email sender, +- invoice numbering format, +- CRM pipeline defaults. + +### Audit logging + +Audit logs should capture tenant context so you can answer questions like: + +- which tenant triggered this action, +- which admin changed a tenant setting, +- which background job modified tenant data. + +### Localization + +Tenants often need different localization defaults: + +- language, +- timezone, +- regional formatting, +- legal text. + +Tenant-level settings are usually the right place for these preferences. + +### Common pitfalls + +- forgetting tenant context in background jobs, +- using cache keys without tenant id, +- disabling data filters too broadly, +- mixing host and tenant responsibilities in one service, +- assuming tenant resolution works the same in browser and API flows, +- not testing wildcard domain auth configuration carefully. + +## Building a Sample SaaS CRM Application + +Let’s connect the concepts with a practical example. + +Imagine a SaaS CRM built with ABP. + +### Functional areas + +#### Host administration + +The host side manages: + +- tenant creation, +- subscription plans, +- feature packages, +- billing integration, +- tenant health and usage. + +#### Tenant administration + +Each tenant manages: + +- users, +- roles, +- settings, +- branding, +- sales workflows. + +#### Customer management + +Tenant users create and manage: + +- customers, +- contacts, +- opportunities, +- orders, +- notes. + +#### Subscription management + +The host assigns plans such as: + +- Starter +- Growth +- Enterprise + +These plans map naturally to ABP features and settings. + +### Suggested architecture + +- **Host app** for platform administration +- **Tenant-facing app** resolved by subdomain +- **Shared modules** for identity, audit logging, feature management, setting management +- **Tenant-aware domain entities** for CRM data +- **Hybrid database strategy** if enterprise tenants need dedicated databases + +### Example domain boundaries + +Host-owned entities: + +- Tenant +n- SubscriptionPlan +- Edition +- BillingAccount + +Tenant-owned entities: + +- Customer +- Product +- Order +- SalesPipeline +- ActivityLog + +### Tenant-aware application service example + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Customers; + +public class CustomerAppService : ApplicationService +{ + private readonly IRepository _customerRepository; + private readonly ICurrentTenant _currentTenant; + + public CustomerAppService( + IRepository customerRepository, + ICurrentTenant currentTenant) + { + _customerRepository = customerRepository; + _currentTenant = currentTenant; + } + + public async Task CreateAsync(string companyName, string email) + { + if (!_currentTenant.IsAvailable) + { + throw new BusinessException("Customer creation requires tenant context."); + } + + var customer = new Customer( + GuidGenerator.Create(), + _currentTenant.Id, + companyName, + email + ); + + await _customerRepository.InsertAsync(customer, autoSave: true); + return customer.Id; + } +} +``` + +### End-to-end request example + +A request to `https://acme.mycrm.com/api/customers` flows like this: + +1. Domain resolver extracts `acme` +2. `ITenantStore` loads tenant metadata +3. `ICurrentTenant` is set +4. Authentication runs in tenant context +5. Repository queries apply `IMultiTenant` filter +6. Only Acme customer data is returned + +That is the core ABP multi-tenancy story in one request. + +## Best Practices for ABP Multi-Tenant Applications + +Here are practical best practices that hold up well in real systems. + +1. **Implement `IMultiTenant` on every tenant-owned aggregate root.** Do not rely on conventions or memory. +2. **Use subdomain-based tenant resolution for browser SaaS apps.** It is usually the cleanest UX and operational model. +3. **Keep host and tenant application services separate.** Mixing them creates authorization and maintenance problems. +4. **Treat `IDataFilter.Disable()` as a privileged operation.** Review those code paths carefully. +5. **Index `TenantId` on large tables.** Shared-database performance depends on it. +6. **Include `TenantId` in cache keys, distributed events, and background job payloads.** Ambient context does not cross every boundary. +7. **Resolve tenant before authentication.** Especially for domain/subdomain-based login flows. +8. **Prefer isolated users unless shared identities are a real product requirement.** Shared users add complexity fast. +9. **Automate tenant provisioning.** Manual database creation and migration does not scale. +10. **Make seed contributors idempotent.** Tenant provisioning may be retried. +11. **Design for hybrid early if you expect enterprise customers.** Moving from shared-only to hybrid later is possible, but easier if planned. +12. **Store tenant connection strings securely.** Use secret management and rotation policies. +13. **Log tenant context in audit and operational logs.** Troubleshooting without tenant identity is painful. +14. **Test host context explicitly.** `CurrentTenant` being `null` is a valid and important scenario. +15. **Avoid cross-tenant joins in domain logic.** If you need cross-tenant analytics, build reporting pipelines intentionally. +16. **Keep tenant switching localized.** Use `CurrentTenant.Change(...)` in small, obvious scopes. +17. **Validate tenant ownership in domain rules where it matters.** Filters help, but domain invariants still matter. +18. **Monitor noisy-neighbor patterns.** Shared databases need tenant-level performance visibility. +19. **Run tenant-aware integration tests.** Unit tests alone will not catch resolution and filter issues. +20. **Document your tenant resolution strategy clearly.** Operations, frontend, and identity flows all depend on it. + +## Common Mistakes to Avoid + +Even experienced teams make the same multi-tenancy mistakes. + +### 1) Assuming repository filtering solves everything + +Repository filtering is excellent, but it does not replace: + +- authorization, +- cache isolation, +- event payload design, +- background job context restoration. + +### 2) Treating host context as an edge case + +In ABP, host context is a first-class concept. Design for it explicitly. + +### 3) Choosing database-per-tenant too early or too late + +Too early: + +- unnecessary operational burden. + +Too late: + +- painful migration for large tenants. + +### 4) Forgetting tenant-aware testing + +You should test: + +- host requests, +- tenant requests, +- invalid tenant requests, +- cross-tenant access attempts, +- background jobs with tenant context, +- cache behavior across tenants. + +## Final Thoughts + +ABP’s multi-tenancy support is one of the framework’s strongest features because it combines architectural clarity with practical runtime behavior. + +You get: + +- a clear host/tenant model, +- tenant resolution middleware, +- `ICurrentTenant` for runtime context, +- `IMultiTenant` for entity ownership, +- automatic data filtering, +- tenant-aware identity and module integration, +- support for shared, dedicated, and hybrid database strategies. + +That does not remove the need for good architecture. You still need to make deliberate choices about isolation, authentication, provisioning, caching, and operations. But ABP gives you a solid default path and a consistent set of abstractions, which is exactly what you want in a serious SaaS platform. + +If you are building an enterprise-grade SaaS application on .NET, ABP lets you spend more time on product logic and less time reinventing multi-tenancy infrastructure. + +## TL;DR + +- ABP multi-tenancy is built around `ICurrentTenant`, `IMultiTenant`, tenant resolution middleware, and automatic data filters. +- ABP supports shared database, database-per-tenant, and hybrid models, so you can match architecture to customer and compliance needs. +- Tenant resolution should happen before authentication, and subdomain resolution is usually the best default for SaaS apps. +- Tenant-aware design must extend beyond repositories to seeding, background jobs, caching, events, settings, and audit logging. +- The safest production approach is clear host/tenant separation, minimal filter bypassing, strong provisioning automation, and tenant-aware testing. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/cover.png b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/cover.png new file mode 100644 index 0000000000..b13a985e7e Binary files /dev/null and b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png new file mode 100644 index 0000000000..c8c2186287 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png new file mode 100644 index 0000000000..b49288411b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png new file mode 100644 index 0000000000..78d74772ad Binary files /dev/null and b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png new file mode 100644 index 0000000000..c1568b4320 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-04-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png differ diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/Post.md b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/Post.md new file mode 100644 index 0000000000..e56e2b215b --- /dev/null +++ b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/Post.md @@ -0,0 +1,1452 @@ +# Implementing Multi-Tenancy in ABP Framework: A Complete Practical Guide + +Multi-tenancy is one of those architectural decisions that looks simple in a slide deck and becomes very real the moment you build a SaaS product. The hard part is not adding a `TenantId` column. The hard part is making tenant resolution, data isolation, authentication, seeding, background processing, caching, and database management work together without leaking data or creating operational pain. + +ABP Framework gives you a strong foundation for this. It has built-in multi-tenancy support, tenant-aware entities, automatic data filters, tenant resolution contributors, tenant management, and connection string infrastructure that supports shared database, database-per-tenant, and hybrid models. + +This guide explains both the concepts and the implementation details. It is written for intermediate to advanced .NET developers building real SaaS applications with ABP. + +## What Multi-Tenancy Means in SaaS + +A multi-tenant application serves multiple customers from the same application codebase and runtime while keeping each customer's data, users, configuration, and behavior isolated. + +In ABP terminology: + +- **Host side**: the application owner or platform operator +- **Tenant side**: each customer using the application +- **Current tenant**: the tenant context of the current request or operation + +### Single-tenant vs multi-tenant + +A **single-tenant** system usually means: + +- one deployment per customer +- isolated infrastructure by default +- simpler compliance story +- higher operational cost + +A **multi-tenant** system usually means: + +- one application serving many customers +- shared operational model +- lower cost per customer +- more architectural responsibility around isolation + +### Why multi-tenancy matters + +For SaaS products, multi-tenancy is often the difference between a manageable platform and an expensive collection of customer-specific deployments. + +Benefits: + +- lower infrastructure cost +- centralized updates and fixes +- faster onboarding of new customers +- easier feature rollout +- consistent operational model + +Challenges: + +- strict data isolation +- tenant-aware authentication and authorization +- scaling noisy tenants +- tenant-specific configuration +- migrations across shared or separate databases +- reporting across tenants + +### Why ABP simplifies this + +ABP removes a lot of repetitive plumbing: + +- `ICurrentTenant` exposes tenant context everywhere +- `IMultiTenant` enables automatic tenant filtering +- tenant resolution is built into the ASP.NET Core pipeline +- tenant management is available out of the box +- per-tenant connection strings support database-per-tenant and hybrid models +- identity, permissions, settings, features, and audit logging are tenant-aware + +That does not eliminate architectural decisions, but it gives you a consistent framework for implementing them correctly. + +## Understanding ABP Multi-Tenancy Architecture + +ABP's multi-tenancy model is practical: tenant context is resolved early, stored in the current execution scope, and then used by repositories, DbContexts, identity, settings, and other infrastructure. + +### Core concepts + +#### Tenant concept + +A tenant represents a customer organization in your SaaS system. A tenant typically has: + +- an `Id` +- a `Name` +- optional connection strings +- optional settings and features +- tenant-specific users and roles + +#### Host side + +The host side is where platform-wide operations happen: + +- creating and managing tenants +- assigning plans or subscriptions +- viewing cross-tenant analytics +- configuring defaults +- running migrations and maintenance + +Host-side operations usually run with `CurrentTenant.Id == null`. + +#### Tenant side + +The tenant side is where customer-specific operations happen: + +- managing users inside a tenant +- creating business data such as products, customers, and orders +- configuring tenant-level settings +- consuming tenant-specific features + +#### `ICurrentTenant` + +`ICurrentTenant` is the central service for reading the active tenant context. + +It exposes: + +- `Id` +- `Name` +- `IsAvailable` + +You will use it in application services, domain services, background jobs, seed contributors, and event handlers. + +#### Tenant resolution pipeline + +ABP resolves the tenant from the incoming request using contributors. Common sources include: + +- current user claims +- query string +- route values +- headers +- cookies +- domain or subdomain +- custom resolvers + +#### `IMultiTenant` + +Entities implementing `IMultiTenant` become tenant-aware. ABP applies automatic data filtering so tenant users only see rows belonging to their tenant. + +#### Data filters + +ABP uses data filters, typically backed by EF Core global query filters, to enforce tenant isolation for `IMultiTenant` entities. + +#### Tenant Management module + +ABP's Tenant Management module provides the infrastructure to create and manage tenants. In startup templates, this is often already integrated. In open-source ABP, core tenant management exists, while some advanced SaaS UI capabilities are part of ABP Commercial. + +### Request-to-data flow + +```mermaid +flowchart LR + A[Incoming HTTP Request] --> B[Tenant Resolution Contributors] + B --> C[Resolved Tenant Id or Name] + C --> D[ICurrentTenant Scope] + D --> E[Application Service] + E --> F[Repository / DbContext] + F --> G[IMultiTenant Data Filter] + G --> H[Tenant-Isolated Data] +``` + +### Host and tenant architecture view + +```mermaid +flowchart TB + Host[Host Side\nPlatform Owner] --> TM[Tenant Management] + Host --> Billing[Subscription / Plan Management] + Host --> Reporting[Cross-Tenant Reporting] + + TenantA[Tenant A] --> AppA[Application Modules] + TenantB[Tenant B] --> AppB[Application Modules] + TenantC[Tenant C] --> AppC[Application Modules] + + AppA --> Data[(Shared DB or Tenant DB)] + AppB --> Data + AppC --> Data +``` + +### How ABP applies tenant context + +Once the tenant is resolved: + +- repositories automatically filter `IMultiTenant` entities +- identity operations become tenant-specific +- settings and features can be resolved per tenant +- connection strings can switch dynamically for tenant databases +- audit logs can include tenant information + +This is why ABP multi-tenancy feels cohesive instead of bolted on. + + + +![Generated illustration](inline-1.png) + +## Multi-Tenancy Models Supported by ABP + +ABP supports three practical models. + +### 1) Single Database + +All tenants share one database. Tenant-specific rows are separated by `TenantId`. + +```mermaid +flowchart LR + T1[Tenant A] --> DB[(Shared Database)] + T2[Tenant B] --> DB + T3[Tenant C] --> DB +``` + +#### Advantages + +- lowest infrastructure cost +- simplest deployment model +- easiest migrations +- fast tenant provisioning +- easier aggregate reporting across tenants + +#### Disadvantages + +- weaker isolation than separate databases +- higher risk if filtering is misconfigured +- noisy tenants can affect others +- compliance requirements may be harder to satisfy + +### 2) Database Per Tenant + +Each tenant gets its own database. ABP switches connection strings based on tenant configuration. + +```mermaid +flowchart LR + T1[Tenant A] --> DB1[(Tenant A DB)] + T2[Tenant B] --> DB2[(Tenant B DB)] + T3[Tenant C] --> DB3[(Tenant C DB)] +``` + +#### Advantages + +- strongest data isolation +- easier tenant-specific backup and restore +- better compliance story +- easier per-tenant scaling and maintenance + +#### Disadvantages + +- more operational overhead +- migrations must run for every tenant database +- monitoring and backup complexity increases +- provisioning is slower than shared DB + +### 3) Hybrid Model + +Some tenants use the shared database, while premium or regulated tenants get dedicated databases. + +```mermaid +flowchart LR + T1[Tenant A] --> Shared[(Shared Database)] + T2[Tenant B] --> Shared + T3[Enterprise Tenant C] --> Dedicated[(Dedicated Database)] +``` + +#### Advantages + +- flexible cost/isolation tradeoff +- supports enterprise customers with stricter requirements +- lets you upgrade tenants without redesigning the app + +#### Disadvantages + +- most complex operational model +- more testing scenarios +- migration and support workflows become more involved + +### Comparison table + +| Model | Isolation | Cost | Operational Complexity | Reporting Across Tenants | Best Fit | +|---|---|---:|---:|---|---| +| Single Database | Medium | Low | Low | Easy | Early-stage SaaS, many small tenants | +| Database Per Tenant | High | High | High | Harder | Regulated or enterprise SaaS | +| Hybrid | Medium to High | Medium | High | Mixed | SaaS with tiered customer needs | + +### When to use / When NOT to use + +#### Use single database when + +- you need fast onboarding +- tenants are relatively small +- compliance requirements are moderate +- cross-tenant reporting matters + +#### Avoid single database when + +- customers require strict physical isolation +- one tenant can generate extreme load +- backup/restore must be tenant-specific + +#### Use database per tenant when + +- enterprise customers demand isolation +- you need tenant-specific maintenance windows +- compliance or residency rules are strict + +#### Avoid database per tenant when + +- you have thousands of tiny tenants +- your team is not ready for operational complexity +- your deployment and migration automation is immature + +#### Use hybrid when + +- you serve both SMB and enterprise customers +- you want a premium isolation tier +- you need a migration path from shared to dedicated databases + +#### Avoid hybrid when + +- your team wants the simplest possible operations +- you cannot invest in strong automation and observability + + + +![Generated illustration](inline-2.png) + +## Enabling Multi-Tenancy in ABP + +In ABP solutions, multi-tenancy is commonly controlled by a shared constant and configured through `AbpMultiTenancyOptions`. + +### `MultiTenancyConsts` + +Create or update `MultiTenancyConsts` in your `.Domain.Shared` project: + +```csharp +namespace Acme.Crm; + +public static class MultiTenancyConsts +{ + public const bool IsEnabled = true; +} +``` + +This keeps the setting centralized and easy to reference across modules. + +### Configure `AbpMultiTenancyOptions` + +In your web or HTTP API host module: + +```csharp +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.IsEnabled = MultiTenancyConsts.IsEnabled; + }); +} +``` + +### Typical module configuration example + +```csharp +using Acme.Crm.MultiTenancy; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp; +using Volo.Abp.AspNetCore.MultiTenancy; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm; + +[DependsOn( + typeof(AbpAspNetCoreMultiTenancyModule) +)] +public class CrmHttpApiHostModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IsEnabled = MultiTenancyConsts.IsEnabled; + }); + + Configure(options => + { + options.TenantKey = "__tenant"; + }); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + var app = context.GetApplicationBuilder(); + + app.UseRouting(); + + if (MultiTenancyConsts.IsEnabled) + { + app.UseMultiTenancy(); + } + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseConfiguredEndpoints(); + } +} +``` + +### Relevant configuration files + +`appsettings.json` usually contains the default connection string and may contain tenant-related settings depending on your setup: + +```json +{ + "ConnectionStrings": { + "Default": "Server=localhost;Database=CrmShared;Trusted_Connection=True;TrustServerCertificate=True" + }, + "App": { + "SelfUrl": "https://localhost:44388" + } +} +``` + +If you use `DefaultTenantStore` for simple scenarios, tenant definitions can also be stored in configuration. In production, most real systems use the Tenant Management module and a database-backed tenant store. + +## Tenant Resolution Strategies + +Tenant resolution is where multi-tenancy becomes real. If the wrong tenant is resolved, everything after that is wrong too. + +ABP supports multiple strategies and lets you combine them. + +### Default resolution contributors + +Common contributors include: + +- current user claims +- query string: `?__tenant=acme` +- route value: `/api/acme/products` +- header: `__tenant: acme` +- cookie: `__tenant=acme` + +### Resolution flow through an HTTP request + +```mermaid +sequenceDiagram + participant Client + participant Middleware as MultiTenancy Middleware + participant Resolver as Tenant Resolvers + participant Store as ITenantStore + participant App as Application Service + participant Db as Repository/DbContext + + Client->>Middleware: HTTP Request + Middleware->>Resolver: Resolve tenant from claims/header/query/domain + Resolver->>Store: Find tenant by id or name + Store-->>Resolver: Tenant configuration + Resolver-->>Middleware: Active tenant + Middleware->>App: Execute under tenant scope + App->>Db: Query IMultiTenant entities + Db-->>App: Tenant-filtered data + App-->>Client: Response +``` + +### Subdomain and domain resolution + +Subdomain resolution is usually the cleanest production approach for SaaS. + +Example: `acme.mycrm.com` resolves tenant `acme`. + +```csharp +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.AddDomainTenantResolver("{0}.mycrm.com"); + }); +} +``` + +You can also use full domain mapping patterns depending on your routing strategy. + +### Header-based resolution + +Useful for APIs, internal gateways, or integration testing. + +```csharp +Configure(options => +{ + options.TenantKey = "__tenant"; +}); +``` + +Request example: + +```http +GET /api/app/products HTTP/1.1 +Host: api.mycrm.com +__tenant: acme +Authorization: Bearer eyJ... +``` + +### Query string resolution + +Useful for demos and debugging, but not ideal as the primary production strategy. + +Example: + +```http +GET /api/app/products?__tenant=acme +``` + +### Route-based resolution + +If your API design includes tenant in the route, ABP can resolve from route values. + +Example route: + +```http +GET /api/acme/products +``` + +### Custom tenant resolver + +Sometimes tenant identification comes from a reverse proxy header, a custom JWT claim, or a partner integration contract. + +Create a custom contributor: + +```csharp +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.MultiTenancy; + +public class XTenantResolveContributor : TenantResolveContributorBase +{ + public const string ContributorName = "X-Tenant-Resolver"; + + public override string Name => ContributorName; + + public override Task ResolveAsync(ITenantResolveContext context) + { + var httpContext = context.ServiceProvider + .GetRequiredService() + .HttpContext; + + if (httpContext == null) + { + return Task.CompletedTask; + } + + var tenant = httpContext.Request.Headers["X-Tenant"].ToString(); + + if (!tenant.IsNullOrWhiteSpace()) + { + context.TenantIdOrName = tenant; + } + + return Task.CompletedTask; + } +} +``` + +Register it: + +```csharp +Configure(options => +{ + options.TenantResolvers.Insert(0, new XTenantResolveContributor()); +}); +``` + +### Practical guidance on resolver choice + +Prefer this order in production: + +1. subdomain/domain +2. authenticated user claim +3. trusted gateway header +4. route value +5. query string only for development or support scenarios + +Do not rely on query string alone for sensitive production flows. + + + +![Generated illustration](inline-3.png) + +## Creating Tenant-Aware Entities + +The most important rule is simple: if an entity belongs to a tenant, implement `IMultiTenant`. + +### Product entity example + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class Product : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; private set; } + public string Name { get; private set; } + public decimal Price { get; private set; } + + protected Product() + { + } + + public Product(Guid id, Guid? tenantId, string name, decimal price) + : base(id) + { + TenantId = tenantId; + Name = name; + Price = price; + } + + public void ChangePrice(decimal price) + { + Price = price; + } +} +``` + +### Customer entity example + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Customers; + +public class Customer : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; private set; } + public string CompanyName { get; private set; } + public string Email { get; private set; } + + protected Customer() + { + } + + public Customer(Guid id, Guid? tenantId, string companyName, string email) + : base(id) + { + TenantId = tenantId; + CompanyName = companyName; + Email = email; + } +} +``` + +### Order aggregate example + +```csharp +using System; +using System.Collections.Generic; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Orders; + +public class Order : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; private set; } + public Guid CustomerId { get; private set; } + public DateTime OrderDate { get; private set; } + public List Lines { get; private set; } + + protected Order() + { + Lines = new List(); + } + + public Order(Guid id, Guid? tenantId, Guid customerId, DateTime orderDate) + : base(id) + { + TenantId = tenantId; + CustomerId = customerId; + OrderDate = orderDate; + Lines = new List(); + } +} + +public class OrderLine +{ + public Guid ProductId { get; private set; } + public int Quantity { get; private set; } + public decimal UnitPrice { get; private set; } + + protected OrderLine() + { + } + + public OrderLine(Guid productId, int quantity, decimal unitPrice) + { + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } +} +``` + +### Design considerations + +A few rules matter a lot: + +- keep `TenantId` immutable after creation whenever possible +- avoid moving entities between tenants +- ensure child entities belong to the same tenant as the aggregate root +- do not mix host-owned and tenant-owned data in the same aggregate casually +- index `TenantId` in large tables + +### How ABP filters tenant data automatically + +When an entity implements `IMultiTenant`, ABP applies a tenant filter automatically. If tenant `A` is active, queries only return rows where `TenantId == A`. + +That means this repository call: + +```csharp +var products = await _productRepository.GetListAsync(); +``` + +returns only the current tenant's products in a shared database model. + +### Host-owned entities + +Not every entity should implement `IMultiTenant`. + +Examples of host-owned entities: + +- subscription plans +- global feature definitions +- platform announcements +- tenant catalog records + +Those entities are intentionally shared or host-scoped. + +## Working with `ICurrentTenant` + +`ICurrentTenant` is the API you will use most often in multi-tenant business logic. + +### Reading current tenant information + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class ProductAppService : ApplicationService +{ + private readonly ICurrentTenant _currentTenant; + + public ProductAppService(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public Task GetTenantInfoAsync() + { + var tenantId = _currentTenant.Id?.ToString() ?? "Host"; + var tenantName = _currentTenant.Name ?? "Host"; + + return Task.FromResult($"TenantId: {tenantId}, TenantName: {tenantName}"); + } +} +``` + +### Creating tenant-aware data + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Guids; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Products; + +public class ProductAppService : ApplicationService +{ + private readonly IRepository _productRepository; + private readonly IGuidGenerator _guidGenerator; + private readonly ICurrentTenant _currentTenant; + + public ProductAppService( + IRepository productRepository, + IGuidGenerator guidGenerator, + ICurrentTenant currentTenant) + { + _productRepository = productRepository; + _guidGenerator = guidGenerator; + _currentTenant = currentTenant; + } + + public async Task CreateAsync(string name, decimal price) + { + var product = new Product( + _guidGenerator.Create(), + _currentTenant.Id, + name, + price + ); + + await _productRepository.InsertAsync(product, autoSave: true); + } +} +``` + +### Changing tenant context temporarily + +Host-side services often need to execute logic for a specific tenant. + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Reporting; + +public class TenantReportService : ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _productRepository; + + public TenantReportService( + ICurrentTenant currentTenant, + IRepository productRepository) + { + _currentTenant = currentTenant; + _productRepository = productRepository; + } + + public async Task GetProductCountAsync(Guid tenantId) + { + using (_currentTenant.Change(tenantId)) + { + return await _productRepository.GetCountAsync(); + } + } +} +``` + +### Nested tenant scopes + +Nested scopes are supported and useful when host-side orchestration calls tenant-specific logic. + +```csharp +using (_currentTenant.Change(null)) +{ + // host context + + using (_currentTenant.Change(tenantAId)) + { + // tenant A context + } + + using (_currentTenant.Change(tenantBId)) + { + // tenant B context + } +} +``` + +### Common mistake + +Do not cache `CurrentTenant.Id` globally or in singleton state. Tenant context is request or scope specific. + +## Data Isolation Mechanisms in ABP + +ABP's biggest value in multi-tenancy is not just storing tenant information. It is enforcing isolation consistently. + +### Automatic data filtering + +For `IMultiTenant` entities, ABP applies a tenant filter automatically. + +In a shared database model, a query conceptually becomes: + +```sql +SELECT Id, TenantId, Name, Price +FROM Products +WHERE TenantId = @CurrentTenantId +``` + +If soft delete is also enabled, it may look more like: + +```sql +SELECT Id, TenantId, Name, Price +FROM Products +WHERE TenantId = @CurrentTenantId + AND IsDeleted = 0 +``` + +The exact SQL depends on your provider and EF Core version, but the important point is that tenant filtering is automatic. + +### Tenant-specific repositories + +Standard repositories already respect tenant filters. In most cases, you do not need a special repository implementation just for tenant isolation. + +What you do need is discipline: + +- implement `IMultiTenant` +- avoid raw SQL that bypasses filters unless you know exactly what you are doing +- include tenant context in custom queries and projections + +### Disabling the tenant filter intentionally + +Host-side reporting or maintenance may require access to all tenants in a shared database. + +```csharp +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Acme.Crm.Reporting; + +public class HostReportingService : ITransientDependency +{ + private readonly IDataFilter _dataFilter; + private readonly IRepository _productRepository; + + public HostReportingService(IDataFilter dataFilter, IRepository productRepository) + { + _dataFilter = dataFilter; + _productRepository = productRepository; + } + + public async Task> GetAllProductsAcrossTenantsAsync() + { + using (_dataFilter.Disable()) + { + return await _productRepository.GetListAsync(); + } + } +} +``` + +Important limitation: this only works for shared database scenarios. In database-per-tenant mode, there is no single query that can magically span all tenant databases. + +### Unit of Work integration + +Tenant context and data filters participate naturally in ABP's Unit of Work pipeline. That means: + +- repository operations inside the same UoW use the same tenant context +- transaction boundaries remain consistent +- switching tenant context inside a UoW should be done carefully and intentionally + +### Security implications + +Automatic filtering is a safety net, not a substitute for security design. + +You still need: + +- authorization checks +- tenant-aware cache keys +- careful raw SQL usage +- secure resolver configuration +- tests that verify cross-tenant isolation + +## Seeding Host and Tenant Data + +Seeding is where many multi-tenant applications become inconsistent. The fix is to make seeding explicit, idempotent, and tenant-aware. + +### `IDataSeedContributor` basics + +ABP uses `IDataSeedContributor` for modular data seeding. + +### Host and tenant seed contributor example + +```csharp +using System; +using System.Threading.Tasks; +using Acme.Crm.Products; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Guids; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Data; + +public class CrmDataSeedContributor : IDataSeedContributor, ITransientDependency +{ + private readonly IRepository _productRepository; + private readonly IGuidGenerator _guidGenerator; + private readonly ICurrentTenant _currentTenant; + + public CrmDataSeedContributor( + IRepository productRepository, + IGuidGenerator guidGenerator, + ICurrentTenant currentTenant) + { + _productRepository = productRepository; + _guidGenerator = guidGenerator; + _currentTenant = currentTenant; + } + + public async Task SeedAsync(DataSeedContext context) + { + using (_currentTenant.Change(context.TenantId)) + { + if (await _productRepository.GetCountAsync() > 0) + { + return; + } + + await _productRepository.InsertAsync( + new Product(_guidGenerator.Create(), context.TenantId, "Starter Plan", 49), + autoSave: true + ); + + await _productRepository.InsertAsync( + new Product(_guidGenerator.Create(), context.TenantId, "Professional Plan", 99), + autoSave: true + ); + } + } +} +``` + +### Seeding host data + +When `context.TenantId` is `null`, the contributor runs in host context. Use that for: + +- subscription plans +- global settings +- default editions or features +- host admin data + +### Seeding tenant data + +When `context.TenantId` has a value, seed tenant-specific defaults such as: + +- default CRM pipeline stages +- sample products +- tenant admin roles +- onboarding templates + +### Triggering seeding + +In migrator or startup code: + +```csharp +await dataSeeder.SeedAsync(new DataSeedContext()); +``` + +For a specific tenant: + +```csharp +await dataSeeder.SeedAsync(new DataSeedContext(tenantId)); +``` + +### Best seeding rules + +- make seeders idempotent +- never assume execution order unless you control it +- seed host and tenant data separately when needed +- in database-per-tenant mode, run migrations and seeding for each tenant database + +## Multi-Tenant Authentication and Identity + +Authentication in a multi-tenant app is not just about validating a user. It is about validating a user in the correct tenant context. + +ABP Identity is already tenant-aware: + +- `IdentityUser` includes `TenantId` +- `IdentityRole` includes `TenantId` +- permissions can target host, tenant, or both sides + +### Tenant-specific users and roles + +This means: + +- host admins can exist with `TenantId = null` +- tenant users belong to a specific tenant +- tenant roles are isolated from other tenants + +### Login flow + +A typical login flow looks like this: + +```mermaid +sequenceDiagram + participant User + participant Browser + participant App + participant Resolver as Tenant Resolver + participant Identity as Identity Module + + User->>Browser: Open tenant URL or login page + Browser->>App: Request with tenant context + App->>Resolver: Resolve tenant + Resolver-->>App: Tenant identified + User->>App: Submit username/password + App->>Identity: Authenticate within tenant scope + Identity-->>App: User validated for tenant + App-->>Browser: Auth cookie/token with tenant context +``` + +### Tenant switching + +Tenant switching usually happens through one of these patterns: + +- user visits a tenant-specific subdomain +- login page asks for tenant name first +- gateway injects tenant header +- token contains tenant claim after authentication + +For SaaS UX, subdomain-based switching is usually the cleanest. + +### Permissions by multi-tenancy side + +When defining permissions, ABP lets you specify whether a permission applies to: + +- host +- tenant +- both + +That matters for admin screens. For example: + +- tenant creation should be host-only +- customer management should be tenant-only +- profile management may be both + +## Database Per Tenant Configuration + +Database-per-tenant is where ABP's tenant infrastructure becomes especially valuable. + +### Connection string management + +Each tenant can have its own connection string. If a tenant-specific connection string exists, ABP uses it. Otherwise, it falls back to the default connection string. + +That gives you hybrid support naturally. + +### Tenant configuration storage with `ITenantStore` + +`ITenantStore` is responsible for retrieving tenant configuration. + +It can provide: + +- tenant id +- tenant name +- connection strings +- activation state and related metadata depending on implementation + +In simple setups, `DefaultTenantStore` can read from configuration. In production, tenant data is usually stored in the database through the Tenant Management module. + +### Example tenant configuration in code-backed store scenarios + +Conceptually, a tenant record may look like this: + +```json +{ + "id": "2f7f8f8d-8f8d-4f8d-9f8d-2f7f8f8d8f8d", + "name": "acme", + "connectionStrings": { + "Default": "Server=sql01;Database=Crm_Acme;User Id=app;Password=***;TrustServerCertificate=True" + } +} +``` + +### Production-ready considerations + +For database-per-tenant setups: + +- encrypt or securely store connection strings +- automate tenant database creation +- automate migrations per tenant +- monitor schema drift +- support backup and restore per tenant +- define a fallback strategy for unavailable tenant databases + +### Dynamic connection string resolution in practice + +In ABP, once the tenant is resolved and the tenant store returns a tenant-specific connection string, EF Core DbContexts use that connection automatically. You usually do not write custom DbContext switching logic yourself. + +That is one of the biggest practical benefits of using ABP instead of hand-rolling multi-tenancy. + +## Building a Sample SaaS CRM Application + +Let's connect the pieces with a realistic example. + +Imagine a SaaS CRM product with these modules: + +- host administration +- tenant administration +- customer management +- product catalog +- order management +- subscription management + +### Host administration + +Host users can: + +- create tenants +- assign subscription plans +- decide shared DB vs dedicated DB +- seed tenant defaults +- monitor tenant health + +### Tenant administration + +Tenant admins can: + +- manage users and roles +- configure CRM settings +- manage customers and products +- view tenant-specific reports + +### Domain model split + +Host-owned entities: + +- `SubscriptionPlan` +- `TenantSubscription` +- `TenantProvisioningLog` + +Tenant-owned entities implementing `IMultiTenant`: + +- `Customer` +- `Product` +- `Order` +- `Invoice` +- `SalesPipeline` + +### Provisioning flow + +A practical tenant onboarding flow: + +```mermaid +flowchart TD + A[Host Admin Creates Tenant] --> B[Store Tenant Record] + B --> C{Dedicated DB?} + C -- Yes --> D[Create Tenant Database] + C -- No --> E[Use Shared Database] + D --> F[Run Migrations] + E --> F[Run Shared/Tenant Seed Logic] + F --> G[Create Tenant Admin User] + G --> H[Tenant Ready] +``` + +### Example application service for tenant provisioning + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Data; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Tenants; + +public class TenantProvisioningAppService : ApplicationService +{ + private readonly ITenantAppService _tenantAppService; + private readonly IDataSeeder _dataSeeder; + + public TenantProvisioningAppService( + ITenantAppService tenantAppService, + IDataSeeder dataSeeder) + { + _tenantAppService = tenantAppService; + _dataSeeder = dataSeeder; + } + + public async Task ProvisionAsync(string tenantName, string adminEmail) + { + var tenant = await _tenantAppService.CreateAsync(new TenantCreateDto + { + Name = tenantName, + AdminEmailAddress = adminEmail, + Password = "ChangeMe123*" + }); + + await _dataSeeder.SeedAsync(new DataSeedContext(tenant.Id)); + } +} +``` + +The exact DTOs and APIs may vary by ABP version and modules used, but the pattern is the same: create tenant, configure infrastructure, seed tenant data, then hand over to tenant admins. + + + +![Generated illustration](inline-4.png) + +## Advanced Multi-Tenant Scenarios + +Real SaaS systems need more than request-time filtering. + +### Background jobs in tenant context + +Background jobs must run under the correct tenant. + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Acme.Crm.Jobs; + +public class RecalculateMetricsArgs +{ + public Guid TenantId { get; set; } +} + +public class RecalculateMetricsJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + + public RecalculateMetricsJob(ICurrentTenant currentTenant) + { + _currentTenant = currentTenant; + } + + public override async Task ExecuteAsync(RecalculateMetricsArgs args) + { + using (_currentTenant.Change(args.TenantId)) + { + await Task.CompletedTask; + // tenant-aware work here + } + } +} +``` + +### Distributed events + +Include tenant information in event payloads or ensure handlers execute under the correct tenant scope. Otherwise, event consumers may process data in host context accidentally. + +### Caching per tenant + +Always include tenant id in cache keys. + +Bad: + +- `customer-list` + +Good: + +- `tenant:{tenantId}:customer-list` + +Without tenant-aware keys, cache leakage is almost guaranteed. + +### Feature management + +ABP feature management is useful for SaaS plans: + +- host defines available features +- tenants receive plan-based feature values +- premium tenants can unlock advanced modules + +Examples: + +- max users +- advanced reporting +- API access +- dedicated database eligibility + +### Setting management + +Settings can be layered: + +- application default +- host override +- tenant override +- user override where appropriate + +This is ideal for SMTP settings, branding, localization preferences, and business rules. + +### Audit logging + +Audit logs should capture tenant context so you can answer: + +- who changed what +- in which tenant +- from which client +- under which user identity + +### Localization + +Multi-tenant apps often need tenant-specific culture defaults, branding, or custom terminology. Keep localization extensible, but avoid turning every string into tenant-specific data unless there is a real business need. + +### Common pitfalls + +- forgetting `IMultiTenant` on a tenant-owned entity +- disabling tenant filters too broadly +- using raw SQL without tenant predicates +- not including tenant id in cache keys +- running jobs without tenant context +- migrating only the host database in DB-per-tenant mode +- allowing `TenantId` changes after entity creation +- trusting query string tenant resolution in production + +## Best Practices for Multi-Tenant ABP Applications + +Here are practical rules that hold up in production. + +1. **Implement `IMultiTenant` on every tenant-owned aggregate root.** Missing it is a classic data leak. +2. **Treat `TenantId` as immutable.** Moving data between tenants is rarely safe. +3. **Prefer subdomain or domain-based tenant resolution in production.** It is cleaner and harder to spoof than query strings. +4. **Use query string resolution mainly for development, support, or controlled integrations.** +5. **Index `TenantId` on large tables.** Shared database performance depends on it. +6. **Include tenant id in every cache key.** Never share cache entries across tenants accidentally. +7. **Test host context explicitly.** `CurrentTenant.Id == null` is a real execution mode. +8. **Test tenant context explicitly.** Verify that tenant A cannot see tenant B data. +9. **Be careful when disabling `IMultiTenant` filters.** Keep the scope as small as possible. +10. **Avoid raw SQL unless necessary.** If you use it, add tenant predicates yourself. +11. **Automate migrations for every tenant database.** Manual migration workflows do not scale. +12. **Make seed contributors idempotent.** Provisioning and recovery flows depend on repeatable seeding. +13. **Run background jobs under the correct tenant scope.** Pass tenant id in job args. +14. **Include tenant information in distributed event contracts or handler context.** +15. **Separate host-owned and tenant-owned entities clearly.** Ambiguous ownership creates bugs. +16. **Use feature management for plan-based SaaS behavior.** Do not hardcode plan logic everywhere. +17. **Use setting management for tenant-specific configuration.** Avoid custom config tables unless necessary. +18. **Secure tenant connection strings properly.** Treat them as secrets. +19. **Monitor tenant-level performance and failures.** One noisy tenant should be visible operationally. +20. **Design for tenant lifecycle operations.** Provisioning, suspension, upgrade, backup, restore, and deletion all matter. +21. **Plan reporting architecture early.** Cross-tenant reporting is easy in shared DB and harder in DB-per-tenant. +22. **Keep authorization tenant-aware.** Filtering data is not enough if permissions are wrong. +23. **Log tenant context in diagnostics and audit trails.** It speeds up support and incident response. +24. **Use hybrid architecture only when the business case is real.** It is powerful, but operationally expensive. +25. **Document your tenant model for the team.** Most multi-tenant bugs come from inconsistent assumptions. + +## Final Thoughts + +ABP does not make multi-tenancy trivial, but it makes it systematic. That matters a lot. Instead of scattering tenant checks across controllers, repositories, and middleware, you get a coherent model built around tenant resolution, current tenant context, data filters, tenant-aware identity, and connection string management. + +For most SaaS teams, the right path is: + +- start with shared database if your compliance and scale profile allow it +- model tenant ownership carefully with `IMultiTenant` +- use `ICurrentTenant` consistently +- automate seeding and provisioning +- move selected tenants to dedicated databases when the business case appears + +That is exactly the kind of evolution ABP supports well. + +## TL;DR + +- ABP provides built-in multi-tenancy with tenant resolution, `ICurrentTenant`, `IMultiTenant`, data filters, tenant management, and per-tenant connection strings. +- Shared database is simpler and cheaper; database-per-tenant gives stronger isolation; hybrid supports both at the cost of more complexity. +- Correct tenant resolution and entity modeling are the foundation of safe multi-tenancy in ABP. +- Use tenant-aware seeding, caching, background jobs, identity, and monitoring to avoid subtle production bugs. +- For real SaaS systems, ABP gives you the infrastructure you would otherwise spend months rebuilding. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/cover.png b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/cover.png new file mode 100644 index 0000000000..b7962924a9 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png new file mode 100644 index 0000000000..67da7fdaca Binary files /dev/null and b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png new file mode 100644 index 0000000000..9dfccabb2c Binary files /dev/null and b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png new file mode 100644 index 0000000000..a320e1a22a Binary files /dev/null and b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png new file mode 100644 index 0000000000..1478c47a5e Binary files /dev/null and b/docs/en/Community-Articles/2026-06-05-implementing-multitenancy-in-abp-framework-a-complete/inline-4.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-ai-agent.png b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-ai-agent.png new file mode 100644 index 0000000000..3c5bce22c0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-ai-agent.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-download-linux.png b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-download-linux.png new file mode 100644 index 0000000000..3f30213a31 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-download-linux.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-properties.png b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-properties.png new file mode 100644 index 0000000000..913a193926 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-properties.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-system.png b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-system.png new file mode 100644 index 0000000000..be30260c71 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/abp-studio-solution-system.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/cover.png b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/cover.png new file mode 100644 index 0000000000..d0cdbfa372 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/post.md b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/post.md new file mode 100644 index 0000000000..1a50443a8c --- /dev/null +++ b/docs/en/Community-Articles/2026-06-08-abp-studio-is-now-available-on-linux/post.md @@ -0,0 +1,116 @@ +# ABP Studio Is Now Available on Linux + +We are excited to announce that [ABP Studio](https://abp.io/studio), our cross-platform desktop application for ABP developers, is now available on Linux. + +With this release, Linux users can download and run ABP Studio as an **x64 AppImage**. This is an important step in making ABP Studio available wherever .NET and ABP developers prefer to work. + +## What can you do with ABP Studio? + +[ABP Studio](https://abp.io/studio) is a desktop application designed to make ABP development faster, easier, and more comfortable. It offers: + +* Easy creation of new solutions, from simple applications to distributed systems +* Visual architecture management for modular monolith and microservice solutions +* Solution exploration tools for entities, services, packages, and HTTP APIs +* Simplified running, debugging, and monitoring of multi-application solutions +* Kubernetes integration capabilities +* Built-in access to ABP-specific tooling and workflows + +The screenshots below were captured from ABP Studio on Linux through the AppImage. + +![ABP Studio solution system selection](abp-studio-solution-system.png) + +![ABP Studio solution properties step](abp-studio-solution-properties.png) + +## Linux Support Has Arrived + +ABP Studio has already been supporting multiple desktop environments, and now Linux joins that list. + +You can currently use ABP Studio on: + +* Windows x64 +* Windows ARM +* macOS Apple Silicon +* macOS Intel +* Linux x64 **(New!)** + +On Linux, the current distribution format is **AppImage**, which provides a practical way to distribute a desktop application across different Linux distributions without requiring a distribution-specific installer package. + +## What This Means for Developers + +Many ABP developers use Linux as their daily development environment. Until now, they needed to switch to another operating system to use ABP Studio. With Linux support, developers can now stay on their preferred platform and still benefit from ABP Studio's solution creation, architecture design, solution runner, monitoring, and integrated development experience. + +This is especially valuable for teams that already build and run their backend services on Linux-based environments and want to keep their development workflow aligned with that ecosystem. + +## AI Agent Is Available on Linux Too + +Another common question from the community has been whether ABP Studio AI Agent can be used on Linux machines. With this release, the answer is yes. + +Linux users can now use ABP Studio AI Agent in their own development environment. You can ask questions about your solution, plan implementation steps before changing code, and let the agent help with coding tasks while ABP Studio understands your ABP solution structure, build flow, and runtime context. + +![abp studio ai agent on linux](abp-studio-ai-agent.png) +For a deeper look at the AI Agent experience, see the original announcement: [Introducing ABP Studio AI Agent](https://abp.io/community/announcements/introducing-abp-studio-ai-agent-o1ni0toc). + +## Getting Started + +Downloading and running ABP Studio on Linux is simple: + +![abp studio download on linux](abp-studio-download-linux.png) + +1. Go to [abp.io/studio](https://abp.io/studio) +2. Download the **Linux x64 AppImage** +3. Open a terminal in the folder where the file was downloaded +4. Make the AppImage executable and run it + +```bash +chmod +x ./AbpStudio-stable.AppImage +./AbpStudio-stable.AppImage +``` + +Once launched, you can start using ABP Studio just like on the other supported platforms. + +## If the AppImage Does Not Run Directly + +Some Linux distributions may require additional runtime support for direct AppImage execution. + +For example, on some Ubuntu and Debian-based systems, you may need `libfuse2`: + +```bash +sudo apt update +sudo apt install libfuse2 +``` + +If FUSE is not available on your machine, you can still extract and run the AppImage manually: + +```bash +./AbpStudio-stable.AppImage --appimage-extract +./squashfs-root/AppRun +``` + +This fallback can be useful for testing or for environments where AppImage mounting is restricted. + +## Current Scope and Limitations + +This first Linux release is intentionally focused so we can deliver a reliable experience quickly. + +Here is the current scope: + +* Linux distribution is currently provided as an **x64 AppImage** +* **Linux ARM builds are not published yet** +* Depending on your Linux distribution, some native desktop or browser-related libraries may need to be installed +* When ABP Studio can detect a known native dependency problem, it tries to show guidance in the UI instead of leaving you with an unclear failure + +This means Linux support is ready to use today, and we will continue to improve the Linux experience in future releases. + +## A Better Cross-Platform Experience + +ABP Studio has always aimed to be the default way to start and develop ABP solutions. Linux support brings us closer to that goal by making the Studio experience more accessible across major desktop platforms. + +Whether you are creating a new solution, exploring packages and services, running multiple applications together, or monitoring runtime behavior, you can now do that on Linux too. + +## Conclusion + +We are happy to finally make ABP Studio available on Linux. + +This first release focuses on a practical and reliable target: **Linux x64 through AppImage**. It already opens the door for many developers who prefer Linux as their primary development environment, and it gives us a strong foundation to improve the Linux experience further. + +Please download it, try it in your daily workflow, and share your feedback with us. If you encounter a problem or want to request additional Linux targets like ARM, feel free to open an issue and let us know. diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/Post.md b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/Post.md new file mode 100644 index 0000000000..a63ce8c77c --- /dev/null +++ b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/Post.md @@ -0,0 +1,496 @@ +ABP has supported multiple UI approaches for a long time, but many teams building line-of-business apps have been waiting for a first-class React option that feels native to the framework instead of bolted on. That is exactly what the ABP React template brings. + +If you are already using ABP for application services, modules, authentication, multi-tenancy, and code generation, the React template gives you a modern frontend stack without forcing you to hand-wire the same infrastructure in every project. You get React + TypeScript, a sensible project structure, generated API clients, authentication, localization, permission-aware UI, and a prebuilt admin experience that matches how ABP applications are typically built. + +This article explains what the ABP React template is, how it is structured, what you get out of the box, where it fits well, and what to watch for before adopting it. + +## What is the ABP React template? + +The ABP React template is the React UI option in ABP Framework's modern template system. It is available through ABP Studio v3.0+ and through the CLI when you create a modern solution. + +Typical commands look like this: + +```bash +abp new MyCompany.MyApp --modern --ui-framework react +``` + +Depending on your architecture, ABP supports React in these template types: + +- Layered: `app --modern` +- Single-layer: `app-nolayers --modern` +- Microservice: `microservice --modern` + +A very important detail: React UI is part of the modern template system. If you create a classic ABP solution, React UI is not included. + +At the time of its introduction, the React UI arrived as a beta/preview in 10.4.0-rc.1, with general availability planned for ABP 10.4 stable. So if you are evaluating it, make sure your ABP version and tooling align with the current docs. + +## The tech stack behind the template + +ABP did not build the React template around random choices. The stack is opinionated, but in a practical way that fits business applications. + +### Core frontend stack + +The template uses: + +- React +- TypeScript +- Vite +- TanStack Router +- TanStack Query +- Tailwind CSS +- shadcn/ui +- Radix UI +- Zod +- React Hook Form +- Axios +- Vitest + +That combination matters because it covers the common pain points in enterprise React apps: + +- Routing is structured and type-friendly. +- Server state is handled properly instead of scattered across components. +- Forms and validation are consistent. +- UI components are source-owned, not black-box widgets. +- Build and local development are fast. + +### Why this stack makes sense for ABP apps + +ABP applications usually have: + +- many CRUD screens +- authenticated users +- role and permission checks +- localization +- backend-driven DTOs +- modular features +- admin pages +- multi-tenant concerns + +The React template maps well to that reality. It is not trying to be a blank React starter. It is trying to be a production-ready frontend foundation for ABP-based applications. + + + +![Generated illustration](inline-1.png) + +## Project structure and template layout + +The structure depends on which ABP solution type you choose. + +### Layered and single-layer solutions + +In layered and single-layer templates, the React application lives under the solution root: + +```text +react/ +``` + +The Admin Console is embedded under the backend host and served from: + +```text +/admin-console/* +``` + +This setup is convenient when you want a unified application host and do not need to split the frontend into separate deployable apps. + +### Microservice solutions + +In the microservice template, the React application is placed under: + +```text +apps/react/ +``` + +The Admin Console is a separate app: + +```text +apps/react-admin-console/ +``` + +It is typically served through the Web Gateway using YARP. This is a better fit when your architecture already separates concerns across gateways and independently deployed services. + +### Common folders you will work with + +The frontend typically includes folders like these: + +- `components/` for layouts, reusable UI, and feature components +- `lib/` for auth, API setup, theme, and routing helpers +- `pages/` for page-level components +- `routes/` for route definitions +- `locales/` for translations + +This is a familiar structure for React teams, but it also reflects ABP conventions well enough that backend and frontend concerns stay aligned. + +## What you get out of the box + +The main value of the ABP React template is not that it uses React. Plenty of templates do that. The real value is that it wires React into ABP's application model. + + + +![Generated illustration](inline-2.png) + +## Authentication and authorization + +Authentication is one of the first places where many custom React frontends get messy. The ABP React template handles this using OpenID Connect with Authorization Code flow and PKCE against the ABP Auth Server. + +That means you get a setup suitable for modern browser-based applications without having to build the auth plumbing from scratch. + +### What is included + +Out of the box, you get: + +- OIDC integration +- route protection +- auth-related helpers and hooks +- support for ABP's authorization model +- permission-aware navigation and UI + +Permission-aware UI is especially useful in real applications. Instead of hardcoding role checks everywhere, you can use ABP's permission system in the frontend so menus, buttons, and screens reflect what the current user is actually allowed to do. + +For example, a user without the right permission should not see management actions just because the page rendered successfully. + +### Why this matters in practice + +In many projects, teams secure the backend correctly but forget to make the frontend behave consistently. The result is confusing UX: + +- users see actions they cannot execute +- menus show modules they cannot access +- pages fail after navigation instead of being guarded earlier + +The ABP React template reduces that mismatch. + +## API integration without hand-written client boilerplate + +One of the most useful parts of the template is the generated API client approach. + +ABP can generate frontend API clients from OpenAPI definitions, so your React app consumes backend endpoints using generated contracts instead of duplicated DTO definitions or hand-written fetch code. + +### Why this is a big deal + +Without generated clients, frontend/backend integration often drifts over time: + +- DTOs change but frontend types do not +- query strings are built inconsistently +- error handling varies by developer +- service layers become repetitive + +With the ABP React template, Axios is already set up and typically used together with TanStack Query. That gives you a cleaner pattern for data fetching, caching, invalidation, and loading states. + +A simplified example looks like this: + +```tsx +import { useQuery } from '@tanstack/react-query'; +import { identityUserControllerGetList } from '@/client'; + +export function UsersPage() { + const query = useQuery({ + queryKey: ['users'], + queryFn: () => identityUserControllerGetList({ maxResultCount: 10, skipCount: 0 }), + }); + + if (query.isLoading) return
Loading...
; + if (query.isError) return
Failed to load users.
; + + return ( +
    + {query.data.items.map((user) => ( +
  • {user.userName}
  • + ))} +
+ ); +} +``` + +The exact generated function names may vary based on your solution, but the pattern is the point: use generated contracts, wrap them with TanStack Query, and keep components focused on UI. + +## UI system and customization model + +The ABP React template uses shadcn/ui with Radix UI primitives and Tailwind CSS. That is a smart choice for teams that want control over their UI instead of being locked into a vendor component library. + +### Source-owned components + +This is one of the most important characteristics of the template: the frontend is source-owned. + +Your components, pages, and UI primitives live in your solution. They are not hidden behind proprietary packages that you cannot meaningfully adjust. + +That gives you freedom to: + +- restyle components +- change layouts +- modify route structure +- reorganize menus +- build your own design language on top +- customize page composition without fighting framework internals + +In practice, ABP places UI primitives under locations like `src/components/ui/`, so you can modify them directly when needed. + +### Theme support + +The template supports light, dark, and system themes. Styling is based on Tailwind CSS and CSS variables, which is a good fit for maintainable theming. + +If your team needs brand-level customization, this approach is usually easier to work with than overriding a large third-party theme package. + +### A practical trade-off + +Source-owned UI gives you control, but it also means you own consistency. If every developer changes shared components casually, the design system can drift quickly. + +The template gives you freedom. Your team still needs discipline. + + + +![Generated illustration](inline-3.png) + +## Forms, validation, and developer ergonomics + +Business apps live and die by form quality. The ABP React template uses React Hook Form and Zod, which is a solid setup for forms that need validation, predictable behavior, and clean code. + +This stack helps with: + +- schema-driven validation +- reusable form components +- clearer error handling +- less form boilerplate +- better TypeScript integration + +For ABP-style applications with lots of create/update dialogs and data-entry screens, that is a practical choice. + +A minimal example might look like this: + +```tsx +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), +}); + +type FormValues = z.infer; + +export function ProductForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '' }, + }); + + const onSubmit = (values: FormValues) => { + console.log(values); + }; + + return ( +
+ +

{form.formState.errors.name?.message}

+ +
+ ); +} +``` + +The exact ABP project will likely wrap inputs with shared UI components, but the workflow remains familiar. + +## Localization and multi-tenancy + +If you are building a SaaS product or an internal enterprise application for multiple regions, this part matters a lot. + +The ABP React template includes support for: + +- localization with i18next +- ABP localization resources and keys +- culture information from the backend +- end-to-end multi-tenant scenarios + +This is where ABP has always been strong, and the React template carries that strength into the frontend. + +### Why this matters + +Many React starters look great until you need to answer these questions: + +- Where does the current tenant come from? +- How does the frontend know which culture is active? +- How are permission and tenant context propagated to API requests? +- How do I keep localization aligned with backend resources? + +With the ABP React template, these are not afterthoughts. + + + +![Generated illustration](inline-4.png) + +## The built-in Admin Console + +Another notable piece is the React-based Admin Console provided through `Volo.Abp.AdminConsole`. + +This gives you a prebuilt admin UI for common ABP modules such as: + +- Identity +- Settings +- Audit Logs +- and other standard management features + +### Why it is useful + +For many projects, admin screens are necessary but not differentiating. You need them, but you do not want to spend weeks rebuilding infrastructure screens that ABP already understands well. + +The Admin Console helps you start with a solid baseline. + +### One thing to be clear about + +The Admin Console is managed by ABP and is often intended to be used mostly as-is. If your project requires heavy customization there, you should evaluate that path carefully before assuming it behaves like the rest of your source-owned frontend. + +That is not necessarily a problem, but it is worth knowing early. + +## Runtime configuration with dynamic-env.json + +A practical detail that deserves more attention is `dynamic-env.json` under the public folder. + +This file allows runtime configuration for values such as: + +- OAuth issuer +- client ID +- API base URLs +- related environment settings + +That means you can adapt environment-specific settings without rebuilding the frontend for every deployment target. + +For teams deploying across dev, staging, and production, this is much more convenient than baking every environment value into the build. + +### Real-world example + +Suppose your staging environment uses different hostnames and an alternate auth server URL. With runtime configuration, you can update those values at deployment time instead of producing another frontend artifact just for staging. + +That said, when you change URLs or hostnames, remember that runtime config is only part of the story. You also need to update OpenIddict client settings such as redirect URIs and related auth configuration. + +## Development workflow + +The local development loop is straightforward. + +### Running the application + +You typically: + +1. start the backend host with `dotnet run` +2. go to the React app folder +3. install dependencies with `npm install` +4. start the frontend with `npm run dev` + +Vite keeps frontend startup and rebuild times fast, which helps a lot when you are iterating on pages and forms. + +### Production build + +For production, you build the frontend with: + +```bash +npm run build +``` + +This outputs the app to `dist/`. How it is served depends on the template: + +- via the backend host in simpler solution structures +- via a gateway or separate frontend host in microservice setups + +### Testing + +The template uses Vitest and React Testing Library, with scripts such as: + +- `npm run test` +- `npm run test:coverage` + +This is a good baseline. It encourages teams to test UI behavior without dragging in slow or outdated frontend test setups. + +## When to use the ABP React template + +The ABP React template is a strong fit when your project already wants ABP's backend capabilities and your team prefers React on the frontend. + +Use it when: + +- you are starting a new ABP project with a modern template +- you want React + TypeScript with ABP conventions already wired in +- you need authentication, permissions, localization, and multi-tenancy from day one +- you want generated API clients instead of duplicated DTOs +- you prefer source-owned UI components +- your app is admin-heavy, form-heavy, or module-heavy + +### Good fit examples + +- SaaS admin portals +- internal enterprise dashboards +- operations and management systems +- modular business applications with many CRUD workflows +- ABP-based platforms needing a modern React frontend + +## When not to use it + +It is not the right default for every case. + +Avoid or reconsider it when: + +- you are on a classic ABP template and do not want to migrate to modern templates +- your team wants a very custom frontend architecture unrelated to ABP conventions +- your application is mostly marketing pages or content-driven public pages +- you do not need ABP's auth, permission, module, or tenant model +- you expect deep customization of every built-in admin experience without validating the boundaries first + +### A practical warning + +Do not choose the template just because it uses React. Choose it because you want React inside the ABP ecosystem. + +That distinction matters. + +## Common pitfalls and things to check early + +The template is productive, but there are a few details worth validating at the beginning of a project. + +### 1. Make sure you are using a modern template + +This is the most common source of confusion. React UI is for modern templates. If you create a classic solution, the React UI option is not there. + +### 2. Align auth settings across environments + +If you change frontend URLs, callback paths, ports, or hostnames, update: + +- runtime frontend configuration +- OpenIddict client redirect URIs +- post-logout redirect URIs if applicable +- any gateway or proxy configuration involved + +A lot of login issues come from partial updates here. + +### 3. Treat source-owned UI like a product asset + +Because the UI code is in your solution, it is easy to modify. That is good. It also means teams can slowly lose consistency unless they define clear frontend standards. + +### 4. Understand the Admin Console boundary + +The Admin Console is valuable, but it is not the same customization surface as your app pages. If extensive admin customization is a requirement, validate that path before committing architecture decisions around it. + +## Why the ABP React template is different from a plain React starter + +A plain React starter gives you flexibility, but also leaves many critical concerns unresolved. The ABP React template starts from a different assumption: most business applications need the same infrastructure pieces, and those pieces should work together. + +Compared to a generic starter, ABP gives you tighter integration for: + +- auth and authorization +- generated API clients +- localization +- tenant-aware applications +- modular backend alignment +- admin capabilities +- solution-level conventions + +That makes it less minimal than a blank React scaffold, but much more useful for real ABP projects. + +## Final thoughts + +The ABP React template is not interesting because it says React on the label. It is interesting because it brings React into ABP's application model in a way that feels intentional. + +You get a modern frontend stack, source-owned customization, generated client integration, and the ABP features many teams actually need in production: permissions, localization, multi-tenancy, and admin tooling. + +If your team already values ABP on the backend and wants React on the frontend, this template is one of the fastest ways to get to a serious foundation without spending the first sprint rebuilding plumbing. + +## TL;DR + +- The ABP React template is available in ABP's modern template system, not classic templates. +- It uses a practical stack: React, TypeScript, Vite, TanStack Router/Query, shadcn/ui, Tailwind, Zod, and Axios. +- Key strengths are generated API clients, OIDC auth, permission-aware UI, localization, and multi-tenancy. +- The frontend is source-owned, which gives you flexibility but also requires discipline. +- It is a strong choice for ABP-based business apps, especially admin-heavy and SaaS-style applications. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/cover.png b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/cover.png new file mode 100644 index 0000000000..030338577f Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-1.png b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-1.png new file mode 100644 index 0000000000..32b829bbf4 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-2.png b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-2.png new file mode 100644 index 0000000000..11d686df90 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-3.png b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-3.png new file mode 100644 index 0000000000..91d7568b10 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-4.png b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-4.png new file mode 100644 index 0000000000..73bc2d57fc Binary files /dev/null and b/docs/en/Community-Articles/2026-06-08-getting-started-with-the-abp-react-template/inline-4.png differ diff --git a/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/Post.md b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/Post.md new file mode 100644 index 0000000000..5997617f4c --- /dev/null +++ b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/Post.md @@ -0,0 +1,1363 @@ +# Implementing Multi-Tenancy in ABP Framework: A Complete Practical Guide + +Multi-tenancy is one of those features that looks straightforward on a whiteboard and gets complicated the moment real customers, databases, authentication, billing, and background jobs enter the picture. + +If you are building a SaaS application on ASP.NET Core, you need more than a `TenantId` column scattered across a few tables. You need a consistent way to resolve the tenant for each request, isolate data safely, seed tenant-specific records, handle host-level administration, and support different deployment models as your product grows. + +This is where ABP Framework helps a lot. Multi-tenancy is built into the framework's core architecture: request pipeline, entity model, data filters, tenant store, identity integration, settings, features, and modules all understand the concept of host and tenant. + +In this guide, we will go from architecture to implementation. The focus is practical: how ABP multi-tenancy works, how to enable it correctly, how to model tenant-aware entities, how request resolution flows through the app, and how to build a real SaaS CRM application on top of it. + +## Introduction to Multi-Tenancy + +Multi-tenancy means a single software application serves multiple customers, where each customer is treated as a separate tenant. A tenant usually has its own users, roles, settings, data, and operational boundaries. + +In SaaS terms: + +- The **host** is the software provider, the platform owner. +- A **tenant** is a customer organization using the system. + +### Single-tenant vs multi-tenant architecture + +A **single-tenant** system typically gives each customer a separate deployed application instance, often with a separate database and infrastructure boundary. + +A **multi-tenant** system shares the application runtime and, depending on the model, may share databases too. + +**Single-tenant** usually gives: + +- simpler isolation reasoning +- easier customer-specific customization +- higher infrastructure cost +- more operational duplication + +**Multi-tenant** usually gives: + +- better resource efficiency +- easier centralized updates +- lower cost per customer +- more complexity around isolation and scaling + +### Why multi-tenancy matters for SaaS + +Most SaaS products eventually need: + +- centralized onboarding +- tenant-specific authentication and authorization +- subscription plans and features +- controlled data isolation +- operational efficiency at scale + +Without a solid multi-tenancy model, these become ad hoc implementations. That tends to create subtle security bugs and hard-to-maintain code. + +### Advantages and challenges + +**Advantages** + +- Better infrastructure utilization +- Centralized deployment and upgrades +- Lower operating cost per customer +- Easier platform-wide monitoring and governance +- Feature and setting management per tenant + +**Challenges** + +- Preventing cross-tenant data leakage +- Tenant-aware caching and background jobs +- Managing request resolution correctly +- Handling database-per-tenant operations +- Supporting host-side administration cleanly + +### Why ABP Framework simplifies it + +ABP does not treat multi-tenancy as a naming convention. It treats it as a first-class capability. + +You get: + +- built-in tenant resolution pipeline +- `ICurrentTenant` for runtime tenant context +- `IMultiTenant` support for entities +- automatic data filters +- host and tenant side concepts across modules +- tenant management module and tenant store abstraction +- identity, features, settings, and permissions that understand tenants + +That combination is what makes ABP especially useful for serious SaaS applications. + +## Understanding ABP Multi-Tenancy Architecture + +At the center of ABP multi-tenancy are a few concepts that appear everywhere in the application lifecycle. + +### Tenant concept + +A tenant is usually a customer organization. In a CRM product, one tenant might be `acme`, another might be `globex`. Each one has: + +- its own users +- its own roles +- its own application data +- optionally its own connection string +- optionally its own features and settings + +The host is not just another tenant. It is the platform owner context. + +### Host side vs tenant side + +ABP explicitly distinguishes between host-side and tenant-side operations. + +**Host side** typically includes: + +- creating and managing tenants +- viewing subscription status +- assigning features and plans +- managing platform-level settings +- running cross-tenant operations + +**Tenant side** typically includes: + +- managing tenant users and roles +- working with tenant business data +- updating tenant settings +- tenant-specific administration + +In shared database setups, host-side records usually have `TenantId == null`. Tenant-side records have a concrete `TenantId`. + +### `ICurrentTenant` + +`ICurrentTenant` is the runtime source of truth for tenant context. + +It exposes: + +- `Id` +- `Name` +- `IsAvailable` + +In application services, domain services, controllers, and many ABP base classes, it is already available or easy to inject. + +Example: + +```csharp +public class DashboardAppService : ApplicationService +{ + public string GetContextInfo() + { + if (!CurrentTenant.IsAvailable) + { + return "Host context"; + } + + return $"Tenant: {CurrentTenant.Name} ({CurrentTenant.Id})"; + } +} +``` + +If `CurrentTenant.Id` is `null`, you are in host context. + +### Tenant resolution pipeline + +Before your application logic runs, ABP tries to determine which tenant the request belongs to. + +ABP uses a set of tenant resolvers, executed in order. Common sources include: + +- current user claims +- query string parameter `__tenant` +- route value `__tenant` +- header `__tenant` +- cookie `__tenant` +- domain or subdomain pattern + +The middleware `UseMultiTenancy()` plugs this into the ASP.NET Core pipeline. + +Because diagram DSLs are not suitable here, the request flow is best explained in steps: + +1. An HTTP request reaches the ASP.NET Core pipeline. +2. ABP multi-tenancy middleware runs. +3. Configured tenant resolvers inspect the request. +4. If a tenant identifier is found, ABP loads tenant information from the tenant store. +5. `ICurrentTenant` is populated for the rest of the request scope. +6. Repository queries and data filters automatically use the current tenant context. +7. Authentication, authorization, settings, features, and caches can all behave tenant-aware. + +### `IMultiTenant` + +`IMultiTenant` marks an entity as tenant-aware. + +It defines: + +```csharp +public interface IMultiTenant +{ + Guid? TenantId { get; } +} +``` + +In practice, entities implementing this interface participate in ABP's multi-tenant data filtering behavior. + +### Data filters + +ABP automatically applies data filters to entities implementing `IMultiTenant`. In a shared database model, queries are filtered so the current tenant sees only its own records. + +That means code like this: + +```csharp +var products = await _productRepository.GetListAsync(); +``` + +will only return the current tenant's products when `Product` implements `IMultiTenant`. + +If you are on host side and intentionally need cross-tenant access, you can disable the filter temporarily: + +```csharp +using (_dataFilter.Disable()) +{ + var allProducts = await _productRepository.GetListAsync(); +} +``` + +This is powerful and dangerous. Use it carefully. + +### Tenant Management module + +ABP's Tenant Management module gives you a production-ready foundation for tenant administration. + +It provides: + +- tenant CRUD operations +- tenant connection strings +- tenant store integration +- management UI in ABP-based solutions +- admin user provisioning support + +In real projects, this removes a lot of plumbing work. + + + +![Generated illustration](inline-1.png) + +## Multi-Tenancy Models Supported by ABP + +ABP supports all common SaaS multi-tenancy models. + +### 1. Single database + +All tenants share one database and typically the same schema. Data is separated logically using `TenantId` and data filters. + +**How it works** + +- one database for host and tenant data +- tenant-aware tables contain a `TenantId` +- ABP filters queries automatically based on current tenant + +**Advantages** + +- simplest to start with +- cheapest operationally +- easiest schema migration story +- simpler reporting when all tenant data is in one place + +**Disadvantages** + +- strongest need for careful isolation +- noisy-neighbor effects at database level +- scaling limits can appear earlier +- large shared tables can become operational pain points + +### 2. Database per tenant + +Each tenant gets a dedicated database. The host may still use a separate shared database for platform-level data. + +**How it works** + +- tenant-specific connection strings are stored per tenant +- ABP resolves the current tenant, then resolves the tenant's database connection +- repositories work in the tenant's database context + +**Advantages** + +- stronger isolation +- easier compliance story for some customers +- easier tenant-specific restore and backup +- tenant-level scaling options + +**Disadvantages** + +- more complex provisioning +- harder cross-tenant reporting +- more migration orchestration +- greater operational overhead + +### 3. Hybrid model + +Some tenants share a database, others get dedicated databases. + +This is common in enterprise SaaS. + +**Typical scenario** + +- small and mid-market tenants use shared infrastructure +- premium or regulated customers get dedicated databases +- host still manages everything through the same application codebase + +**Advantages** + +- flexible cost model +- premium isolation for large customers +- efficient default for smaller tenants + +**Disadvantages** + +- highest operational complexity +- more provisioning paths to maintain +- more testing combinations + +### Comparison + +| Model | Database layout | Strengths | Weaknesses | Best fit | +|---|---|---|---|---| +| Single database | All tenants share one DB | Simple, cheap, easy migrations | Lower isolation, shared load | Early-stage SaaS, internal SaaS | +| Database per tenant | One DB per tenant | Strong isolation, restore flexibility | Higher ops cost, harder reporting | Enterprise SaaS, regulated environments | +| Hybrid | Shared for some, dedicated for others | Flexible pricing and isolation | Most complex to operate | Growing SaaS platforms | + +### When to use / When NOT to use + +**Use single database when:** + +- you are launching an MVP or early SaaS +- tenant counts are moderate +- compliance requirements are manageable +- operational simplicity matters most + +**Do not use single database when:** + +- customers require physical data isolation +- large tenants can dominate database resources +- tenant-specific backup and restore is mandatory + +**Use database per tenant when:** + +- compliance and isolation matter a lot +- customers pay enough to justify dedicated infrastructure +- you need tenant-level backup, restore, and scaling + +**Do not use database per tenant when:** + +- you are optimizing for low operational overhead +- your team is not ready to automate migrations and provisioning +- most tenants are very small and margins are tight + +**Use hybrid when:** + +- you want shared infrastructure by default +- you have a premium enterprise tier +- you need a migration path from shared to dedicated tenants + +**Do not use hybrid when:** + +- your platform operations are still immature +- you want to minimize architectural branching + + + +![Generated illustration](inline-2.png) + +## Enabling Multi-Tenancy in an ABP Application + +Multi-tenancy is off by default in the framework, although ABP startup templates commonly enable it for you. + +A clean approach is to centralize the flag in `MultiTenancyConsts`. + +### `MultiTenancyConsts` + +```csharp +namespace Acme.Crm; + +public static class MultiTenancyConsts +{ + public const bool IsEnabled = true; +} +``` + +### Configure `AbpMultiTenancyOptions` + +In your HTTP API Host module: + +```csharp +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.IsEnabled = MultiTenancyConsts.IsEnabled; + }); +} +``` + +### Add middleware + +In `OnApplicationInitialization`: + +```csharp +public override void OnApplicationInitialization(ApplicationInitializationContext context) +{ + var app = context.GetApplicationBuilder(); + var env = context.GetEnvironment(); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseAuthentication(); + + if (MultiTenancyConsts.IsEnabled) + { + app.UseMultiTenancy(); + } + + app.UseAuthorization(); + app.UseConfiguredEndpoints(); +} +``` + +A practical rule: place `UseMultiTenancy()` after authentication setup and before application endpoints. + +### Configuration example + +If you use ABP's default tenant store from configuration in a simple setup, you can define tenants in `appsettings.json`. + +```json +{ + "TenantManagement": { + "Tenants": [ + { + "Id": "11111111-1111-1111-1111-111111111111", + "Name": "acme" + }, + { + "Id": "22222222-2222-2222-2222-222222222222", + "Name": "globex" + } + ] + } +} +``` + +In real applications, the Tenant Management module is usually a better choice than static config. + +## Tenant Resolution Strategies + +Tenant resolution is where many real-world mistakes happen. The framework can only isolate data correctly if the tenant is resolved correctly. + +### Default resolvers + +ABP can resolve tenant information from: + +- authenticated user claims +- query string: `__tenant` +- route value: `__tenant` +- header: `__tenant` +- cookie: `__tenant` + +### Configure resolver options + +```csharp +using Volo.Abp.AspNetCore.MultiTenancy; +using Volo.Abp.MultiTenancy; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.AddDefaultResolvers(); + }); +} +``` + +### Subdomain resolution + +Subdomain-based tenant resolution is common in SaaS. + +Examples: + +- `acme.mycrm.com` +- `globex.mycrm.com` + +Configuration: + +```csharp +Configure(options => +{ + options.AddDomainTenantResolver("{0}.mycrm.com"); +}); +``` + +Now a request to `acme.mycrm.com` resolves tenant name `acme`. + +### Domain resolution + +You can also map full domains, especially for custom domains. + +For example, a tenant might use: + +- `crm.acme.com` +- `sales.globex.io` + +You typically handle these with custom tenant resolution logic backed by your own domain mapping table. + +### Header-based resolution + +Useful for internal APIs, gateways, or backend-to-backend communication. + +Example request: + +```http +GET /api/products +__tenant: acme +``` + +This is convenient, but do not trust arbitrary tenant headers from public traffic unless a trusted gateway injects them. + +### Query string resolution + +Useful for demos, diagnostics, or very simple integrations. + +Example: + +```text +https://api.mycrm.com/api/products?__tenant=acme +``` + +It works, but it is not usually the best production UX. + +### Route-based resolution + +You can support routes like: + +```text +/api/{__tenant}/products +``` + +This is more common in APIs than browser-based SaaS frontends. + +### Custom tenant resolver + +For custom logic, implement a tenant resolve contributor. + +```csharp +using System.Threading.Tasks; +using Volo.Abp.MultiTenancy; + +public class ApiKeyTenantResolveContributor : TenantResolveContributorBase +{ + public override string Name => "ApiKey"; + + public override Task ResolveAsync(ITenantResolveContext context) + { + var httpContext = context.GetHttpContext(); + var apiKey = httpContext?.Request.Headers["X-Api-Key"].ToString(); + + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Task.CompletedTask; + } + + if (apiKey == "acme-key") + { + context.Handled = true; + context.TenantIdOrName = "acme"; + } + + return Task.CompletedTask; + } +} +``` + +Register it: + +```csharp +Configure(options => +{ + options.TenantResolvers.Insert(0, new ApiKeyTenantResolveContributor()); +}); +``` + +Putting your resolver at the beginning gives it priority. + +### How tenant identification flows through an HTTP request + +A practical request flow looks like this: + +1. Browser requests `https://acme.mycrm.com/api/app/customers`. +2. ASP.NET Core receives the request. +3. `UseMultiTenancy()` runs. +4. Domain resolver extracts `acme` from the host. +5. ABP loads tenant metadata from `ITenantStore`. +6. `ICurrentTenant.Id` is set for the request scope. +7. Authentication and authorization proceed in tenant context. +8. Repositories apply the tenant data filter. +9. Only `acme` data is returned. + +That is the core path you should have in mind when debugging tenant issues. + + + +![Generated illustration](inline-3.png) + +## Creating Tenant-Aware Entities + +The heart of tenant data isolation is entity design. + +### Using `IMultiTenant` + +Let's model a SaaS CRM with `Product`, `Customer`, and `Order`. + +```csharp +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +public class Product : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public string Name { get; private set; } + public decimal Price { get; private set; } + + protected Product() + { + } + + public Product(Guid id, string name, decimal price, Guid? tenantId) + : base(id) + { + TenantId = tenantId; + Name = name; + Price = price; + } +} +``` + +```csharp +public class Customer : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public string Name { get; private set; } + public string Email { get; private set; } + + protected Customer() + { + } + + public Customer(Guid id, string name, string email, Guid? tenantId) + : base(id) + { + TenantId = tenantId; + Name = name; + Email = email; + } +} +``` + +```csharp +public class Order : FullAuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid CustomerId { get; private set; } + public decimal TotalAmount { get; private set; } + + protected Order() + { + } + + public Order(Guid id, Guid customerId, decimal totalAmount, Guid? tenantId) + : base(id) + { + TenantId = tenantId; + CustomerId = customerId; + TotalAmount = totalAmount; + } +} +``` + +### Aggregate root considerations + +In a multi-tenant model: + +- aggregate roots should clearly belong to host or tenant side +- child entities usually follow the aggregate root's tenant boundary +- references between aggregates from different tenants should be avoided +- host-side entities should not accidentally depend on tenant-scoped data + +A simple rule is helpful: if an entity exists only inside a tenant's business workflow, make it tenant-aware. + +### Automatically assigning `TenantId` + +In application services, use `CurrentTenant.Id` when creating tenant-owned entities. + +```csharp +public class ProductAppService : ApplicationService +{ + private readonly IRepository _productRepository; + + public ProductAppService(IRepository productRepository) + { + _productRepository = productRepository; + } + + public async Task CreateAsync(string name, decimal price) + { + var product = new Product( + GuidGenerator.Create(), + name, + price, + CurrentTenant.Id + ); + + await _productRepository.InsertAsync(product, autoSave: true); + return product.Id; + } +} +``` + +### How ABP filters tenant data automatically + +Suppose `acme` is the current tenant. + +This code: + +```csharp +var customers = await _customerRepository.GetListAsync(); +``` + +will generate SQL conceptually similar to: + +```sql +SELECT * +FROM CrmCustomers +WHERE TenantId = @CurrentTenantId +``` + +If the filter is disabled from host context, the generated query may no longer include the tenant predicate. + +The exact SQL depends on EF Core and your provider, but the practical point is the same: ABP injects the tenant boundary for you. + +## Working with `ICurrentTenant` + +`ICurrentTenant` is not just for reading tenant info. It is also how you intentionally switch tenant context during controlled operations. + +### Reading current tenant information + +```csharp +public class TenantInfoAppService : ApplicationService +{ + public object GetCurrent() + { + return new + { + CurrentTenant.Id, + CurrentTenant.Name, + CurrentTenant.IsAvailable + }; + } +} +``` + +### Changing tenant context + +A host-side admin service may need to run work inside a specific tenant. + +```csharp +public class TenantReportingAppService : ApplicationService +{ + private readonly IRepository _customerRepository; + + public TenantReportingAppService(IRepository customerRepository) + { + _customerRepository = customerRepository; + } + + public async Task GetCustomerCountAsync(Guid tenantId) + { + using (CurrentTenant.Change(tenantId)) + { + return await _customerRepository.GetCountAsync(); + } + } +} +``` + +### Switching to host context + +```csharp +using (CurrentTenant.Change(null)) +{ + // host-side logic here +} +``` + +### Nested tenant scopes + +ABP restores the previous tenant automatically after the `using` block ends. + +```csharp +using (CurrentTenant.Change(tenantA)) +{ + // tenant A + + using (CurrentTenant.Change(tenantB)) + { + // tenant B + } + + // back to tenant A +} +``` + +This matters in background jobs, cross-tenant maintenance tasks, and provisioning routines. + +## Data Isolation Mechanisms in ABP + +ABP's multi-tenancy model is more than a `TenantId` field. + +### Automatic data filtering + +If an entity implements `IMultiTenant`, ABP applies a filter automatically. This reduces the amount of repetitive tenant checks you need to write manually. + +That said, you should still think in layers: + +- **Resolution** determines who the tenant is. +- **Filtering** limits data access. +- **Authorization** controls what the current user can do. +- **Database strategy** defines physical or logical isolation. + +These layers complement each other. + +### Tenant-specific repositories + +You usually do not need separate repository implementations just to filter by tenant. The standard repository already respects the active tenant context. + +But custom repository methods still need discipline. If you write raw SQL, disable filters, or query across host boundaries, you must preserve isolation intentionally. + +### Unit of Work integration + +ABP's Unit of Work operates inside the current tenant context. That means reads and writes in the same UoW use the same tenant scope unless you explicitly change it. + +This is one reason `CurrentTenant.Change(...)` is safer than trying to pass tenant identifiers manually through every service method. + +### Security implications + +Multi-tenancy bugs are often security bugs. + +Be especially careful with: + +- host-side screens that disable tenant filters +- header and query-string based tenant resolution in public endpoints +- cross-tenant exports and reports +- tenant-aware caches +- background jobs and event handlers that run without explicit tenant context + +A common mistake is assuming data filtering replaces authorization. It does not. + +## Seeding Tenant Data + +A good SaaS system usually needs both host seed data and tenant seed data. + +Examples: + +- host roles and platform settings +- tenant admin user +- default CRM stages +- default product catalog or sample records + +ABP uses `IDataSeedContributor` for this. + +### Basic seed contributor + +```csharp +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +public class CrmDataSeedContributor : IDataSeedContributor, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _productRepository; + + public CrmDataSeedContributor( + ICurrentTenant currentTenant, + IRepository productRepository) + { + _currentTenant = currentTenant; + _productRepository = productRepository; + } + + public async Task SeedAsync(DataSeedContext context) + { + using (_currentTenant.Change(context?.TenantId)) + { + if (await _productRepository.GetCountAsync() > 0) + { + return; + } + + await _productRepository.InsertAsync( + new Product(Guid.NewGuid(), "Starter Plan", 49, _currentTenant.Id), + autoSave: true + ); + + await _productRepository.InsertAsync( + new Product(Guid.NewGuid(), "Growth Plan", 99, _currentTenant.Id), + autoSave: true + ); + } + } +} +``` + +### Host seed vs tenant seed + +Inside `SeedAsync`, `context.TenantId` tells you which scope you are seeding. + +- `null` means host context +- a concrete value means tenant context + +That makes it easy to branch logic: + +```csharp +public async Task SeedAsync(DataSeedContext context) +{ + using (_currentTenant.Change(context?.TenantId)) + { + if (_currentTenant.Id == null) + { + await SeedHostAsync(); + } + else + { + await SeedTenantAsync(_currentTenant.Id.Value); + } + } +} +``` + +### Per-tenant initialization + +For onboarding, a common pattern is: + +1. Create tenant. +2. Create tenant admin user. +3. Configure tenant connection string if needed. +4. Run migration for tenant database if dedicated. +5. Seed tenant defaults. +6. Enable tenant features. + +That workflow becomes especially important in database-per-tenant setups. + + + +## Multi-Tenant Authentication and Identity + +Identity is where host and tenant boundaries become visible to users. + +### Tenant-specific users and roles + +ABP Identity supports tenant-specific users and roles. + +- Host users have `TenantId == null` +- Tenant users have a tenant `TenantId` + +This allows the same email or username patterns to exist in separate tenant scopes, depending on your identity rules and configuration. + +### Permission definitions by side + +ABP permissions can be restricted by multi-tenancy side. + +Example: + +```csharp +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.MultiTenancy; + +public class CrmPermissionDefinitionProvider : PermissionDefinitionProvider +{ + public override void Define(IPermissionDefinitionContext context) + { + var crmGroup = context.AddGroup("Crm"); + + crmGroup.AddPermission( + "Crm.Tenants.Manage", + multiTenancySide: MultiTenancySides.Host + ); + + crmGroup.AddPermission( + "Crm.Customers.Manage", + multiTenancySide: MultiTenancySides.Tenant + ); + } +} +``` + +This is a clean way to avoid accidentally exposing host admin actions to tenant users. + +### Login flow in a multi-tenant app + +A typical login flow works like this: + +1. User opens tenant URL such as `acme.mycrm.com`. +2. ABP resolves tenant `acme`. +3. Login request is processed within tenant context. +4. Identity validates the user against that tenant. +5. Claims are issued with tenant information. +6. Subsequent requests continue in the same tenant context. + +For host login: + +1. User opens host administration URL. +2. No tenant is resolved. +3. Login happens in host context. +4. Host-only permissions become available. + +### Tenant switching + +Tenant switching can mean different things: + +- a host admin acting on behalf of a tenant in backend services +- a user selecting a tenant context in a multi-organization portal +- changing browser origin or subdomain for tenant-specific UI access + +For backend logic, `CurrentTenant.Change(...)` is the mechanism. + +For frontend flows, tenant resolution strategy usually drives the user experience. + +## Database-Per-Tenant Configuration + +Database-per-tenant is where ABP's tenant abstractions become especially valuable. + +### Tenant-specific connection strings + +The Tenant Management module supports storing per-tenant connection strings. Once configured, ABP can use the tenant's own database automatically. + +Typical flow: + +- host creates tenant +- tenant record stores connection string override +- request resolves tenant +- connection string resolver uses tenant-specific value +- DbContext points to the tenant database + +### `ITenantStore` + +`ITenantStore` is the abstraction responsible for retrieving tenant configuration. + +That includes: + +- tenant id +- tenant name +- active status +- connection strings + +The default implementation may read from configuration or from the Tenant Management module database, depending on setup. + +### Example: tenant configuration in appsettings + +```json +{ + "Tenants": [ + { + "Id": "11111111-1111-1111-1111-111111111111", + "Name": "acme", + "ConnectionStrings": { + "Default": "Server=.;Database=Crm_Acme;Trusted_Connection=True;TrustServerCertificate=True" + } + }, + { + "Id": "22222222-2222-2222-2222-222222222222", + "Name": "globex", + "ConnectionStrings": { + "Default": "Server=.;Database=Crm_Globex;Trusted_Connection=True;TrustServerCertificate=True" + } + } + ] +} +``` + +### Production-ready considerations + +For production, prefer: + +- tenant metadata in a central host database +- automated tenant provisioning pipeline +- a migration runner for new and existing tenant databases +- secure connection string storage +- monitoring and health checks per tenant database + +### What changes operationally + +With one shared database, disabling the tenant filter can make host-wide queries possible. + +With separate databases, cross-tenant queries are fundamentally different. The host cannot query all tenant rows with one SQL statement because the data lives in different databases. + +That changes how you design: + +- reporting +- analytics +- support tooling +- exports +- migration scripts + +## Advanced Multi-Tenancy Scenarios + +Once the core application works, the interesting problems begin. + +### Background jobs in tenant context + +Background jobs often execute outside the original HTTP request. That means tenant context is not automatically available unless you pass and restore it. + +```csharp +public class RebuildCustomerStatsJob : AsyncBackgroundJob +{ + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _customerRepository; + + public RebuildCustomerStatsJob( + ICurrentTenant currentTenant, + IRepository customerRepository) + { + _currentTenant = currentTenant; + _customerRepository = customerRepository; + } + + public override async Task ExecuteAsync(Guid tenantId) + { + using (_currentTenant.Change(tenantId)) + { + var count = await _customerRepository.GetCountAsync(); + // Rebuild stats for this tenant + } + } +} +``` + +If you queue jobs without tenant identity, you will eventually process data in the wrong context. + +### Distributed events + +Distributed event handlers should also be tenant-aware. + +A practical pattern is to include tenant id in the event payload and restore it in the handler before performing repository operations. + +### Caching per tenant + +Cache keys must include tenant identity when the cached value is tenant-specific. + +Bad: + +- `dashboard-summary` + +Good: + +- `tenant:{tenantId}:dashboard-summary` + +This sounds obvious, but cross-tenant cache leakage is a very real failure mode. + +### Feature management + +ABP feature management is a natural fit for SaaS plans. + +Use features for things like: + +- max number of users +- advanced reporting availability +- API access +- storage limits + +A tenant on a basic plan and a tenant on an enterprise plan can run the same codebase with different capabilities. + +### Setting management + +Settings are another strong tenant-aware capability. + +Examples: + +- default currency +- email sender name +- CRM pipeline defaults +- localization preferences + +Settings should be tenant-scoped when they represent tenant configuration, not platform-wide behavior. + +### Audit logging + +Audit logs should always preserve tenant identity. That makes support and incident investigation much easier. + +When reviewing logs, you should be able to answer: + +- which tenant performed the operation +- whether it happened in host or tenant context +- which user executed it + +### Localization + +Localization becomes multi-tenant when tenants can override culture, language preferences, or content. Keep shared localization resources separate from tenant-specific content whenever possible. + +### Common pitfalls + +A few pitfalls show up repeatedly in real projects: + +- tenant resolution order produces unexpected `CurrentTenant.Id == null` +- public APIs trust `__tenant` header without gateway validation +- seed logic is not idempotent and creates duplicates +- background jobs run without restored tenant context +- raw SQL bypasses tenant filtering +- cache keys omit tenant id +- host admin screens disable filters too broadly +- database-per-tenant migrations are not automated + +## Building a Sample SaaS CRM Application + +Let's put the concepts together into a practical example. + +### Business scenario + +We are building a SaaS CRM platform with: + +- host administration +- tenant administration +- customer management +- product catalog +- order management +- subscription management + +### Host administration responsibilities + +The host side manages the platform itself. + +Typical host features: + +- create tenant +- suspend or reactivate tenant +- assign subscription plan +- configure dedicated database +- view platform metrics +- trigger tenant provisioning + +### Tenant administration responsibilities + +Each tenant manages its own organization. + +Typical tenant features: + +- invite users +- manage roles +- configure CRM settings +- manage customers and sales pipeline +- view tenant-specific reports + +### Suggested module boundaries + +A practical application split might look like this: + +- **SaaS/Host module**: tenant lifecycle, subscriptions, plans +- **Identity module**: users, roles, permissions +- **CRM module**: customers, contacts, products, orders +- **Billing module**: plan, invoice metadata, subscription status +- **Reporting module**: tenant-level analytics + +### Example tenant onboarding flow + +Suppose a new tenant signs up: `acme`. + +1. Host creates a `Tenant` record. +2. Subscription plan is assigned. +3. If enterprise tier, a dedicated database is created. +4. Migrations run for the tenant database if needed. +5. Tenant admin user is provisioned. +6. CRM defaults are seeded. +7. Features are assigned based on plan. +8. Tenant accesses `acme.mycrm.com`. + +### Example application service for tenant onboarding + +```csharp +public class TenantProvisioningAppService : ApplicationService +{ + private readonly ITenantRepository _tenantRepository; + private readonly IDataSeeder _dataSeeder; + + public TenantProvisioningAppService( + ITenantRepository tenantRepository, + IDataSeeder dataSeeder) + { + _tenantRepository = tenantRepository; + _dataSeeder = dataSeeder; + } + + public async Task ProvisionAsync(Guid tenantId) + { + var tenant = await _tenantRepository.GetAsync(tenantId); + + await _dataSeeder.SeedAsync(new DataSeedContext(tenant.Id)); + } +} +``` + +In a real solution, provisioning usually includes database creation, migration, admin user setup, and feature initialization as well. + +### How the pieces fit together + +In the CRM app: + +- tenant is resolved from subdomain +- identity authenticates users within the tenant +- entities implement `IMultiTenant` +- repositories automatically filter by tenant +- host users manage tenant lifecycle +- features and settings control plan differences +- background jobs and event handlers restore tenant context explicitly + +That is the practical ABP multi-tenancy story end to end. + +## Best Practices for ABP Multi-Tenant Applications + +Here are 15+ best practices that hold up well in production. + +1. **Decide host vs tenant ownership early.** Not every entity should implement `IMultiTenant`. +2. **Choose the database model intentionally.** Start simple, but plan the migration path. +3. **Prefer subdomain resolution for browser-based SaaS.** It is usually the cleanest user experience. +4. **Do not trust public tenant headers blindly.** Accept them only behind trusted infrastructure. +5. **Keep tenant resolution order explicit.** Unexpected resolver precedence causes hard-to-debug bugs. +6. **Use `CurrentTenant.Change(...)` for cross-tenant work.** Do not fake tenant context with ad hoc parameters. +7. **Make seeding idempotent.** Seed contributors should safely run more than once. +8. **Include tenant id in cache keys.** Always. +9. **Be cautious when disabling `IMultiTenant` filters.** Limit scope and review security impact. +10. **Define permissions with the right `MultiTenancySides`.** Host and tenant actions should be separated clearly. +11. **Automate tenant database migrations.** Manual DB-per-tenant operations do not scale. +12. **Monitor by tenant.** Errors, latency, and usage metrics should be attributable to tenant context. +13. **Design indexes around tenant access patterns.** Shared-database tables often need `(TenantId, ...)` composite indexes. +14. **Avoid cross-tenant joins in business logic.** They are usually a sign of a leaking domain boundary. +15. **Test host context explicitly.** Many bugs appear only when `CurrentTenant.Id` is null. +16. **Test tenant switching and nested scopes.** Especially in background and integration flows. +17. **Preserve tenant id in audit logs and events.** It helps debugging, support, and compliance. +18. **Plan backup and restore by tenancy model.** Shared and dedicated databases require different operational playbooks. +19. **Keep raw SQL rare and reviewed.** Repository filters do not protect careless SQL. +20. **Treat multi-tenancy as a security concern, not just an architecture pattern.** Because in practice, it is both. + +## Conclusion + +ABP Framework gives you a strong, practical multi-tenancy foundation. The important part is that its support is not isolated in one package or middleware. It is woven through the framework: request resolution, current tenant context, repositories, data filters, identity, tenant management, features, settings, and background processing. + +That matters because real SaaS applications do not fail on the happy path. They fail in the edge cases: host-side screens, cache leakage, background jobs, custom resolvers, and rushed provisioning logic. + +If you understand the host vs tenant model clearly and use ABP's abstractions as intended, you can build a multi-tenant application that stays maintainable as your product grows from a few customers to many. + +Start with a simple shared-database model if it fits. Move to dedicated databases where needed. Keep tenant context explicit. Respect the boundaries. ABP does the heavy lifting, but architecture discipline is still your job. + +## TL;DR + +- ABP multi-tenancy gives you built-in tenant resolution, `ICurrentTenant`, `IMultiTenant`, data filters, and tenant management support. +- Shared DB is the easiest starting point; DB-per-tenant and hybrid models fit stronger isolation and enterprise needs. +- Correct tenant resolution is the foundation of data isolation, authentication, and authorization. +- Use `CurrentTenant.Change(...)` for controlled cross-tenant work, especially in jobs, seeders, and host-side services. +- Production success depends on secure resolvers, tenant-aware caching, automated migrations, and strict host/tenant boundaries. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/cover.png b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/cover.png new file mode 100644 index 0000000000..def7467e73 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png new file mode 100644 index 0000000000..3940d506be Binary files /dev/null and b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png new file mode 100644 index 0000000000..9c0d224922 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png new file mode 100644 index 0000000000..b0a8cb5a96 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-09-implementing-multitenancy-in-abp-framework-a-complete/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-mode-picker.png b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-mode-picker.png new file mode 100644 index 0000000000..e4ac0af1fe Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-mode-picker.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-plan-actions.png b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-plan-actions.png new file mode 100644 index 0000000000..ef99ac7ee6 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/abp-agent-plan-actions.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/cover.png b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/cover.png new file mode 100644 index 0000000000..ef6b3dd2a6 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/post.md new file mode 100644 index 0000000000..82fed5324d --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-1-agent-plan-ask-abp-studio-ai/post.md @@ -0,0 +1,105 @@ +# Deep Dive on ABP AI Agent #1: Agent, Plan and Ask Modes + +There is a small question I like to answer before I type anything into **ABP Agent**: + +**Do I want an answer, a plan, or action?** + +That question looks simple, but it changes the whole experience. Sometimes I am only trying to understand why a module is structured a certain way. Sometimes I already know the direction, but I want the implementation path checked before touching files. And sometimes the task is clear enough that I want ABP Agent to do the work, run the checks, and iterate with me. + +That is where the three modes in ABP Studio AI become more than labels. They help me choose the right level of trust, risk, and action for the moment. + +![ABP Agent mode picker showing Agent, Plan, and Ask](abp-agent-mode-picker.png) + +## Ask Mode: When I Want To Understand + +**Ask** is the mode I reach for when I want to stay in learning mode. + +It is useful when I am reading a solution and want to ask questions like: + +* What is this module responsible for? +* Why is this permission checked here? +* How does this application service relate to the domain layer? +* What would happen if I changed this setting, dependency, or flow? +* Which ABP concept should I use for this requirement? + +The important part is that Ask mode is read-only. I can explore the codebase, ABP concepts, architecture, or possible approaches without worrying that files will be changed as a side effect of the conversation. + +That makes it a comfortable starting point. I do not need to prepare a perfect prompt. I can ask a rough question, follow up with more context, and slowly turn uncertainty into something clearer. + +For me, Ask mode is especially helpful when I join a solution after some time away. Instead of jumping between files and trying to rebuild the story manually, I can ask ABP Agent to explain the shape of the solution in the language of ABP: modules, layers, permissions, application services, entities, settings, events, and runtime pieces. + +## Plan Mode: When I Want To Think Before Changing Code + +**Plan** is the mode I use when the next step is probably implementation, but I do not want to start editing yet. + +This is the middle ground between a conversation and a code change. ABP Agent can inspect the solution in a read-only way, ask clarifying questions when the requirement is not clear enough, and produce a structured plan before any file is modified. + +That changes the feeling of working with AI. Instead of saying "go build this" and reviewing only the result, I can review the approach first: + +* Which files will likely be affected? +* Which ABP layers are involved? +* Does the implementation path match the existing solution style? +* Are there missing decisions before the work starts? +* Is this a small change, or is it actually a larger workflow? + +This is useful for changes that cross boundaries: adding a new entity, adjusting an application service, introducing a permission, changing a UI flow, or touching more than one module. Those are exactly the moments where I want a second pass before code starts moving. + +When a plan is active, ABP Studio gives me clear actions around it. I can view the plan, detach it if it is no longer the right direction, or apply it with Agent mode when I am ready to move from planning to implementation. + +![ABP Agent plan actions for viewing, detaching, or applying a plan](abp-agent-plan-actions.png) + +The small detail I like here is that the plan does not disappear into the chat history. It becomes something I can review and intentionally carry into the next step. + +## Agent Mode: When I Am Ready For Action + +**Agent** is the mode I choose when I am ready to let ABP Agent work on the solution. + +This is the action mode. ABP Agent can edit files, run commands, build projects, use ABP Studio tasks, and iterate when something fails. It is the right choice when the task is clear enough and I am comfortable letting the agent make changes that I will review afterward. + +For small trusted tasks, I may go directly to Agent mode: + +* Add a missing localization entry +* Fix a straightforward build error +* Update a simple DTO mapping +* Add a validation rule that matches an existing pattern +* Apply a plan that I have already reviewed + +For larger work, I prefer not to start here. Agent mode is powerful, and power is better when it is intentional. If I am not sure about the shape of the change, I usually start with Ask or Plan first. + +## A Practical Workflow + +The modes are most useful when I treat them as a workflow, not as three disconnected buttons. + +For larger changes, my usual flow is: + +1. **Ask** to understand the area and the existing conventions. +2. **Plan** to turn the requirement into a reviewable implementation path. +3. **Agent** to apply the plan, build, and iterate. + +For learning, I often stay entirely in Ask mode. If I am trying to understand ABP multi-tenancy behavior, module dependencies, permission definitions, or why a solution is organized a certain way, there is no need to involve file changes. + +For small tasks, I may go directly to Agent mode. The key is that I already know what I want, the risk is low, and the expected result is easy to review. + +Here is the simple rule I keep in mind: + +| Situation | Mode I Choose | Why | +| --- | --- | --- | +| I need an explanation | Ask | It keeps the conversation read-only. | +| I need a direction before implementation | Plan | It gives me a reviewable path before changes. | +| I am ready for ABP Agent to work | Agent | It can edit, build, run tasks, and iterate. | + +## Why This Matters + +AI-assisted development can feel too fast when the tool moves from idea to code before I have decided what kind of help I actually need. + +The three modes slow that moment down in a good way. They let me say: + +* "Just explain this." +* "Think through the change first." +* "Now implement it." + +That separation makes the work feel more intentional. It also makes the output easier to review, because the mode already tells me what kind of result I should expect. + +Ask gives me understanding. Plan gives me a path. Agent gives me action. + +Used together, they make ABP Studio AI feel less like a single big button and more like a development partner that can adapt to the level of confidence I have at each step. diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-catalog.png b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-catalog.png new file mode 100644 index 0000000000..8cdcc12bf0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-catalog.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-selector.png b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-selector.png new file mode 100644 index 0000000000..84c8d4161f Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-selector.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-agents.png b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-agents.png new file mode 100644 index 0000000000..5acd071643 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-agents.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-git-review.png b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-git-review.png new file mode 100644 index 0000000000..0ede751e7f Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/abp-agent-model-settings-git-review.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/cover.png b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/cover.png new file mode 100644 index 0000000000..49e2b7d868 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/post.md new file mode 100644 index 0000000000..ef54e2c01c --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-2-supported-ai-models-abp-studio-ai/post.md @@ -0,0 +1,138 @@ +# Deep Dive on ABP AI Agent #2: Supported AI Models in ABP Studio + Usage Recommendations + +There is one question I ask almost as often as "Which mode should I use?": + +**Which model should do this work?** + +At first, it is tempting to answer that question by always choosing the strongest model in the list. That feels safe. If a model is more capable, why not use it for everything? + +In real work, I do not think about it that way. + +When I use **ABP Studio AI**, the model choice is part of the workflow. Some tasks need careful reasoning. Some need speed. Some need a large context window. Some need image support because the browser or a screenshot is involved. Some are small text-processing tasks where using the most capable model would only make the work slower and more expensive. + +So I treat model selection as a practical decision, not a trophy selection. + +## What ABP Studio Supports Today + +ABP Studio gives me a curated model setup by default. It keeps the first experience simple, while still letting me choose from a broader model catalog when I want to tune the setup for a specific kind of work. + +The important word here is **focused**. + +ABP Studio does not treat every model as an equally good choice for agent work. A coding agent needs things like a useful context window, tool support, text output, and reliable behavior in repeated agent loops. Studio keeps the model experience closer to that reality. + +The built-in model set currently includes: + +| Model | How I think about it | +| --- | --- | +| Claude Sonnet 4.6 | The default main model for day-to-day Ask, Plan, and Agent work. | +| Claude Haiku 4.5 | A fast supporting model for research, browser work, and lightweight text processing. | +| Claude Opus 4.7 | A stronger option when the task needs deeper reasoning or more careful review. | +| GPT-5.5 | Another strong option for main conversations or review-style work. | +| GLM-5.1 | A text/code option for tasks that do not need image input. | + +I do not read this list as a ranking. I read it as the default toolbox. + +And it is not a closed box. If I need a different model for a specific task, I can open the Models settings, search the catalog, filter by category, and add more models to my selection. + +![ABP Studio Models settings showing the selectable model catalog](abp-agent-model-catalog.png) + +That is an important distinction. The built-in models are there so I can start with sensible defaults. They are not there to force every team, every solution, or every workflow into the same model choices. + +## The Main Model + +The main model is the one I feel most directly in the conversation. + +It is used when I ask questions, create plans, or let ABP Agent work through an implementation. This is the model behind the normal flow of the chat. + +![ABP Agent model selector showing the current conversation model](abp-agent-model-selector.png) + +For most work, I keep a balanced model as the main model. A Sonnet-style model is a good default because it is capable enough for real development tasks without making every small question feel heavy. + +This is the model I use for: + +* Understanding a module or package +* Planning a feature before editing code +* Applying a reviewed plan +* Fixing ordinary build or test failures +* Making changes where the expected result is easy to review + +When the task gets broader, I become more intentional. + +If I am asking ABP Agent to reason across several modules, plan a risky refactor, review architecture, or inspect a subtle regression, I am more willing to switch to a stronger model. The extra capability is useful when the cost of a shallow answer is high. + +For a quick localization change or a small DTO update, that same choice can be wasteful. The strongest model is not always the best model for the moment. + +## Role-Based Models + +One detail I like in ABP Studio AI is that model selection is not only one global dropdown. + +Studio separates the main conversation model from supporting model roles. That means I can keep the main model strong enough for the conversation while using lighter models for background work. + +![ABP Agent model settings for main, research, browser, and text processor models](abp-agent-model-settings-agents.png) + +The roles are easier to understand if I describe them by how they feel in daily use. + +**Main Model** is the model I am actively talking to. It carries the normal Ask, Plan, and Agent experience. + +**Research Model** is for research and ABP documentation searcher work. I usually keep this lightweight because research often involves gathering, narrowing, and summarizing context before the main model decides what to do with it. + +**Browser Model** is used by the browser subagent in Agent mode. This role should stay fast and practical. When browser screenshots are involved, I choose a model that supports image input. A text-only model may be fine for code, but it is not the right fit when the work depends on seeing the UI. + +**Text Processor Model** is for smaller language tasks such as summarizing errors, generating commit messages, or consolidating learned lessons. This is exactly where I do not want to spend the most capable model every time. + +The Git Review model is separate too. + +![ABP Agent model settings for Git Review model selection](abp-agent-model-settings-git-review.png) + +For AI Review, I like the "Ask me every time" behavior. Some reviews are routine. Some reviews deserve a stronger model because the change is large, security-sensitive, or touches architecture. Asking each time keeps that decision close to the actual change. + +If a team wants consistent review behavior, a fixed review model also makes sense. The key is that Git Review does not have to silently follow the same model I use for ordinary chat. + +## How I Choose In Practice + +For quick questions, I use the default main model. + +If I am asking "Where is this permission defined?" or "Why does this module reference that package?", I do not need to overthink the model. I want a clear answer and maybe a few source references. + +For planning larger work, I use a stronger main model when the decision matters. + +Plan mode is where the model can save me from an expensive wrong turn. If the change crosses layers, modules, permissions, UI, or database behavior, I prefer a model that can hold more context and reason carefully. I still narrow the scope where possible, because a focused prompt usually beats a huge unfocused one. + +For implementation, I care about reliability more than raw size. + +Agent mode is not only about generating code. It is about reading the solution, editing files, running checks, seeing failures, and trying again. A good main model should follow instructions consistently and use tools well. For supporting roles, I usually keep the lighter defaults. + +For UI and browser tasks, I check image support. + +If the task involves screenshots, browser interaction, visual verification, or UI state, the browser model needs to be able to understand images. This is one reason I do not treat every text/code model as interchangeable. + +For reviews, I choose based on risk. + +A small formatting or localization change does not need the same review setup as a large change in authorization, multi-tenancy, persistence, or distributed behavior. For deeper reviews, I am willing to use a stronger model because the goal is not speed. The goal is to catch what I missed. + +For cost and latency, I avoid using the strongest model everywhere. + +This is not only about credits. It is also about pace. If every background task uses a heavy model, the development loop feels slower. Keeping lightweight models for lightweight jobs makes ABP Studio AI feel more responsive. + +## A Simple Rule + +The model name matters less than the job. + +When I choose a model in ABP Studio AI, I usually ask: + +| Situation | Model choice I prefer | +| --- | --- | +| Normal Ask, Plan, or Agent work | Balanced main model | +| Broad planning or risky implementation | Stronger main model | +| Research and documentation lookup | Lightweight supporting model | +| Browser tasks with screenshots | Vision-capable browser model | +| Error summaries and commit messages | Lightweight text processor model | +| Important AI Review | Stronger model or ask each time | + +That keeps the experience practical. + +Modes decide how much action I want: Ask, Plan, or Agent. + +Models decide which brain should handle the work. + +When those two choices are made intentionally, ABP Studio AI feels less like one generic AI button and more like a set of tools I can tune for the task in front of me. diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/POST.md b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/POST.md new file mode 100644 index 0000000000..1440f5061d --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/POST.md @@ -0,0 +1,143 @@ +# Deep Dive on ABP AI Agent #3: Rules, Skills and Lessons + +Every new chat starts from zero. + +The model can write the API, the permissions, and the UI. What disappears between sessions is everything specific to *this* solution: your naming rules, your feature checklist, the demo-data fix from yesterday. You end up re-teaching before you start building. + +That is not an intelligence problem. The model is already capable enough. What does not carry over is the setup around it. + +## Agent, Model, and Harness + +Three terms get mixed up constantly. Keeping them separate makes everything else in this article click. + +**The model** is the LLM: the weights, the training data, the reasoning engine. You do not get to change it. You pick which one to use, and that is about it. + +**The harness** is everything wrapped around the model so it can actually finish work in *your* environment: the system prompt, the tools it can call, the files it may read, the checks that run on its output, the limits on what it may touch, and the instructions injected into every session. A raw model is not an agent. An agent is the model plus the harness. If you are not the model, you are building the harness. + +**The agent** is the whole system: model plus harness, running in a loop until the task is done. The behaviour you experience is dominated by the harness, not just the model. [Addy Osmani](https://addyosmani.com/blog/agent-harness-engineering/) puts the same idea plainly: *a decent model with a great harness beats a great model with a bad harness.* The gap between what today's models can do and what you actually see them do in your codebase is mostly a harness gap. + +The habit that makes a harness good is simple: treat every mistake as a reason to update the setup, not just to fix the output in chat. Each time the agent gets something wrong, change what it always knows, can look up, or carries forward so it cannot repeat the same error. Do that consistently and the agent stops repeating mistakes and starts working the way your team works. + +In **ABP Studio AI Coding Agent**, the harness includes three pieces that hold what the agent knows about your solution: **Rules**, **Skills**, and **Lessons**. They live under **Settings > Rules & Skills**. + +![The Rules & Skills settings page in ABP Studio](rules-and-skills-settings.png) + +## Rules: The House Rules On The Wall + +A **Rule** is a standing instruction that the harness injects into the agent's context on every turn of every session. In harness-engineering terms, it is always-on context injection: the same role as an `AGENTS.md` or `CLAUDE.md` at the root of a repo, except scoped to your solution or your profile inside ABP Studio. + +Think of the house rules pinned to a kitchen wall: how we do things here, every shift, no exceptions. Unlike a glance at a poster, though, a Rule is not ambient background. It is read on every turn, so it costs context budget every time the agent acts. + +ABP Agent already follows the framework's own conventions by default. It goes through the data layer instead of hitting the database directly, uses the built-in permission system instead of hand-rolled checks, and reads user-facing text from the translation files instead of hardcoding it. You do not write those down. + +Rules are where *your* conventions go. The ones the framework cannot guess because they belong to your solution and your team. The good part is that these read like plain conventions any developer would recognize, not framework trivia: + +* New code goes in the same place, with the same naming, as the code already around it. +* Every endpoint that changes data checks permissions before it does anything. +* Money is stored in minor units (cents), never as a floating-point number. +* API errors use our standard response shape, never ad-hoc JSON. + +With those on the wall, I stop repeating them. Instead of writing this: + +```text +Add a Category screen. Use our data layer, do not query the database +directly. Check permissions on every write. Keep money in cents. Put the +files where the other features live. +``` + +I can write this: + +```text +Add a Category screen. +``` + +The conventions are not renegotiated, one prompt at a time, in every session. The agent applies them the way a teammate who has read the contributor guide would. + +This matters more than it looks. A model with no standing rules does not stay neutral. It falls back on the defaults from its training data. Ask a generic model for "an endpoint that returns categories" and you often get one with the database query written straight into the handler, because that is the most common shape on the public internet. Rules steer the agent's output back toward *your* codebase. + +One caution: treat the rule list like a pilot's checklist, not a style guide. A Rule is injected on every turn, so keep the list short. Every line should earn its place, ideally traceable to a real failure or a hard constraint you cannot ignore. If a line does not change what the agent produces, it is noise, and it dilutes the rules that actually matter. + +You also choose where a rule is saved. Global rules sit under your user profile and apply to every solution on your machine. Solution rules apply only to the current solution, and only while it is open. + +## Skills: Recipes You Pull Off The Shelf + +A **Skill** is the same kind of note as a Rule, but loaded only when the task calls for it. The harness keeps a short description of each skill in context at all times; the agent reads the full text only when that description matches what you asked it to do. Harness engineers call this **progressive disclosure**: reveal instructions and detail only when they are needed, instead of stuffing everything into the opening prompt. + +A recipe is not pinned to the wall. It sits in a drawer, and the cook pulls it out only when making that dish. That is a Skill. + +In ABP Studio, a rule and a skill are the same kind of note with one switch between them. + +![Creating a rule or a skill: the Always Apply toggle decides which one it is](create-rule-skill-dialog.png) + +You write a name (which becomes the file name) and some content, then set **Always Apply**. Turn it on and the note is a Rule, always in context. Turn it off and the note is a Skill, fetched on demand. So if a skill turns out to be something the agent should never skip, you flip one switch and it becomes a rule. + +My favorite example of a skill is a "build a feature end to end" checklist: the ordered steps your team follows to ship one complete feature. The data model comes first, then the data access layer, then the application service, then the API, then the translations, then the UI, and finally the demo data. Write it once, and the agent follows it whenever it builds a feature, instead of inventing its own order each time. + +Writing a skill is like writing an onboarding note for a new teammate. You are not making them smarter. You are saving them the week it would take to work out how your team does this one thing. + +A skill can be long without slowing anything down. The agent sees only the short description of each skill at all times, and reads the full text only when the description matches the task. The detail stays in the drawer until it is needed. + +Skills are also portable. A skill is plain Markdown, so you can import one written elsewhere instead of retyping it. + +![Importing skills from .md files into a solution or your global profile](import-rules.png) + +You point the importer at a file or a folder of `.md` files and choose whether they go into the current solution or your global profile. A procedure one person worked out can travel to the rest of the team, or to your next solution. + +A simple test for which one to reach for: + +* If it should hold no matter what you are doing, it is a **Rule**. ("Always return errors in our standard shape.") +* If it is a procedure you follow only for a certain kind of task, it is a **Skill**. ("Here is our checklist for adding a feature end to end.") + +## Lessons: What The Agent Learns On Its Own + +Rules and Skills are written by a person. **Lessons** are written by the agent. + +Think of a shift handoff log in the kitchen: after something goes wrong, someone writes down what happened and what to do differently next time. The next cook reads it before repeating the same work. In ABP Studio, you correct the mistake; the agent records the verified fix so future sessions do not trip over it again. That entry is a Lesson. + +When the agent gets something wrong and is corrected, by you, by a failing build, or by the official ABP documentation, it can record the correction as a short, verified note. The harness carries that note into later turns and later sessions as high-priority context. That is the `ai-learned-lessons` entry you saw in the settings list, growing as the agent works in your solution. + +The idea behind it is simple: when the agent gets something wrong, fix it once so it never gets it wrong the same way again. You already do a version of this when you edit a notes file by hand. Lessons do the recording for you, at the moment of the correction, when the reason is still fresh and you would otherwise forget to write it down. + +Here is the shape of a real one. I ask the agent to add a `Category` screen. It builds the data model, the API, and the UI, but forgets to grant the new permission in the demo data. The screen compiles, the API responds, everything looks finished, and the only symptom is that real users get "access denied." I correct it, the agent fixes the demo data, and it records a note: in this solution, a new permission is not done until it is granted in the demo data. Next time, it remembers. + +A Lesson is not a general fact about ABP. The documentation already covers that, and the agent reads it. A Lesson is a solution-specific, verified correction: the kind of knowledge a team usually keeps in people's heads and loses when they move on. Here it is written down the moment it is learned, instead of weeks later, if ever. + +## When To Use Which? + +Once the three are clear, they sort themselves out: + +| Mechanism | Who writes it | When it loads | Answers | +| --- | --- | --- | --- | +| **Rules** | You | Always, every turn | "What must always be true here?" | +| **Skills** | You | When relevant to the task | "How do we do this kind of task?" | +| **Lessons** | The agent | In later sessions, as high-priority context | "What did we get wrong before in this solution?" | + +They feed into each other. A Lesson that keeps coming back is a sign it should be promoted. If the agent records the same correction again and again, it is no longer a one-off note. It belongs in a Rule, if it is an always-true convention, or in a Skill, if it is a procedure. Knowledge tends to move from Lessons toward Rules and Skills: the agent finds the pattern by tripping over it, and you write it down properly once it has earned the spot. + +Keep an eye on the budget, because all three share the same limited space, the context window. Rules cost the most, since they are always present, so an overstuffed rule list weighs down every turn and crowds out the rules that matter. Skills are cheaper, paid for only when used, which is a good reason to move anything situational out of Rules and into a Skill. Lessons add up too, so clear out the stale ones now and then. The point is not to write down everything you know. It is to write down the few things that change what the agent produces, and put each one where it loads at the right time. + +## Why This Is Different From Generic Coding Agents? + +To be fair, the ideas themselves are not unique to ABP. Tools like Cursor, Claude Code, Codex, and Windsurf are strong general-purpose coding tools, and they already have always-on instruction files and on-demand skill files. If all you compare is whether a tool can hold rules and skills, there is no real difference, and I would not pretend otherwise. + +The difference is in the two places where a generic tool has to guess. + +The first is memory of its own mistakes. In most tools, remembering a past mistake means you stop and edit a memory file by hand. Lessons remove that step. The agent records the correction itself, the moment it happens, so the knowledge survives without you having to maintain it. + +The second, and the one that matters most, is what all of this sits on. A generic instruction file can say "use the data layer," but the tool still has to read your files and guess what the data layer is and where it lives. ABP Agent does not guess. It starts every session with an ABP-aware map of your solution, and when it is unsure how something should be done, it checks the official ABP documentation instead of the average of the public internet. So the rules and skills you write are not hints dropped into a tool that barely understands the project. They are backed by one that already knows the framework underneath them. + +There is also a smaller convenience worth a line: a rule and a skill are the same note with one switch between them, scoped to the solution or your profile from the same place, instead of two separate file formats to keep track of. + +## Conclusion + +It is best to see the three as one system, not three switches. + +* **Rules** hold the conventions you refuse to repeat. +* **Skills** hold the procedures you want to reuse. +* **Lessons** hold the corrections the agent learns as it works. + +Together they answer the blank-memory problem. They do not give the model new weights. They inject, at the start of each session, the smallest useful set of instructions so the agent can work as if it has been here before. + +A generic agent with no harness tuning stays roughly as capable on its thousandth task in your codebase as on its first. With Rules, Skills, and Lessons, ABP Agent accumulates solution-specific knowledge over time, especially through Lessons and the promotions you make from them. + +That is the real value: **not just an AI that writes code, but one that learns how your solution is built and keeps that knowledge from one session to the next.** diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/cover-image.png b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/cover-image.png new file mode 100644 index 0000000000..f88dd33b79 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/create-rule-skill-dialog.png b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/create-rule-skill-dialog.png new file mode 100644 index 0000000000..0d874ae897 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/create-rule-skill-dialog.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/import-rules.png b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/import-rules.png new file mode 100644 index 0000000000..6e5f10dab0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/import-rules.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/rules-and-skills-settings.png b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/rules-and-skills-settings.png new file mode 100644 index 0000000000..d66fdda94b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-3-rules-skills-lessons/rules-and-skills-settings.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/POST.md b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/POST.md new file mode 100644 index 0000000000..1f736795d7 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/POST.md @@ -0,0 +1,275 @@ +# Deep Dive on ABP AI Agent #4: Integrated ABP Studio Tools + +When I use an AI coding agent, there is a point where plain code awareness is not enough. + +The agent may understand the project structure. It may read the failing method. It may even guess the most likely cause of an error. But in a real development session, I usually need more than a guess. I need the latest exception, the request that caused it, the logs around it, the application that is running, the containers it depends on, the tasks I can execute, and the build result after a fix. + +That is where **ABP AI Coding Agent** becomes different from a generic coding assistant. It is not only connected to files. It is connected to **ABP Studio**. + +ABP Studio already knows the solution, run profiles, runnable applications, containers, tasks, monitoring data, and build actions. ABP AI Coding Agent can use that context through integrated tools when those tools are enabled. So instead of copying exception details, terminal output, container names, or build logs into the chat manually, I can let the agent work with the same ABP Studio environment I am using. + +![ABP AI Coding Agent with tools](tools-with-agent.png) + +> **Note:** ABP AI Coding Agent is available directly to ABP license holders. License holders have predefined credits so they can try it without setting up a separate AI workflow first. When those credits run out, they can buy more and continue using the same integrated experience. + +## Why Integrated Tools Matter + +Most coding agents start from the same place: **source code**. + +That is useful, but ABP development is not only source code. A running ABP solution has applications, modules, services, containers, database connections, migrations, logs, exceptions, requests, tasks, and build steps. When these pieces are outside the agent's reach, the developer becomes the bridge: + +* Copy this exception. +* Paste that log. +* Run this build. +* Check that container. +* Explain which application is currently running. +* Tell the agent what failed after the last change. + +Integrated ABP Studio tools reduce that manual work. They let the agent ask ABP Studio for runtime and solution information directly, within the permission boundary I choose. + +The important detail is that this access is still explicit. Tools can be enabled or disabled. If I do not want the agent to use a tool, I can keep it disabled. If I want the agent to troubleshoot with runtime information, I can enable the relevant tools and ask for a more complete investigation. + +That gives me a practical balance: the productivity of automation, with a visible boundary around what the agent can use. + +## Tool Access: What The Agent Is Allowed To Use + +The tools view is the control point. + +This is where ABP Studio shows the integrated tools that can be used by ABP AI Coding Agent. Some tools are for reading runtime information. Some are for interacting with applications. Some are for containers, tasks, or build actions. + +![ABP AI Coding Agent tools overview](abp-agent-tools-overview.png) + +> The names are intentionally direct. A monitoring tool that gets exceptions is about exceptions. A build tool is about build validation. A task tool is about ABP Studio tasks. That makes the tool list easy to understand even before using it in a real prompt. + +For me, the key idea is not the individual button names. It is the permission model: + +* If a tool is disabled, the agent should not act as if it has that information. +* If a tool is enabled, the agent can use it as part of the current session. +* If a task needs runtime evidence, I can enable only the tools needed for that task. + +This makes ABP AI Coding Agent feel more intentional than a black box. I can decide when it should stay in code reasoning and when it should use ABP Studio's runtime view of the solution. + +## Monitoring Tools + +Monitoring tools are the first group I reach for when something fails at runtime. + +![ABP AI Coding Agent monitoring tools](abp-agent-monitoring-tools.png) + +These tools help the agent inspect what happened while the application was running. In practice, this means information like **exceptions**, **logs**, **events**, and **request details**. + +This is a big difference from a generic coding agent. Without monitoring tools, the agent can read the code and make a reasonable guess. With monitoring tools, it can work from the actual failure. + +For example, if a page throws an exception, I can ask: + +```text +Get the latest exception from ABP Studio Monitoring and explain what failed. +``` + +If the exception tool is enabled, the agent can use that runtime signal. It can look at the exception message, stack trace, request context, and related code. Then it can connect the runtime failure to the implementation. + +That changes the debugging loop: + +1. Trigger the problem. +2. Ask the agent to inspect the runtime evidence. +3. Let it find the related code. +4. Apply the fix. +5. Validate again. + +The developer no longer needs to manually copy the exception from one place and paste it into another. ABP Studio becomes part of the agent's working context and give it ***harness***! + +## Application Tools + +Application tools connect the agent to the applications defined in the active ABP Studio run profile. + +![ABP AI Coding Agent application tools](abp-agent-application-tools.png) + +This matters because ABP solutions often contain more than one runnable application. A layered solution may have a web application, an API host, a DbMigrator, and other executable projects. A microservice solution may have several services with different roles. + +ABP Studio already understands these applications through the solution and run profile. When application tools are available, the agent does not need to rediscover everything from file names or ask me which project is running. It can use ABP Studio's view of the solution. + +That is useful for prompts like: + +```text +Check which application is running and use the relevant runtime information to investigate the problem. +``` + +The benefit is not only convenience. It also reduces mistakes. The agent can reason from the same run profile that I use in ABP Studio, instead of guessing from the repository structure alone. + +## Container Tools + +Many ABP applications depend on infrastructure services while running locally. + +![ABP AI Coding Agent container tools](abp-agent-container-tools.png) + +A solution may need SQL Server, PostgreSQL, Redis, RabbitMQ, OpenIddict-related services, or other containers depending on the template and modules. When something fails, the cause is not always in application code. Sometimes a required container is not running. Sometimes the application cannot reach a dependency. Sometimes the runtime error is only a symptom of an infrastructure problem. + +Container tools give the agent a way to include that part of the environment in the investigation. + +Instead of asking the agent to guess why a database connection fails, I can let it check the container context that ABP Studio already has. The agent can then distinguish between: + +* a code problem, +* a configuration problem, +* a missing or stopped container, +* or a dependency that is running but unhealthy. + +This is one of the places where ABP Studio integration is especially valuable. General coding agents can help with Docker files or connection strings, but they usually do not know the current ABP Studio container state unless I copy it into the prompt. ABP AI Coding Agent can work closer to the actual local development environment. + +## Task Tools + +ABP Studio tasks are another part of the development workflow that should not live outside the agent. + +![ABP AI Coding Agent task tools](abp-agent-task-tools.png) + +Tasks can represent common solution actions. They may run commands, scripts, or workflow steps that are already configured for the solution. If the team uses ABP Studio tasks to standardize local development, the agent should be able to understand and use that same layer. + +That means I can ask for a workflow instead of a raw command: + +```text +Use the available ABP Studio tasks to validate this change. +``` + +The agent can work with the task names and outputs rather than asking me to remember the exact command. This is helpful in larger solutions where the correct validation step is not obvious from a single project file. + +It also keeps the agent aligned with the team's development path. If ABP Studio has the task, the agent can follow that path instead of inventing a one-off command. + +## Build Tools + +Build tools close the loop after an implementation or fix. + +![ABP AI Coding Agent build tools](abp-agent-build-tools.png) + +The agent should not only change files. It should also help verify that the change still builds. + +In a generic coding agent flow, validation often depends on shell access and manually chosen commands. That can work, but it leaves more room for guessing. Which project should be built? Which solution file should be used? Is there an ABP Studio-specific build action already configured? + +With ABP Studio build tools, the agent can use the build context exposed by the platform. That makes prompts like this more natural: + +```text +Apply the fix and run the available build validation. +``` + +For small changes, this may simply confirm that the project compiles. For larger changes, it can become part of a broader loop with tasks, application checks, and monitoring tools. + +The important part is that the agent can move from implementation to validation without requiring me to manually transfer output between tools. + +## A Practical Tool Access Walkthrough + +Let me make this more concrete with a small debugging scenario. + +In the sample application, I deliberately added a runtime exception to the `UpdateAsync` method of `BookAppService.cs`: + +```csharp +throw new Exception("Sample exception for demonstrating integrated tools!"); +``` + +Then I started the application from ABP Studio and triggered the related request from the browser. The only purpose of this setup is to create a real runtime failure that ABP Studio Monitoring can capture. + +The interesting part is not the exception itself. The interesting part is how the agent behaves when the monitoring tool is disabled, and how that changes when the tool is enabled. + +### First, Without The Exception Tool + +For the first run, I kept the monitoring tool that retrieves exceptions disabled. + +![ABP AI Coding Agent with exception monitoring tool disabled](disabled-monitoring-tools.png) + +Then I asked: + +```text +Can you get the latest exception from ABP Studio Monitoring and explain what failed? +``` + +At this point, the agent did not have direct access to the exception tool. So it tried to reason around the problem in other ways. It looked for available information, tried to inspect logs from files, and checked the codebase to understand what might be happening. + +![ABP AI Coding Agent result without exception tool access](without-tool-result.png) + +That is still useful in some cases, but it is not the best debugging flow. The agent is spending time and context trying to reconstruct a runtime failure indirectly. It may eventually find the suspicious code, but it is working from weaker evidence. + +This is exactly why tool access matters. Without the monitoring tool, the agent can reason from files. With the monitoring tool, it can inspect the actual runtime failure. + +### Then, With The Exception Tool Enabled + +For the second run, I enabled the monitoring tool that can retrieve exceptions, such as `get_exceptions`. + +![ABP AI Coding Agent with exception monitoring tool enabled](enabled-monitoring-tools.png) + +Then I asked: + +```text +Now the get_exceptions tool is enabled. Please get the latest exception from ABP Studio Monitoring, identify the failing code path, and suggest the smallest fix. +``` + +This time, the behavior was very different. The agent directly used the integrated exception tool, retrieved the exception details from ABP Studio Monitoring, and connected the runtime error to the failing code path. + +![ABP AI Coding Agent using get_exceptions result](with-tool-result-1.png) + +It also used the enabled logging tool to verify the surrounding context instead of guessing from the source code alone. + +![ABP AI Coding Agent correlating exception with logs](with-tool-result-2.png) + +That is the workflow I want from an integrated coding agent. It does not only say "this code looks suspicious." It checks the exception, follows the evidence, finds the source of the problem, and proposes the smallest fix. + +It is also faster and more efficient. The agent does not need to spend as many tokens searching for indirect clues because ABP Studio can provide the runtime signal directly. + +### Adding Logs And Requests + +After that, I asked the agent to use the available monitoring tools together: + +```text +Use the available monitoring tools to check the related logs and recent requests for this failure. Tell me whether they confirm the same root cause. +``` + +At this point, the agent used the enabled tools for exceptions, requests, and logs. + +![ABP AI Coding Agent checking monitoring tools together](step-3-1.png) + +![ABP AI Coding Agent reviewing exception, request, and log details](step-3-2.png) + +This is where the ABP Studio integration becomes even more valuable. A runtime problem is rarely just one line of code. There is usually a request, a log entry, an exception, and a running application context around it. + +When these tools are available together, the agent can correlate them. It can say, "this request caused this exception, the logs confirm it, and this code path is responsible." + +That is much better than asking the developer to copy each piece manually into the chat. + +### Validating The Fix + +Finally, I asked the agent to validate the result: + +```text +Run the available build or application validation tools and confirm that the problem is fixed. +``` + +The agent used ABP Studio tools to stop the application, build it, and run it again. + +![ABP AI Coding Agent validating the fix with ABP Studio tools](validate-the-fix.png) + +This completes the loop. The agent did not only identify the problem. It used the integrated tools to move through the full flow: + +1. Read the runtime exception. +2. Correlate it with logs and requests. +3. Find the failing code path. +4. Apply or suggest the focused fix. +5. Validate the application again. + +That is the difference I want to highlight in this article. ABP AI Coding Agent is not only a model that can edit files. When ABP Studio tools are enabled, it can participate in the same development workflow I use: observing the running application, understanding the failure, fixing the code, and validating the result. + +## Why This Is Different From Generic Coding Agents + +Tools like Cursor, Claude Code, and Codex are powerful. They can read code, edit files, run commands, and help with many software projects. + +**ABP AI Coding Agent has a different advantage: it is built for the ABP development experience.** + +It is aware of ABP concepts, ABP solution structure, ABP Studio run profiles, application metadata, containers, monitoring, tasks, and build actions. It is also backed by the ABP Platform: ABP Framework, ABP Commercial, ABP Suite, ABP Studio, and the workflows that connect them. + +That platform context matters. When I am building an ABP solution, I do not only want a model that can write C# or TypeScript. I want an assistant that understands the way ABP applications are structured and the way ABP Studio runs them. + +**That makes the starting point simple:** _open the ABP solution, use ABP Studio, [choose the right mode](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-1-agent-plan-and-ask-modes-62wteg9t), enable the tools needed for the task, and work with the agent inside the platform._ + +## Conclusion + +Integrated tools make ABP AI Coding Agent more than a chat window next to the code. They give it a controlled way to work with the same ABP Studio context I already use: **applications**, **containers**, **monitoring**, **tasks**, and **build actions**. + +-> **That helps the agent move from "I think this might be the problem" to "I checked the runtime evidence, found the related code, applied the fix, and validated it."** + +That is the real value of this part of ABP Studio AI. It brings the coding agent closer to the full development experience, from understanding the solution to running it, observing it, fixing it, and checking the result. + +As ABP Studio evolves, more tools can be added to this workflow. That means the agent can become more useful over time without changing the basic idea: _ABP AI Coding Agent works best when it is not isolated from the platform, but integrated into it._ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-application-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-application-tools.png new file mode 100644 index 0000000000..34845eac5b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-application-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-build-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-build-tools.png new file mode 100644 index 0000000000..d1d4559ca1 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-build-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-container-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-container-tools.png new file mode 100644 index 0000000000..ac9c9f9e5a Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-container-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-monitoring-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-monitoring-tools.png new file mode 100644 index 0000000000..73fc3d5355 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-monitoring-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-task-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-task-tools.png new file mode 100644 index 0000000000..89239146fc Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-task-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-tools-overview.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-tools-overview.png new file mode 100644 index 0000000000..5d666173ba Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/abp-agent-tools-overview.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/cover-image.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/cover-image.png new file mode 100644 index 0000000000..7cf80f3905 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/disabled-monitoring-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/disabled-monitoring-tools.png new file mode 100644 index 0000000000..cc84d05d25 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/disabled-monitoring-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/enabled-monitoring-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/enabled-monitoring-tools.png new file mode 100644 index 0000000000..d2dd9df33b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/enabled-monitoring-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-1.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-1.png new file mode 100644 index 0000000000..f6eb90c3ec Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-1.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-2.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-2.png new file mode 100644 index 0000000000..d457d9f220 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/step-3-2.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/tools-with-agent.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/tools-with-agent.png new file mode 100644 index 0000000000..7b0f88ac73 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/tools-with-agent.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/validate-the-fix.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/validate-the-fix.png new file mode 100644 index 0000000000..99a46deb60 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/validate-the-fix.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-1.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-1.png new file mode 100644 index 0000000000..ca86fcb3d4 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-1.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-2.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-2.png new file mode 100644 index 0000000000..843b4ac856 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/with-tool-result-2.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/without-tool-result.png b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/without-tool-result.png new file mode 100644 index 0000000000..b381c31fba Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-4-tools/without-tool-result.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-mcp-server.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-mcp-server.png new file mode 100644 index 0000000000..766e0f55a2 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-mcp-server.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-seo-analyzer-mcp-server.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-seo-analyzer-mcp-server.png new file mode 100644 index 0000000000..1e98f568da Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/add-seo-analyzer-mcp-server.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/cover-image.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/cover-image.png new file mode 100644 index 0000000000..f4baa39fd2 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/mcp-servers-empty.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/mcp-servers-empty.png new file mode 100644 index 0000000000..ea4785c009 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/mcp-servers-empty.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/post.md new file mode 100644 index 0000000000..a28a96edcd --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/post.md @@ -0,0 +1,315 @@ +# Deep Dive on ABP AI Agent #5: MCP (Model Context Protocol) + +When I use an AI coding agent inside ABP Studio, most of the work starts inside the solution. + +The agent can read the code. It can edit files. It can use the ABP Studio tools I enabled. It can build the solution, run tasks, start applications, inspect containers, and check monitoring data when something fails. [In the previous article of this](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-4-integrated-abp-studio-tools-be2xa2om) series, we focused on exactly that: the integrated tools that connect the agent to the ABP Studio development environment. + +But there is another point where plain solution awareness is not enough. + +Sometimes the information I need is not in the repository. It is not in the running application. It is not in the build output, container state, or ABP Studio monitoring screen. + +It may be in a live Prometheus workspace. It may be in Google Search Console. It may be in an SEO analyzer, a documentation system, a database, a customer support tool, or another service that has nothing to do with the ABP solution itself. + +That is where **MCP** becomes useful. + +MCP, short for **Model Context Protocol**, gives ABP AI Coding Agent a standard way to reach external tools and data sources. It does not replace the built-in ABP Studio tools. It extends the agent beyond the solution boundary when the task depends on something outside that boundary. + +> **Note:** ABP AI Coding Agent is available directly to ABP license holders. License holders have predefined credits so they can try the integrated experience without setting up a separate AI workflow first. MCP is one of the ways this experience can be extended when the agent needs access to systems outside ABP Studio. + +## Why MCP Matters + +Most coding agents start from the same place: **the files they can see**. + +That is a good start, but real development work often needs context from somewhere else: production metrics, SEO data, analytics, search performance, customer records, internal APIs, or product knowledge. + +If the agent cannot reach those systems, the developer becomes the bridge: + +- Copy this metric result. +- Paste that SEO report. +- Export this analytics data. +- Summarize this dashboard. +- Explain which production signal matters. +- Tell the agent what the external system says. + +That works, but it is not the best workflow. The more information I manually copy into the chat, the easier it is to miss something, simplify too much, or give the agent an outdated snapshot. + +MCP reduces that manual work. It lets the agent ask an external system for context directly, within the permission boundary I configure. + +The important detail is that MCP is not "the agent can do anything now." It is a controlled extension point. A server is connected. Its tools are listed. Individual tools can be enabled or disabled. The agent can only use what is available in the current session. + +That gives me a practical balance: the ABP-aware development experience stays in ABP Studio, and external context can be added only when the task needs it. + +## What MCP Actually Is + +MCP is an open standard for connecting external tools and data to AI agents. + +Instead of every tool inventing its own integration for every agent, MCP defines a common shape. A program that exposes tools or resources through this standard is called an **MCP server**. An agent that understands MCP can connect to that server and use what it provides. + +For me, the easiest way to think about it is simple: + +- ABP Studio tools connect the agent to the ABP development environment. +- MCP servers connect the agent to systems outside that environment. + +The server might expose a tool for querying Prometheus, checking Search Console data, running an SEO audit, inspecting a database, or calling an internal API. The exact capability depends on the server. + +MCP is not a prompt, a rule, a skill, or a lesson. Those shape what the agent knows or how it behaves. MCP changes what the agent can reach. + +That makes MCP more like equipment than instruction. It gives the agent access to a tool or data source that was previously outside its working area. + +There are already many community and vendor MCP servers for different systems. A useful place to discover examples is the [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers) repository, which collects MCP servers across many categories. + +For concrete examples, there are MCP servers for [Prometheus metrics](https://github.com/pab1it0/prometheus-mcp-server), [AWS Managed Prometheus](https://github.com/awslabs/mcp/tree/main/src/prometheus-mcp-server), and [SEO analysis](https://github.com/g-battaglia/mcp-seo). The exact server you choose depends on your environment, but the pattern is the same: expose a focused external capability to the agent through MCP. + +## When You Actually Need It + +Most of the time, I would not start with MCP. + +For normal ABP development, the built-in tools are usually the right first layer. The agent can already work with the solution, run profiles, applications, containers, tasks, builds, monitoring data, and documentation. If the task is completely inside the ABP solution, MCP may not add anything. + +MCP becomes useful when the task crosses the solution boundary. + +For example, I may ask: + +```text +Run an SEO audit for https://abp.io, summarize the weak areas, and suggest which content or technical improvements would matter most. +``` + +Without MCP, I would need to open a separate SEO tool, run the audit, copy the results, and paste them into the chat. With the right MCP server enabled, the agent can call the SEO tool directly and reason from the structured result. + +Or I may ask: + +```text +Connect to our Prometheus MCP server, check the error rate and p95 latency for the AuthServer over the last 30 minutes, and tell me whether the last deployment changed anything. +``` + +That is a very different kind of task. The answer is not in the source code alone. It lives in a monitoring system. Prometheus MCP servers exist for this kind of workflow; for example, some expose tools for instant PromQL queries, range queries, metric discovery, and target inspection. + +Another realistic prompt could be: + +```text +Use Search Console data to find pages that lost traffic this month, then inspect the related docs pages and suggest focused improvements. +``` + +Again, the important part is not that the agent magically knows everything. The important part is that I can connect a specific external system, expose a specific set of tools, and let the agent use them when they are relevant. + +If the work starts outside the codebase but ends inside the codebase, MCP can help connect those two parts of the workflow. + +## Setting Up An MCP Server + +MCP servers are configured under **Settings > MCP Servers**. + +When no server is connected, the page is intentionally simple. It is an empty list waiting for the first server. That is important because MCP should be explicit. If I have not connected a server, the agent should not behave as if it has access to that external system. + +![The MCP Servers page in ABP Studio, before any server is added](mcp-servers-empty.png) + +When I add a server, ABP Studio asks how it should connect. + +There are two main connection types: + +- **Stdio:** ABP Studio runs the MCP server as a local process. I provide the command, arguments, and environment variables the server needs. +- **HTTP:** ABP Studio connects to an MCP server over the network. I provide the URL and any required headers. + +![Adding an MCP server: pick stdio or HTTP, then provide the command, arguments, and environment variables](add-mcp-server.png) + +This is useful because different MCP servers are packaged in different ways. Some are local command-line programs. Some are hosted services. Some need environment variables for tokens or configuration. + +In the example below, I am adding an HTTP MCP server named **SEO Analyzer**. It exposes a small set of SEO-related tools through a remote MCP endpoint. + +![Adding the SEO Analyzer MCP server over HTTP](add-seo-analyzer-mcp-server.png) + +ABP Studio does not require every server to be created manually from scratch. If I already use MCP elsewhere, I can import existing configuration from tools like Cursor, Claude, VS Code, Windsurf, or a plain MCP server JSON file. It can also export configuration in the standard `mcpServers` JSON shape. + +That matters because MCP is an open ecosystem. The same server definition can often move between tools. ABP Studio becomes another place where I can use that server, but now in the context of an ABP-aware agent. + +## What A Connected Server Shows + +After a server is connected, ABP Studio shows the important parts of the connection. + +I can see whether the server is connected, how many tools it exposes, which tools are available, and whether it provides resources. I can also inspect resources directly from the settings page. + +![A connected SEO Analyzer MCP server and its available tools](seo-analyzer-mcp-tools.png) + +This visibility matters because an MCP server is not just a checkbox. It is a surface area. It may expose one tool or many. Some tools may be read-only, and some may perform actions outside the repository. + +The agent does not need me to call those tools manually. I describe the task, and if the tool is available and relevant, the agent can decide to use it. That is why a prompt can stay natural: + +```text +Read the product requirement from the connected knowledge base and compare it with the current implementation. +``` + +The prompt does not need to become a tool invocation script. The agent still owns the reasoning loop. MCP only gives it a new place to look. + +## Tool Access: What The Agent Is Allowed To Use + +The most important part of MCP in ABP Studio is not only connecting servers. It is controlling what the agent is allowed to use. + +Individual tools can be disabled. If a tool is disabled, it is not offered to the agent. The agent should not plan around it, call it, or assume it has access to it. + +That is useful for safety, but also for quality. + +![Disabling individual tools exposed by the SEO Analyzer MCP server](seo-analyzer-mcp-tools-disabled-indivually.png) + +Every enabled tool becomes part of the menu the agent considers. If a server exposes ten tools but the current task only needs two, I usually prefer to enable only those two. A shorter tool list is easier for the agent to choose from. It also makes the session easier for me to reason about. + +The permission model is simple: + +- If a server is not connected, the agent cannot use it. +- If a server is disabled, the agent cannot use its tools. +- If an individual tool is disabled, the agent cannot use that tool. +- If the session is not in Agent mode, MCP tools are not available. + +That last point is important. MCP tools are available in **Agent mode only**. + +Plan and Ask modes are read-only by design. They are useful for understanding, planning, and discussing changes, but they do not receive MCP tools. If I want the agent to call an external MCP tool, I need to work in Agent mode. + +This keeps the model consistent with the rest of the ABP AI Coding Agent experience. The mode determines what kind of work the agent is allowed to perform. + +## Keeping MCP Safe + +MCP is powerful because it lets an AI agent reach systems outside the solution. + +That is also why it needs care. + +An MCP server is a program. It may read files, call APIs, query databases, send requests, or perform actions depending on how it was built. The agent is not inventing those capabilities. It is using what the server exposes. + +So trust matters at two levels. + +First, I need to trust the actions. If a tool can update an issue, send a message, change a record, or trigger a workflow, that is a real side effect. + +Second, I need to trust the descriptions. When an MCP server connects, the names and descriptions of its tools become part of what the model can read. A careless or hostile server can use that text to influence the model before I even write my prompt. + +That means MCP safety is not only about "what can this tool do?" It is also about "what instructions or descriptions does this server place in front of the model?" + +Two habits keep this manageable: + +- Connect only servers I trust. +- Disable tools the agent should not use for the current task. + +This sits on top of the agent's normal guardrails. Shell commands, URL fetches, downloads, and other sensitive actions can still require permission. Also, the tool list is fixed for a running session, so changing a server in the middle of a session does not quietly alter the tool surface already given to that session. + +That is the behavior I want from this kind of integration. MCP can extend the agent, but it should do so through visible, intentional configuration. + +## A Practical MCP Walkthrough + +Let me make this more concrete with a small scenario. + +Imagine I am working on the public website or documentation side of an ABP-based product. The task is not a compiler error or a failing unit test. I want to understand how the website looks from an SEO perspective and what I would improve first. + +Without MCP, I would open a separate SEO tool, run the audit, wait for the result, copy the score, paste the table into the chat, and then ask the agent to interpret it. That is not terrible, but it turns me into a copy-paste integration layer. + +With an SEO Analyzer MCP server connected, I can start from a better prompt: + +```text +Please rate the SEO work on the https://abp.io website on a scale of 1 to 10. I'd also like to know what you would do if you were in charge. +``` + +At that point, the agent can use the SEO Analyzer MCP tool instead of guessing from general SEO knowledge alone. It can call the audit tool, read the structured result, and turn it into a concrete improvement plan. + +![ABP AI Coding Agent using the SEO Analyzer MCP server](using-mcp.png) + +The value is not only that the agent saved me a few seconds. The value is that the agent starts from live tool output instead of a manually summarized report. + +### First, Without The MCP Server + +If no SEO MCP server is connected, the agent cannot run the audit directly. + +It may still give a reasonable answer from general knowledge. It may say that the site should have good titles, descriptions, headings, performance, backlinks, structured data, and content targeting. That advice can be useful, but it is generic. + +For example, I might write: + +```text +Review the SEO of abp.io and tell me what to improve. +``` + +The agent can reason about common SEO practices, but it does not have a fresh audit result. It does not know which categories scored well, which areas are weak, or whether the live page data confirms the concern. + +This is similar to debugging without monitoring tools. The agent can reason, but it is reasoning from weaker evidence. + +### Then, With The MCP Server Enabled + +Now imagine the SEO Analyzer MCP server is connected, enabled, and the relevant audit tool is enabled. + +I can ask: + +```text +Run an audit on https://abp.io, rate the result, and suggest the highest-impact improvements. +``` + +This time the agent can gather the external context first. In the screenshot, the server exposes tools like `run_audit_anonymous`, `run_audit`, `get_audit`, `list_audits`, `get_audit_pdf`, and `get_organic_traffic`. The agent uses the audit tool and then explains the result in human terms. + +That changes the workflow: + +1. Run the external SEO audit. +2. Read the score and category breakdown. +3. Identify the weakest areas. +4. Suggest concrete technical or content improvements. +5. If needed, inspect the related website or documentation files. + +The agent is no longer guessing from best practices alone. It can connect the outside report to the actual website and then, if the relevant files are in the solution, help improve them. + +### Another Example: Live Monitoring + +SEO is only one example. Monitoring is another strong fit for MCP. + +Prometheus MCP servers can expose tools for PromQL instant queries, range queries, metric discovery, targets, and server information. That means I can ask operational questions in natural language while the agent queries the monitoring system through MCP. + +For example: + +```text +Use the Prometheus MCP server to check p95 latency, request rate, and error rate for the public web application after the last deployment. If something changed, identify the most likely area to inspect in the ABP solution. +``` + +That does not mean the agent should blindly change production behavior. It means the agent can start from live evidence: metrics, trends, targets, and recent changes. Then it can use ABP Studio context to inspect the related application, module, or configuration. + +### Combining MCP With ABP Studio Tools + +The best part is that MCP does not replace the ABP Studio tools from the previous article. It works beside them. + +In a real workflow, MCP may provide the outside signal, and ABP Studio tools may provide the inside development loop: + +1. MCP brings in the external context. +2. ABP-aware reasoning maps it to the solution. +3. ABP Studio tools help implement and validate the change. + +That is where the integration becomes more useful than a generic MCP checkbox. The agent is connected to an external tool while still understanding the ABP development environment. + +## How MCP Fits With Everything Else + +MCP is an open standard, so connecting an MCP server is not an ABP-only idea. + +Tools like Cursor, Claude, VS Code extensions, and other AI coding environments can also use MCP. That is part of its appeal. Teams can build or adopt a server once and use it across different tools. + +What makes MCP especially useful in ABP Studio is the company it keeps. ABP AI Coding Agent already understands the ABP solution structure, application layers, modules, migrations, proxies, build actions, run profiles, containers, monitoring data, and official ABP documentation. MCP adds the outside world to that picture. + +So I do not need to choose between: + +- an agent that understands ABP, +- and an agent that can reach my team's external systems. + +The better experience is both together, under visible configuration. Put simply: + +- Built-in ABP Studio tools handle the solution and runtime environment. +- MCP servers handle external tools and data sources. +- Rules, skills, and lessons shape how the agent behaves. +- Agent mode decides whether tool use is available for the session. + +Each part has a different role. Keeping those roles clear makes the agent easier to trust. This is also why I would start small in a real team workflow: one low-risk, high-value server, probably a read-only analytics, documentation, or monitoring server, with only the tools that support a clear workflow enabled. + +MCP by itself is a protocol. The difference in ABP Studio is that MCP becomes part of an ABP development session. The agent can read an external SEO report or monitoring signal, but it can also understand which ABP application, module, page, or configuration owns the behavior and validate the change with ABP Studio tools. + +Most real tasks are not only "call an external tool." They are more like: + +1. Understand the requirement from outside the repository. +2. Find where that behavior lives in the ABP solution. +3. Make the smallest correct change. +4. Validate it in the same development environment. + +MCP helps with the first part. ABP Studio AI helps connect the rest. + +**That makes MCP valuable not because it is another tool list, but because it extends the ABP AI Coding Agent beyond the repository without disconnecting it from the ABP workflow.** + +## Conclusion + +MCP is the agent's connection to everything that is not already inside the ABP solution. + +You will not need it for every task. But when the work depends on SEO data, live metrics, external documentation, internal services, or team knowledge, MCP gives the agent a standard way to reach that context. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools-disabled-indivually.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools-disabled-indivually.png new file mode 100644 index 0000000000..e1b631f99c Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools-disabled-indivually.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools.png new file mode 100644 index 0000000000..2160b389e0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/seo-analyzer-mcp-tools.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/using-mcp.png b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/using-mcp.png new file mode 100644 index 0000000000..68810c6915 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-5-mcp/using-mcp.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/cover-image.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/cover-image.png new file mode 100644 index 0000000000..67e2819cac Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-commit-message.gif b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-commit-message.gif new file mode 100644 index 0000000000..553e5b2b78 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-commit-message.gif differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review-details.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review-details.png new file mode 100644 index 0000000000..8e4c43718d Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review-details.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review.png new file mode 100644 index 0000000000..572ec3d4b7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-ai-review.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-branch-and-stash.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-branch-and-stash.png new file mode 100644 index 0000000000..5f5d9078a9 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-branch-and-stash.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-changes-and-diff.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-changes-and-diff.png new file mode 100644 index 0000000000..dabd936b88 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-changes-and-diff.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-diff-comments.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-diff-comments.png new file mode 100644 index 0000000000..5ace3ff8eb Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-diff-comments.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-initialize-repository.png b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-initialize-repository.png new file mode 100644 index 0000000000..fcf2ad3929 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/git-initialize-repository.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/post.md new file mode 100644 index 0000000000..18036a1da3 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-6-abp-studio-git-integration/post.md @@ -0,0 +1,212 @@ +# Deep Dive on ABP AI Agent #6: ABP Studio Git Integration + +When I use an AI coding agent, I do not only care about whether it can change files. + +I care about what happens around those changes. + +Which branch am I on? What exactly changed? Can I review the diff before I commit? Did I accidentally touch a file from another package? Is my branch behind the default branch? If a pull request already has feedback, can I bring that context back into the coding session without copying every comment by hand? + +That is where Git integration in **ABP Studio** becomes important. + +Git is not just the final step after the agent finishes. For me, it is the confidence layer around the whole workflow. It helps me keep AI-assisted work reviewable, recoverable, and connected to the same team process I already use every day. + +![ABP Studio Git Integration cover](cover-image.png) + +## Git In The AI Workflow + +Most development work is not a straight line from prompt to done. + +I may ask ABP Agent to make a small change, then review the diff and adjust the direction. I may ask it to address feedback from a pull request. I may start from a GitHub issue, create a branch, let the agent investigate, and then decide which changes are ready to commit. + +In all of those moments, Git gives me a practical boundary: + +* this is the branch I am working on, +* these are the files that changed, +* this is the diff I need to review, +* this is the commit I am about to create, +* and this is the context I want to send back to the team. + +Without a Git-aware workflow, AI changes can feel a little too loose. The agent may be productive, but I still need a clean way to inspect, group, commit, push, and discuss the result. + +ABP Studio Git Integration brings that loop into the same place where I already work with the solution and the agent. + +## Initializing Git For A Solution + +The Git panel starts with the active solution. + +If the solution is not a Git repository yet, ABP Studio does not pretend otherwise. It shows a simple empty state and lets me initialize Git from there. I can choose the initial branch name, create a `.gitignore`, and create the first commit. + +![Initializing Git for an ABP solution in ABP Studio](git-initialize-repository.png) + +That is useful for new ABP solutions because the first Git step is part of the project setup, not something I need to remember after the fact. + +If I want to put the solution on GitHub, Studio can also help with that path. After connecting my GitHub account, I can publish the repository under my account or an organization, choose the repository name, add a description, and decide whether it should be private. + +The small but important detail is that Git becomes part of the solution experience early. I do not need to move from ABP Studio to a separate Git tool just to create the repository before I start working with ABP Agent. + +## Changed Files And Diff Review + +Once Git is active, the Git panel becomes the place I check after an agent session or a manual edit. + +I can see the current branch, remote state, changed files, and the selected file diff. The changes are not only a flat list. In an ABP solution, they can be grouped in a way that follows the solution structure, so changes under different packages or solution areas are easier to scan. + +![Changed files and diff review in ABP Studio Git Integration](git-changes-and-diff.png) + +That grouping matters in real ABP work. + +If I asked ABP Agent to adjust a public web page, but I see changes in an admin package, I immediately know to slow down and review why. If a change touches a contract package and a UI package, the grouping helps me understand that relationship before I commit. + +The diff viewer is also part of the same loop. I do not need to leave the workspace just to answer the basic review question: + +```text +What did this task actually change? +``` + +That is the question I want to answer before a commit, especially when AI helped produce the diff. + +## Selected Files And Commit Messages + +Committing is not only pressing a button. + +I still want to choose which files belong together. I still want the commit message to match the change. I still want to avoid committing work on a protected branch by accident. + +ABP Studio keeps that flow visible. I can select the files I want, write a summary and description, and commit to the current branch. When AI is enabled, Studio can generate a commit message from the selected diffs. + +![Generating a commit message from selected changes](git-ai-commit-message.gif) + +I like this because it keeps the AI help close to the actual diff. + +A generic prompt like "write a commit message" depends on what I paste into the chat. In Studio, the commit message generator can work from the selected files. That makes the result more focused, and I still stay in control because the generated text lands in the commit fields before I use it. + +The protected branch warning is another important part of the experience. If the current branch should not receive direct commits, Studio makes that visible and pushes me toward the safer workflow: create a branch, review the diff, then commit there. + +That is the right kind of guardrail. It does not make Git complicated. It makes the normal team habit harder to miss. + +## Branching, Stashing, And Syncing + +AI-assisted development often starts with a branch decision. + +Sometimes I am starting fresh from the default branch. Sometimes I am building on work already in my current branch. Sometimes I have local changes and need to switch context without losing them. + +ABP Studio exposes those decisions in the Git panel. + +I can create a branch, switch branches, update from the default branch, fetch, pull, push, and see whether I am ahead or behind. If I switch branches while I have local changes, Studio asks what should happen to that work: leave it behind as a stash or bring it with me. + +![Branch switching and stashed changes in ABP Studio](git-branch-and-stash.png) + +That choice is more important than it looks. + +When I am working with an agent, I do not want local changes to silently follow me into the wrong branch. I also do not want to lose half-finished work just because I need to inspect another issue. The stash flow turns that into an explicit decision. + +The same idea applies to sync. + +If my branch is behind, I can update before I continue. If I have local commits, I can push them. If a merge or pull produces conflicts, Studio shows the conflicted files and gives me a path to resolve, abort, continue, or send the conflict context to ABP Agent. + +That keeps the Git workflow close to the coding workflow. I can move from change to review to sync without mentally switching tools. + +## AI Review And Manual Diff Comments + +There is a moment before a commit where I often want a second look. + +Not a full pull request review. Not a long architecture discussion. Just a focused pass over the files I selected: + +```text +Does this diff contain something suspicious? +Did the agent miss a small edge case? +Is there a line I should check again before committing? +``` + +ABP Studio supports that with AI review on selected Git changes. + +![AI review suggestions on selected Git changes](git-ai-review.png) + +![AI review suggestions on selected Git changes](git-ai-review-details.png) + +AI review is not the only way to leave notes on a diff. I can also write my own comments directly on changed lines while I am reviewing. + +![Manual comments on Git diff in ABP Studio](git-diff-comments.png) + +The useful part is that the review is attached to the diff. Suggestions and notes appear near the changed lines, and if there is something I want the agent to handle, I can send those review notes to ABP Agent. + +That changes the feel of the workflow. + +Instead of asking the agent to code and then manually re-explaining my review comments, I can turn the review result back into a task. Whether a note comes from AI review or from something I wrote myself, the agent gets the file, line, and note context. I still review the result, but I spend less time copying context between places. + +Git also helps with recovery. In a Git repository, ABP Studio can offer **Back to this point** in the agent conversation. For me, that is a comfort feature: if an agent turn takes the work in the wrong direction, I can return to an earlier point instead of manually untangling every changed file. + +I still treat Git commits as the real checkpoints for team work. But during a live agent session, being able to go back to a previous point makes experimentation feel less risky. + +## GitHub Issue Context + +Many tasks do not start as a prompt. They start as an issue. + +The issue has the requirement, comments, labels, screenshots, and sometimes a conversation about what is expected. If that context stays only in the browser, I have to copy it into the agent manually. + +ABP Studio can bring GitHub issues into the Git area. + +I can filter issues, open one, read the description and comments, create a branch for that issue, and send the issue context to ABP Agent. + + + +That makes the workflow feel natural: + +1. Pick the issue. +2. Create a branch for it. +3. Send the relevant context to ABP Agent. +4. Let the agent inspect the solution and implement the change. +5. Review the Git diff before committing. + +The important detail is that the agent starts from the same context I would start from as a developer. It sees the issue title, description, labels, included comments, and attached images when they are part of the selected context. + +That is much better than writing a vague prompt that tries to summarize the issue from memory. + +## Pull Request Feedback Context + +Pull request feedback is another place where Git integration helps the AI workflow. + +When I open a pull request inside ABP Studio, I can see the PR title, branches, comments, reviews, and requested changes. If I am not on the PR branch, Studio can switch to it. Then I can choose which comments or requested changes should be included and send that context to ABP Agent. + + +This is the workflow I want when a reviewer asks for changes: + +* read the feedback, +* switch to the right branch, +* include only the relevant comments, +* send the request to ABP Agent, +* review the resulting diff, +* commit and push. + +The include and exclude controls matter here. Not every PR comment should become an agent instruction. Some comments are discussion, some are already resolved, and some are optional. I want to choose what becomes context. + +That keeps the agent from treating the entire PR timeline as one undifferentiated command. I can shape the task before sending it. + +## Git Integration In The Deep Dive Series + +In the earlier articles, we looked at modes, tools, MCP, scopes, and workflows. + +Git integration connects those ideas to the normal development lifecycle. + +* **Ask and Plan** help me understand and shape the work. +* **Agent mode** can make the change. +* **Tools** help the agent use ABP Studio context. +* **Scopes** keep the working area focused. +* **Workflows** make repeated actions predictable. +* **Git integration** lets me review, recover, commit, push, and collaborate around the result. + +That last part is easy to underestimate. + +The value of an AI coding agent is not only how quickly it can modify files. The value is whether I can bring those modifications into a professional development workflow without losing control. + +Git is the structure that makes that possible. + +## Conclusion + +ABP Studio Git Integration makes ABP Agent feel more grounded. + +It gives me a clear path from issue to branch, from agent work to diff review, from selected files to commit, and from pull request feedback back into the agent. + +For everyday work, that means fewer context switches. For AI-assisted work, it means more confidence. + +I can let the agent help, but I do not have to accept the result blindly. I can inspect the diff, ask for a review, commit intentionally, push when ready, and keep the whole process connected to GitHub and the team workflow. + +That is the part I value most: **Git integration turns AI output into reviewable development work.** diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/ai-agent-panel.png b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/ai-agent-panel.png new file mode 100644 index 0000000000..c0fcd9a3e7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/ai-agent-panel.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/auth-identity-scope.png b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/auth-identity-scope.png new file mode 100644 index 0000000000..1fb92ec9ca Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/auth-identity-scope.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/post.md new file mode 100644 index 0000000000..4b9aa4b8d7 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/post.md @@ -0,0 +1,157 @@ +# Deep Dive on ABP AI Agent #7: Scopes + +When I use an AI coding agent in a real ABP solution, I do not always want it to see everything. + +That may sound strange at first. More context usually feels better. But in a large solution, more context can also mean more noise, more unrelated files, and more chances for the agent to drift into an area that is not part of the task. + +If I am working on the public side of a modular application, I do not want the agent to redesign the admin side. If I am changing a Catalog module, I do not want it to spend half the session reasoning about Identity, SaaS, or Payment code. If I am fixing one microservice, I do not want the agent to treat the whole platform as editable surface area. + +That is where **AI Scopes** become one of the most important control features in **ABP Studio AI Coding Agent**. + +## Why Scopes Matter? + +Most AI coding agents are very good at reading a folder and making changes. That is useful, but an ABP solution is rarely just a folder. + +An ABP solution can contain modules, packages, applications, gateways, background workers, database projects, shared contracts, UI projects, and infrastructure configuration. In a microservice solution, the repository may contain multiple independently meaningful services. In a modular monolith, a single solution may still have clear business boundaries. + +-> In those situations, the question is not only: **Can the agent understand the solution?** ❌ + +-> The better question is: **Which part of the solution should the agent be allowed to work with for this task?** ✅ + +AI Scopes answer that question directly. + +They let me choose the accessible area before the session starts. The agent can then focus on the relevant module, package, solution area, or external folder instead of treating the entire repository as equally relevant. + +For me, that changes the feeling of using an AI agent. It is no longer "here is my whole codebase, please be careful." It becomes "here is the part of the system this task belongs to, work inside that boundary." + +## What An AI Scope Controls? + +An AI Scope **restricts which directories the agent can access during a session**. + +![Auth and Identity scope configuration in ABP Studio](auth-identity-scope.png) + +Depending on the task, a scope can include: + +* the whole solution, +* selected modules, +* selected packages, +* selected external folders, +* or a focused combination of these. + +The important part is that this is not only a prompt suggestion. It is part of the session context and file access boundary. File paths used by the agent are validated against the resolved scope. If a file is outside the accessible directories, the agent should not treat it as part of the editable workspace. + +Scopes also work together with `.abpignore`. Even if a file is under an accessible directory, files excluded by `.abpignore` remain blocked. That gives teams two useful layers: + +* **Scopes** decide which solution areas are relevant to the task. +* **`.abpignore`** protects files that should stay inaccessible, such as secrets, certificates, environment files, or other sensitive local data. + +This is a practical control model. I can narrow the agent's working area without pretending that the repository is smaller than it really is. + +## Scope Is Locked To The Session + +Another detail I like is that scope belongs to the AI Agent session. + +The first message of a session locks the configuration that affects the system prompt, including the active AI Scope. If a background session continues running and I change the foreground scope later, that running session does not silently change its context. + +That matters when multiple sessions are active. + +![Selected AI scope shown in the agent session panel](selected-scope.png) + +Imagine I have one session working on a Catalog module and another session answering questions about the whole solution. Those sessions should not accidentally share a changing boundary. Each one should keep the scope it started with. + +This makes scopes more predictable. I can choose the scope intentionally at the beginning of the work and trust that the session is tied to that decision. + +## Focused Autonomy + +Scopes are not about making the agent weaker. They are about making autonomy more focused. + +When I narrow the scope, I am not saying the agent is less capable. I am saying the task has a boundary. + +For example: + +```text +Use the Public AI Scope. +Add a small validation improvement to the public product search flow. +Do not inspect or change the Admin side unless you find a direct contract dependency. +``` + +That kind of prompt becomes much stronger when the selected scope already matches the instruction. The agent receives both the natural-language task and the platform-level boundary. + +This is especially useful for ABP because ABP applications are built around clear concepts: modules, layers, packages, application services, repositories, DTOs, permissions, localization resources, DbContexts, and run profiles. A scope can follow those boundaries instead of relying only on a long prompt. + +## What Scopes Help Prevent? + +Scopes help reduce a few common AI-agent failure modes. + +- First, they reduce **unrelated exploration**. The agent does not need to spend time discovering files that have nothing to do with the task. +- Second, they reduce **accidental edits**. When a task belongs to one module, the agent should not casually change another module just because it found a similar type there. +- Third, they improve **reviewability**. If I scoped the task to `Catalog`, and the diff changes `Identity`, that is immediately suspicious. The boundary makes the review easier. +- Fourth, they support **parallel work**. Different sessions can be scoped to different areas, which is useful when independent tasks are running in the same solution. + +This is one of the places where ABP AI Coding Agent feels different from a generic coding tool. The feature is not only "the model can read fewer files." It is integrated into ABP Studio's understanding of the solution. + +## Scopes And ABP Solution Architecture + +ABP already encourages clear boundaries. + +In a layered module, the Domain layer should not depend on the Application layer. HTTP API projects should depend on contracts, not implementation projects. Entity Framework Core and MongoDB integrations should stay behind the domain abstractions. A reusable module should be understandable as a module, not only as a set of files. + +AI Scopes fit naturally into that mindset. + +If I am working on a Domain change, I can keep the scope close to the module and its required shared contracts. If I am working on UI behavior, I can include the UI package and the related contract package. If I am working on a microservice, I can scope the agent to that service and only add external folders when they are truly required. + +That means the agent's working area can follow the same mental model I already use as an ABP developer: + +```text +What bounded area owns this change? +Which packages are needed to make it safely? +Which parts of the system should stay out of this session? +``` + +## Scopes And Workflows Work Better Together + +- Scopes define **where** the agent can work. +- Workflows define **what deterministic actions** should happen around that work. + +![ABP AI Coding Agent panel showing scopes and workflows combined](scopes-openning.png) + +That combination is powerful. For example, I can scope the agent to the `Catalog` module and use a workflow that builds the affected package, regenerates proxies if contracts changed, and restarts the related application. + +The scope keeps the coding session focused. The workflow keeps the verification loop repeatable. + +This is the larger ABP Studio AI story. It is not only an AI chat window. It is an agent inside a platform that already understands ABP solutions, run profiles, tools, workflows, Git state, and runtime signals. + +## Why This Is Different From Generic Coding Agents? + +Tools like Cursor, Claude Code, Codex, and Windsurf are strong general-purpose coding tools. They can read files, edit code, run shell commands, and help with many projects. + +**ABP AI Coding Agent is different because it is built around ABP Studio's view of an ABP solution.** + +Scopes are a good example of that difference. + +In a generic tool, I can try to simulate scope with a prompt: + +```text +Only work in this folder. +``` + +That is helpful, but it is still mostly an instruction. In ABP Studio, scope is part of the agent session and file access model. It can be selected intentionally before the work starts, stored with the session, and combined with `.abpignore`, workflows, tools, plans, and run profile context. + +For professional ABP teams, that matters. The goal is not to give an AI agent unlimited access and hope the prompt is clear enough. The goal is to create a controlled development loop where the agent understands the system, works in the right area, uses the right tools, and produces a diff that is easier to trust. + +## Conclusion + +AI Scopes make ABP Studio AI Coding Agent feel more deliberate. + +They let me say: + +* this is the part of the solution that matters, +* this is the boundary for the current session, +* this is the context the agent should focus on, +* and everything else should stay outside unless we intentionally expand the scope. + +That is exactly the kind of control I want when using AI in real ABP solutions. + +The agent can still be powerful. It can still plan, edit, build, run tools, and iterate. But with scopes, that power is pointed at the right part of the system. + +That is the real value: **not just more AI autonomy, but better-shaped AI autonomy for ABP development.** diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/scopes-openning.png b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/scopes-openning.png new file mode 100644 index 0000000000..6297b817e8 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/scopes-openning.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/selected-scope.png b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/selected-scope.png new file mode 100644 index 0000000000..b9039e28c2 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-7-scopes/selected-scope.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/cover-image.png b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/cover-image.png new file mode 100644 index 0000000000..4ba1f3f4c8 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/model-settings.png b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/model-settings.png new file mode 100644 index 0000000000..1f130d4888 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/model-settings.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/post.md new file mode 100644 index 0000000000..67f9279082 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-8-parallel-agent-execution/post.md @@ -0,0 +1,90 @@ +# Deep Dive on ABP AI Agent #8: Parallel Agent Execution + +Real work rarely lines up one task at a time. + +While the agent is busy adding a feature, a question comes up about a different module. A review is waiting. A small fix would take two minutes, but the agent is in the middle of something else. With a single session, you wait. You watch one task finish before you can start the next, even when the two have nothing to do with each other. + +ABP Studio does not make you wait. It can run several agent sessions at the same time, each on its own task. + +## Parallel Agent Sessions + +A session is one conversation with the agent. It has its own history, its own mode, its own model, its own scope, and its own workflow. ABP Studio keeps multiple sessions per solution, and they can run in parallel. + +There is a limit, and it is there on purpose. By default you can run up to **3 sessions at once**, and you can set that anywhere from **1 to 5**. When you send more prompts than there are open slots, the extra ones are queued and start as soon as a slot frees up. + +So "parallel" here is not a trick of switching back and forth quickly. The sessions actually run at the same time, up to the limit you choose. + +## Parallel Session Use Cases + +The point is to stop letting one task block another. A few ways it plays out: + +* One session implements a feature while another answers questions about a different part of the solution. +* Two unrelated modules get worked on at once, each in its own session. +* One session writes the code while another reviews a diff or does research. + +Because each session is separate, you can even point them at different parts of the solution and give them different jobs: + +```text +Session 1 (Agent): Add a Category screen to the Catalog module. +Session 2 (Ask): Explain how permissions flow from the API to the UI. +``` + +The first one edits files. The second one only reads and answers. They do not interfere, because they are different sessions with different settings. + +## Per-Session Settings Isolation + +This is the part that makes parallel work predictable instead of chaotic. + +When a session sends its first message, two things happen at different levels. The session permanently locks its **scope** and **workflow**—these stay fixed for the entire session lifetime and cannot be changed once the first message is sent. On the other hand, the **model**, **mode**, and **active tools** are snapshotted fresh at each run (each turn the agent takes), so they reflect whatever is configured at the moment the run starts but remain stable for its duration. + +That matters the moment you have more than one session open. Say one session is running in the background, scoped to the `Catalog` module. You switch the foreground to a different scope to start a second task. The background session does not notice. It keeps the scope and workflow it was locked to, and each of its runs uses whatever model and tools were configured at the moment that run began. + +Without this, parallel sessions would quietly corrupt each other every time you changed a setting. With it, each session is a sealed unit of work. + +## The Prompt Queue + +You do not have to wait for a session to be idle to line up its next step. + +While a session is running, you can queue more prompts on it. They attach to the same session and are sent one after another, each after the current turn finishes. The queue keeps the session's settings, so a queued prompt runs with the same mode, scope, model, workflow, and active plan as the rest of that session. + +This works together with the concurrency limit. Prompts that cannot start right away, because every slot is busy, simply wait their turn instead of failing. + +## Keeping Parallel Work Safe + +Running several sessions at once is powerful, and it also gives you a new way to get in your own way: two sessions editing the same files. + +ABP Studio helps here, but it does not pretend the problem away. It tracks file changes, so if one session edited a file after another session read it, the second is told to read it again before overwriting. It also serializes operations that cannot safely overlap, such as adding a migration while a build is running, so two sessions do not run conflicting `dotnet` commands at the same time. And because each session tracks its own pending questions, one session waiting on your input does not freeze the others. + +Still, the simplest rule is the best one: keep parallel Agent-mode sessions on separate parts of the solution. This is exactly where scopes earn their place. Give each session its own scope, and they stay in their own lanes by design instead of by luck. + +## A Second Kind Of Parallel: Subagents + +There is also a smaller, quieter form of parallel work that happens inside a single session. + +When the agent needs to look something up, it can fan out multiple **subagents** in a single turn: read-only helpers that research in parallel and return their results before the main agent continues. There are a few kinds, each with a narrow job: + +* one that researches your solution and code, +* one that searches the web, +* one that searches and reads the official ABP documentation. + +These research-style subagents (code research, web search, documentation search) are read-only—they cannot modify files or solution state. They only read, gather, and hand back a short summary. That keeps the main session's context clean, because the digging happens elsewhere and only the answer comes back. (Note that the browser subagent is an exception: it is stateful and can mutate the shared browser session.) + +ABP Studio can use a separate model for research subagents, configured apart from your main one. A fast, cheap model is the right choice for this kind of lookup work. + +![Model Settings: the research model used by subagents is set separately from the main model](model-settings.png) + +So there are two scales of parallel here. Sessions run independent tasks side by side. Subagents run read-only research inside one task. Both keep the slow parts from blocking the useful parts. + +## ABP Studio Parallel Execution Model + +Plenty of tools let you open more than one chat, and that is genuinely useful. So the idea of running agents in parallel is not, by itself, an ABP feature. + +The difference is that each session here carries the context of an ABP solution and stays inside it. A session locks its scope and workflow permanently, and snapshots its model and tools per run, so background work does not drift when you change the foreground. Studio coordinates the operations that would otherwise collide, like builds and migrations, across all the running sessions. And scopes give each session a clear boundary, so parallel does not turn into a pile of agents editing the same files. + +In short, the parallelism is not just several chat windows. It is several controlled, solution-aware sessions that know how to stay out of each other's way. + +## Conclusion + +Parallel execution is about respecting how work actually arrives: more than one thing at a time, often unrelated. + +ABP Studio lets you run several agent sessions together, each sealed to its own settings, with a queue for what comes next and guardrails for the places where parallel work could collide. Inside each session, subagents add a second layer of parallel research without cluttering the main task. diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/ai-agent-panel.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/ai-agent-panel.png new file mode 100644 index 0000000000..c0fcd9a3e7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/ai-agent-panel.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md new file mode 100644 index 0000000000..5d739b1588 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md @@ -0,0 +1,209 @@ +# Deep Dive on ABP AI Agent #9: Workflows + +There is a pattern I see in almost every real development session. + +The interesting part is the code change, but the repeated part is everything around it: + +* start the containers, +* build the affected packages, +* add a migration if the model changed, +* regenerate proxies if the API contract changed, +* restart the application, +* run the validation task, +* check the logs when something fails... + +When I work alone, I can do those steps manually. When I work with an AI coding agent, I do not want to keep pasting the same checklist into every prompt. I want the tool to understand that this solution has a normal way of preparing, validating, and recovering after changes. + +That is the point of **ABP Studio AI Agent Workflows**. + +![ABP Studio AI Agent Workflows overview](workflow.jpg) + +Workflows let me define repeatable actions around an agent run. The model can focus on the ambiguous part, understanding the requirement and changing the code, while ABP Studio handles the deterministic parts that should happen before or after the work. + +That combination is one of the clearest differences between **ABP AI Coding Agent** and a generic coding assistant. It is not only an editor with a chat panel. It is an agent inside a platform that **already knows how to build, run, migrate, generate proxies, manage containers, execute tasks, and inspect runtime signals.** + +## Why Workflows Matter? + +LLMs are powerful because they can reason through unclear requirements and modify code across files. But many development steps should not be creative. + +If the team always builds a package after an application service change, that should be predictable. If API contract changes require proxy generation, that should not depend on whether I remembered to mention it. If a local run needs containers before the application starts, that setup should not be reinvented in every prompt. + +Workflows give ABP Studio a place to encode those repeatable steps. + +_**Choose a Workflow and Scope before sending your prompt:**_ + +![Opening the workflow settings panel in ABP Studio](workflows-openning.png) + +_**The selected workflow can be configured through Workflow Settings, where you can create, edit, and manage reusable workflows for common development tasks:**_ + +![ABP AI Agent workflow settings](workflow-settings.png) + +For me, the value is not only automation. It is also consistency. + +Instead of asking: + +```text +Please implement this, and then build, and maybe regenerate proxies, +and restart the app, and also add a migration if needed. +``` + +I can configure the workflow once and let the agent session carry that context. + +![Sample ABP AI Agent workflow configuration](sample-workflow-1.png) + +That makes the prompt cleaner: + +```text +Add the missing status filter to the order list. +Use the selected workflow for validation after the change. +``` + +The workflow becomes part of the development environment, not a long instruction I repeat manually. + +## Before And After The Agent Works + +An AI Agent workflow has two sides: + +* **Before steps** prepare the environment before the agent starts coding. +* **After steps** guide the validation and follow-up work after the main task is complete. + +Before steps run automatically in **Agent** mode. They are useful for setup actions that should happen before the model receives control, such as starting containers or running a preparation task. + +Plan and Ask modes are read-only, so they do not execute before steps. That separation is important. If I am only asking a question or asking for a plan, I do not want Studio to start applications or run mutation-oriented tooling. + +After steps are injected into the agent instructions as post-task guidance. The agent is expected to run the relevant post-steps after completing the work, but it can skip actions that do not apply. + +For example, if my workflow includes "Add Migration" but the change does not touch the EF Core model, the agent should not create an empty migration just because the workflow exists. The workflow gives deterministic options, but the agent still uses the actual change to decide what is relevant. + +## What A Workflow Can Do? + +Workflow actions are built around the things ABP developers already do in ABP Studio. + +![ABP AI Agent workflow actions](workflow-actions.png) + +The supported actions include: + +* **Build** the solution, selected modules, selected packages, or configured targets. +* **Start Application** for selected applications, folders, or all runnable applications. +* **Stop Application** when validation needs a clean state. +* **Restart Application** after code changes. +* **Start Containers** for databases, caches, message brokers, or other dependencies. +* **Stop Containers** when the workflow needs to clean up. +* **Run Task** for configured ABP Studio tasks. +* **Add Migration** when entity changes require a database migration. +* **Generate C# Proxies** after contract changes. +* **Generate Angular Proxies** after API changes consumed by Angular clients. + +That list is very ABP-specific. + +A generic coding tool can run shell commands, and that is useful. But ABP Studio workflows know about ABP Studio concepts: applications, containers, run profile tasks, packages, modules, migrations, and proxy generation. The agent can use those as first-class actions instead of trying to infer everything from terminal commands. + +## Personal And Shared Workflows + +Workflows can be personal or shared. + +![Sharing an ABP AI Agent workflow with the team via run profile](shared-with-team.png) + +- A **personal workflow** is stored locally under the solution workspace. It is useful for my own development habits. Maybe I like restarting a specific app after each agent turn. Maybe I have a local task that only makes sense on my machine. +- A **shared workflow** is stored with the active run profile. That makes it suitable for source control and team usage. + +This is where workflows become more than a convenience feature. A team can encode its normal AI-agent validation path into the solution itself. + +For example: + +```text +Before: +- Start required containers +- Run the prepare-local-environment task + +After: +- Build affected packages +- Generate Angular proxies when contracts changed +- Restart the Web and API applications +- Run the smoke-test task +``` + +Every developer using that run profile can work with the same repeatable loop. The workflow does not replace code review or testing, but it raises the baseline for what happens after the agent touches code. + +## Workflows Make AI More Deterministic Where It Should Be + +- I do not want the model to creatively decide whether my team usually runs proxy generation. I want the workflow to encode that. +- I do not want every prompt to include a long validation checklist. I want the workflow to carry it. +- I do not want an agent to guess which applications belong to the local run. I want ABP Studio's run profile to provide that context. + +This is why workflows are such a strong fit for ABP Studio AI Coding Agent. The model stays flexible where flexibility helps, and the platform stays deterministic where determinism matters. + +The result is a cleaner division of responsibility: + +| Responsibility | Best handled by | +| --- | --- | +| Understanding the requirement | AI Agent | +| Finding and editing relevant code | AI Agent | +| Starting known containers | Workflow | +| Running known tasks | Workflow | +| Adding migrations when needed | Agent using workflow action | +| Generating proxies when contracts change | Agent using workflow action | +| Building affected packages | Workflow / Studio tools | +| Investigating runtime failures | Agent using monitoring tools | + +## Workflows And Scopes Together + +Workflows are even better when combined with AI Scopes. + +Scopes define where the agent can work. Workflows define what repeatable actions should happen around that work. + +For example, I can select a `Catalog` scope and a workflow that: + +* builds the `Catalog` package, +* regenerates proxies if contracts changed, +* restarts the public web app, +* checks recent exceptions after restart. + +That is a focused agent loop. The agent does not need the whole repository, and the validation path does not need to be invented from scratch. + +This is the kind of full flow that makes ABP AI Coding Agent feel different from tools that only operate at the file-and-terminal level. + +## Why This Is Different From Generic Coding Agents + +Generic coding agents can be excellent: Cursor, Claude Code, Codex, Windsurf, and similar tools can read code, edit files, run shell commands, and help across many kinds of projects. + +But ABP Studio AI Coding Agent is built for a different experience: **It works inside ABP Studio, where the solution already has run profiles, applications, containers, tasks, modules, packages, migrations, proxy generation, monitoring, Git integration, and ABP-aware analysis.** + +Workflows use that platform context. + +Instead of saying: + +```text +Run whatever commands seem appropriate. +``` + +I can say: + +```text +Use the selected ABP Studio workflow. +``` + +That is a very different contract. The workflow is _visible, configurable, repeatable, and tied to the solution_. + +For ABP teams, this matters because the development process is not only code generation. It is code generation plus build, migration, proxy generation, application restart, runtime observation, and review. + +ABP Studio AI Coding Agent is designed for that full loop. + +## Conclusion + +Workflows make ABP Studio AI Coding Agent more practical for real development. + +They let me move repeated setup and validation steps out of my prompt and into the platform: + +* start what needs to be running, +* build what needs to be built, +* generate what needs to be regenerated, +* migrate when a model change requires it, +* restart the relevant apps, +* and continue the debugging loop with runtime evidence. + +That is why I see workflows as one of the features that makes ABP AI Coding Agent feel complete. + +The agent is not isolated from the development environment. It works inside ABP Studio, with the same solution structure, run profile, tools, and team workflow that I already use. + +That is the difference: **not just AI-generated code, but an AI-assisted ABP development flow from change to validation.** diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/sample-workflow-1.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/sample-workflow-1.png new file mode 100644 index 0000000000..b834ee979b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/sample-workflow-1.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/shared-with-team.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/shared-with-team.png new file mode 100644 index 0000000000..08257f286b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/shared-with-team.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-actions.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-actions.png new file mode 100644 index 0000000000..08d906d188 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-actions.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-settings.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-settings.png new file mode 100644 index 0000000000..e539c3cf65 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow-settings.png differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow.jpg b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow.jpg new file mode 100644 index 0000000000..f7b8e5774b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflow.jpg differ diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflows-openning.png b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflows-openning.png new file mode 100644 index 0000000000..6297b817e8 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/workflows-openning.png differ diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md new file mode 100644 index 0000000000..2fd61ac23e --- /dev/null +++ b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md @@ -0,0 +1,645 @@ +Multi-tenancy sounds simple until the first real requirement lands: tenant-specific data isolation, host-only features, separate databases for a few big customers, and an admin panel that still feels like one product. + +ABP Framework gives you most of the plumbing out of the box, but the important part is knowing which pieces to enable, which defaults to trust, and where teams usually get into trouble. This article walks through a practical implementation approach for ABP Framework v8+ and the latest branch, covering shared database, separate database, and hybrid setups. + +## What ABP Multi-Tenancy Actually Gives You + +ABP's multi-tenancy support is not just a `TenantId` convention. It includes: + +- tenant context management +- automatic data filtering for multi-tenant entities +- tenant resolution from web requests +- host vs tenant side separation +- permission scoping by tenancy side +- tenant-aware connection string resolution +- tenant management infrastructure + +The first switch is explicit. + +```csharp +Configure(options => +{ + options.IsEnabled = true; +}); +``` + +Technically, multi-tenancy is disabled by default, although ABP startup templates usually enable it for you. + +ABP models two sides: + +- Host: the system owner, platform operator, or SaaS provider +- Tenant: the customer using the system + +A `TenantId` value of `null` typically means the data belongs to the host side. + +## Choose the Right Database Architecture First + +Before writing entities or resolvers, decide how tenant data will be stored. This choice affects migrations, operations, support cost, and sometimes your pricing model. + +### 1. Shared Database + +All tenants share the same database and tables. Isolation is enforced with `TenantId` and ABP's built-in data filters. + +Why teams choose it: + +- simplest deployment model +- lowest infrastructure cost +- easiest to operate in early-stage SaaS products +- one migration pipeline + +Trade-offs: + +- large tables grow quickly +- indexing becomes more important +- noisy-neighbor performance is more likely +- stricter discipline is required to avoid cross-tenant mistakes + +This is usually the best default unless you already know you need stronger isolation. + +### 2. Separate Database per Tenant + +Each tenant gets its own database. Host data is usually kept in a central database, while tenant-specific data goes to per-tenant databases. + +Why teams choose it: + +- stronger data isolation +- easier tenant-specific backup and restore +- cleaner compliance story +- large tenants can scale independently + +Trade-offs: + +- more provisioning logic +- more migration complexity +- more operational overhead +- onboarding a tenant is no longer just inserting a row + +### 3. Hybrid Model + +Some tenants use the shared database, while others get dedicated databases. + +This is often the most realistic long-term model: + +- small customers stay in shared infrastructure +- enterprise customers get isolated databases +- you can promote selected tenants later + +Trade-offs: + +- highest implementation and operational complexity +- migrations and seeding need stronger discipline +- debugging environment-specific issues becomes harder + + + +![Generated illustration](inline-1.png) + +## Implement Tenant-Aware Entities Correctly + +In ABP, tenant-scoped entities implement `IMultiTenant`. + +```csharp +using Volo.Abp.Domain.Entities; +using Volo.Abp.MultiTenancy; + +public class Product : AggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public string Name { get; private set; } + public decimal Price { get; private set; } + + private Product() + { + } + + public Product(Guid id, string name, decimal price, Guid? tenantId) + : base(id) + { + TenantId = tenantId; + Name = name; + Price = price; + } +} +``` + +Once an entity implements `IMultiTenant`, ABP automatically filters queries according to the current tenant. + +That means this kind of repository call is already tenant-aware in normal application flow: + +```csharp +var products = await _productRepository.GetListAsync(); +``` + +### The nullable `TenantId` detail matters + +`TenantId` is nullable by design because host-owned data is valid in ABP. + +That is useful, but also easy to misuse. + +If an entity is truly tenant-only, do not casually allow `TenantId = null`. Enforce the rule in your constructor, factory method, or domain service. + +Example: + +```csharp +public Order(Guid id, Guid tenantId, string orderNo) : base(id) +{ + TenantId = tenantId; + OrderNo = orderNo; +} + +public Guid? TenantId { get; private set; } +public string OrderNo { get; private set; } +``` + +For tenant-only aggregates, this small constraint prevents a surprising number of data leakage bugs. + +## Use `ICurrentTenant` for Context-Aware Logic + +`ICurrentTenant` is the central service for reading or temporarily changing tenant context. + +```csharp +public class ProductAppService : ApplicationService +{ + public async Task GetTenantInfoAsync() + { + if (CurrentTenant.IsAvailable) + { + return $"TenantId: {CurrentTenant.Id}, Name: {CurrentTenant.Name}"; + } + + return "Host context"; + } +} +``` + +The more interesting capability is context switching. + +```csharp +using (_currentTenant.Change(tenantId)) +{ + var count = await _productRepository.GetCountAsync(); +} +``` + +This is useful for: + +- background jobs that process one tenant at a time +- host-side reporting across tenants +- tenant seeding during onboarding +- maintenance tasks and migrations + +### A practical warning + +Switching tenant context is powerful. It is also a common source of subtle bugs when developers mix host and tenant operations in the same method. Keep tenant context scopes short and obvious. + +## How Tenant Resolution Works in ABP + +ABP determines the active tenant through a chain of tenant resolvers. Out of the box, the default contributors are checked in this order: + +1. Current user claims +2. Query string, using `__tenant` by default +3. Route value +4. Header +5. Cookie + +In practice, this means a request can become tenant-aware even before your application service runs. + +### Default key configuration + +If you want to change the default `__tenant` key: + +```csharp +Configure(options => +{ + options.TenantKey = "tenant"; +}); +``` + +This is fine, but if you have a frontend client, especially Angular, the client must use the same tenant key. Otherwise the backend and frontend silently disagree about tenant resolution. + +### Domain and subdomain based resolution + +ABP also supports domain or subdomain-based tenant resolution. + +```csharp +Configure(options => +{ + options.AddDomainTenantResolver("{0}.myapp.com"); +}); +``` + +This is usually the cleanest user experience for SaaS applications because the tenant is implied by the hostname. + +Use it when: + +- each tenant has a branded subdomain +- the URL should define tenant context naturally +- you want fewer explicit tenant parameters in requests + +Be careful with: + +- reverse proxies and forwarded headers +- wildcard DNS and TLS certificates +- authentication server issuer validation in wildcard domain scenarios +- local development setup + +If you use OpenIddict or token validation with wildcard domains, make sure issuer validation is configured for that pattern. This is one of the most common production surprises in subdomain-based multi-tenant setups. + +### Fallback tenant + +ABP can also use a fallback tenant. + +That can be convenient in development or in a constrained deployment model, but it comes with an important trade-off: you effectively reduce or hide host context behavior. Use fallback tenants deliberately, not as a shortcut for resolver problems. + + + +![Generated illustration](inline-2.png) + +## Custom Tenant Resolvers for Real Projects + +Sooner or later, one tenant comes from a gateway header, another from a custom route pattern, and a third from a legacy integration. + +ABP allows custom tenant resolvers by implementing a contributor. + +```csharp +using System.Threading.Tasks; +using Volo.Abp.MultiTenancy; + +public class XTenantHeaderResolveContributor : TenantResolveContributorBase +{ + public const string HeaderName = "X-Tenant-Code"; + + public override string Name => "XTenantHeader"; + + public override Task ResolveAsync(ITenantResolveContext context) + { + var httpContext = context.GetHttpContext(); + var tenantCode = httpContext?.Request.Headers[HeaderName].ToString(); + + if (!tenantCode.IsNullOrWhiteSpace()) + { + context.Handled = true; + context.TenantIdOrName = tenantCode; + } + + return Task.CompletedTask; + } +} +``` + +Then register it in tenant resolve options. + +The main rule here is simple: prefer one primary strategy. A long resolver chain with multiple overlapping sources makes support harder. + +## Configure `DbContext` for Host and Tenant Sides + +When you move beyond a single shared database, `DbContext` design becomes a core architecture decision. + +ABP supports defining which side a context belongs to: + +- `Both` +- `Host` +- `Tenant` + +This matters when you want host-only tables to stay out of tenant databases, or when tenant databases should contain only selected modules. + +### Why this matters + +Suppose your host side includes tenant management, audit administration, and platform billing, but tenant databases should only include business tables and tenant-facing identity data. + +If you blindly configure every module in every context, your tenant databases will accumulate tables they should never have had. + +### Practical approach + +For a shared database setup, one `DbContext` with `Both` is often enough. + +For separate or hybrid databases, a common approach is: + +- one host/shared `DbContext` +- one tenant-only `DbContext` +- selective module configuration per context + +The important implementation detail is not just the side flag. It is also controlling which `builder.ConfigureXyz()` calls are applied in each context. + +For example, do not configure host-only modules in the tenant-only context. + +## Shared Database Setup: The Best Starting Point + +If you are implementing multi-tenancy for the first time in ABP, start with the shared database model unless you have a strong reason not to. + +A practical setup looks like this: + +1. Enable multi-tenancy +2. Make tenant-owned entities implement `IMultiTenant` +3. Use standard ABP repositories +4. Resolve tenant from user, subdomain, or request key +5. Keep host-owned data with `TenantId = null` +6. Define permissions with proper tenancy sides + +Example entity creation inside a tenant context: + +```csharp +public class ProductManager : DomainService +{ + public async Task CreateAsync(string name, decimal price) + { + var product = new Product( + GuidGenerator.Create(), + name, + price, + CurrentTenant.Id + ); + + return await _productRepository.InsertAsync(product); + } +} +``` + +This works well because ABP naturally fills the application flow with tenant context. + +### Performance tips for shared database mode + +As tenant count grows: + +- index `TenantId` on large tables +- include `TenantId` in common query patterns +- monitor large shared tables early +- be careful with cross-tenant reporting queries +- verify all custom SQL is tenant-aware + +ABP helps with filtering, but it does not replace database design. + +## Separate Database per Tenant in ABP + +This is where ABP becomes especially useful, because it can resolve the active tenant and then use tenant-specific connection strings. + +The Tenant Management module stores tenant metadata, including optional connection strings. + +At a high level, the flow is: + +1. Resolve the current tenant +2. Load tenant configuration +3. Determine the right connection string +4. Build the `DbContext` against the host or tenant database +5. Apply data filters inside that database scope as needed + +### What is available out of the box + +ABP supports the architecture and connection-string-based separation. + +Version-wise, the latest ABP docs reflect improved support in open source for separate database per tenant. However, managing tenant connection strings from the UI remains tied to SaaS/PRO features. In open source, teams often provide this through custom admin screens, configuration management, or provisioning services. + +### What changes operationally + +With per-tenant databases, you now need a plan for: + +- database creation during tenant onboarding +- migrations for new and existing tenant databases +- tenant-specific seeding +- backups and restore procedures +- monitoring failed or drifted tenant databases + +This is the real cost of stronger isolation. + + + +![Generated illustration](inline-3.png) + +## Hybrid Multi-Tenancy: Shared by Default, Dedicated When Needed + +Hybrid architecture is often the most business-friendly model. + +A common pattern looks like this: + +- default all new tenants to shared database +- move larger or regulated tenants to dedicated databases +- keep host/platform data in a central database + +This lets you defer infrastructure cost until a tenant actually needs isolation. + +The challenge is not whether ABP supports it. It does. The challenge is operational consistency: + +- how a tenant is promoted from shared to dedicated +- how data is moved safely +- how migrations stay aligned across both models +- how support engineers know which storage model a tenant uses + +If you choose hybrid, document the lifecycle, not just the code. + +## Tenant Management, Onboarding, and Connection Strings + +ABP's Tenant Management module is the starting point for tenant administration. + +It gives you tenant records and a standard place to store metadata. In more advanced solutions, that metadata is often extended with: + +- edition or plan +- onboarding status +- provisioning result +- custom domains +- support tier +- infrastructure notes + +For separate database scenarios, onboarding usually means more than creating a tenant row. It often includes: + +1. create tenant record +2. assign connection string if needed +3. create database or schema +4. run migrations +5. seed tenant data +6. create admin user +7. confirm domain or resolver setup + +Treat onboarding as a workflow, not a controller action. + +## Permissions and Authorization in a Multi-Tenant App + +ABP permissions can be scoped with `MultiTenancySides`. + +That is important because host users and tenant users often should not even see the same capabilities. + +Example definition: + +```csharp +context.AddGroup(MyPermissions.GroupName) + .AddPermission( + MyPermissions.HostDashboard, + multiTenancySide: MultiTenancySides.Host + ) + .AddPermission( + MyPermissions.TenantDashboard, + multiTenancySide: MultiTenancySides.Tenant + ); +``` + +This is one of the easiest wins in ABP multi-tenancy. Use it early. + +### Why it matters in practice + +Without side-aware permission definitions: + +- host-only menus can appear in tenant UI +- tenant-only features can leak into host administration +- tests become confusing because behavior differs by login context + +Also remember that usernames can collide across tenants. That is normal in multi-tenant identity models. What matters is the combination of user identity and tenant context. + +## Migrations and Data Seeding Without Regret + +Multi-tenant EF Core migrations are straightforward in theory and messy in real systems if you skip the design phase. + +### Shared database + +This is simplest: + +- one database +- one main migration flow +- host and tenant data usually seeded into the same database with different contexts or `TenantId` semantics + +### Separate or hybrid databases + +Now you need to answer: + +- which context owns which schema +- which migration runs against host DB +- which migration runs against tenant DBs +- when new tenants receive schema updates +- how failed migrations are retried + +### Seeding strategy + +A practical model is: + +- seed host-level data in the host database +- seed tenant defaults when a tenant is created +- perform tenant seeding inside `CurrentTenant.Change(tenantId)` scopes where appropriate + +Example: + +```csharp +using (_currentTenant.Change(tenantId)) +{ + await _dataSeeder.SeedAsync(new DataSeedContext(tenantId)); +} +``` + +That keeps seeding logic tenant-aware and consistent with the rest of the application. + +## Common Pitfalls That Break Multi-Tenancy + +Most ABP multi-tenancy bugs are not framework bugs. They are design mistakes. + +### 1. Tenant-only entity accidentally allows host ownership + +If `TenantId` stays nullable for a strictly tenant-owned entity, host-side records can slip in. That often leads to confusing query behavior and data mixing. + +### 2. Custom SQL bypasses tenant filtering + +ABP filters repository and LINQ queries for `IMultiTenant` entities. Your raw SQL does not magically become safe. Always include tenant scope explicitly when writing custom SQL. + +### 3. Host-only modules end up in tenant databases + +This usually happens when all module mappings are copied into every `DbContext`. Be intentional about which modules are configured where. + +### 4. Resolver strategy is inconsistent + +For example: + +- frontend sends `tenant` +- backend expects `__tenant` +- API gateway injects a header +- auth claims still refer to a different tenant source + +You can spend hours debugging what is really just inconsistent tenant resolution. + +### 5. Subdomain authentication is not fully configured + +Wildcard domains, issuer validation, proxy headers, and cookie domains all need a coherent setup. Subdomain multi-tenancy is elegant, but only after it is fully wired. + +### 6. Shared database performance is ignored too long + +If every large table relies on `TenantId` filters, indexing and query shape matter. This usually becomes painful gradually, then suddenly. + +## When to Use Shared, Separate, or Hybrid + +### Use shared database when + +- you are building a standard SaaS product +- operational simplicity matters most +- tenants are relatively small +- strict physical isolation is not required +- you want the fastest path to production + +### Use separate databases when + +- customers require stronger isolation +- you need tenant-level backup and restore +- data volume varies significantly between tenants +- some tenants need independent scaling or maintenance windows +- compliance requirements push you there + +### Use hybrid when + +- most tenants fit shared infrastructure +- a few enterprise tenants need dedicated storage +- you want to defer cost while preserving an upgrade path +- your team can handle extra migration and operational complexity + +### When NOT to over-engineer it + +Do not start with hybrid just because it sounds flexible. + +If you are early-stage and do not yet have hard isolation requirements, shared database with good tenant discipline is usually the better engineering decision. + +## A Practical Implementation Plan + +If you want a sane rollout path, use this order: + +### Phase 1: Enable and model multi-tenancy + +- enable `AbpMultiTenancyOptions` +- implement `IMultiTenant` on tenant-owned entities +- review aggregate rules around nullable `TenantId` +- define host vs tenant permissions correctly + +### Phase 2: Pick one tenant resolution strategy + +- prefer subdomain or authenticated user claim for web apps +- keep request key resolution for APIs or development +- make frontend and backend tenant key configuration consistent + +### Phase 3: Start with shared database + +- launch with shared DB unless requirements force separation +- add indexes and monitoring early +- verify custom queries are tenant-safe + +### Phase 4: Prepare for separation only where needed + +- isolate `DbContext` boundaries cleanly +- separate host-only module configuration from tenant-only configuration +- design onboarding and migration workflows +- add support for per-tenant connection strings when the business actually needs it + +This path keeps your first release simple without blocking future isolation models. + +## Final Thoughts + +ABP Framework removes a lot of the repetitive work in multi-tenant .NET applications, but it does not remove architectural choices. You still need to decide how tenants are resolved, where data lives, which modules belong to which side, and how strict your isolation really needs to be. + +The best ABP multi-tenancy setups are usually boring in the right places: + +- one clear tenant resolution strategy +- strict entity rules +- explicit host vs tenant boundaries +- a simple default database model +- operational workflows designed before enterprise tenants arrive + +That is what keeps a multi-tenant system maintainable after the demo phase. + +## TL;DR + +- Enable ABP multi-tenancy explicitly, then model tenant-owned entities with `IMultiTenant` and disciplined `TenantId` rules. +- Start with a shared database unless you already need stronger isolation, compliance, or tenant-level scaling. +- Use `ICurrentTenant` and a clear tenant resolution strategy to keep application logic predictable. +- For separate or hybrid databases, control `DbContext` boundaries, module mappings, migrations, and onboarding workflows carefully. +- Define permissions with `MultiTenancySides` so host and tenant experiences stay clean and secure. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png new file mode 100644 index 0000000000..b0e2b14a68 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png new file mode 100644 index 0000000000..b0be708000 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png new file mode 100644 index 0000000000..0483d13091 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png new file mode 100644 index 0000000000..6e12a4f4d8 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md new file mode 100644 index 0000000000..be87f21390 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md @@ -0,0 +1,607 @@ +Background jobs are one of those features that look simple at first and become operationally important very quickly. Sending emails, generating reports, syncing with third-party APIs, cleaning expired data, and processing imports should not block your HTTP requests. + +ABP gives you a clean abstraction for background jobs, and Hangfire gives you a production-friendly execution engine with persistence, retries, queues, and a dashboard. The useful part is that you can keep your application code aligned with ABP’s abstractions while swapping in Hangfire as the actual runner. + +In this article, I’ll walk through how to implement background jobs with ABP and Hangfire, when to use each piece, and where teams usually get tripped up. + +## Why use Hangfire instead of ABP's default background job manager? + +ABP already has a built-in background job system, and it is perfectly fine for simple cases. But it helps to understand what you are trading. + +### ABP default job manager + +By default, ABP background jobs are: + +- Enqueued through `IBackgroundJobManager` +- Executed in-process +- FIFO-oriented +- Single-threaded by default +- Retried automatically with increasing delays +- Stored through ABP's background job store + +This is good when: + +- Your app is small or moderate in workload +- You want minimal setup +- You do not need a dashboard +- You do not need advanced queue management + +### Hangfire integration + +When you add `Volo.Abp.BackgroundJobs.HangFire`, ABP can keep the same `IBackgroundJobManager` programming model, but Hangfire becomes the execution backend. + +That gives you: + +- Durable job storage +- Better operational visibility through the Hangfire dashboard +- Multiple worker servers +- Queue-based processing +- Recurring jobs and scheduling features +- A mature retry and monitoring model + +In practice, Hangfire is the better choice when background processing is part of the actual system design, not just a convenience. + +### Quick comparison + +Use ABP default when: + +- You want the simplest possible setup +- Background jobs are low volume +- A single app instance is enough +- You do not need a dashboard or queue controls + +Use Hangfire when: + +- You need reliability across restarts +- You run multiple instances +- You need recurring jobs or queue isolation +- You want to inspect failures and retries visually +- Background processing is operationally important + + + +![Generated illustration](inline-1.png) + +## Defining a background job in ABP + +The nice part of ABP is that your job code does not need to know about Hangfire. + +Start with a job arguments class: + +```csharp +public class EmailSendingArgs +{ + public string To { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; +} +``` + +Then create the job itself: + +```csharp +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; + +public class EmailSendingJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IEmailSender _emailSender; + + public EmailSendingJob(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public override async Task ExecuteAsync(EmailSendingArgs args) + { + await _emailSender.SendAsync( + args.To, + args.Subject, + args.Body + ); + } +} +``` + +This job works with ABP’s job abstraction regardless of whether the runtime backend is the default implementation or Hangfire. + +To enqueue it: + +```csharp +using System; +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; + +public class NotificationAppService : ApplicationService +{ + private readonly IBackgroundJobManager _backgroundJobManager; + + public NotificationAppService(IBackgroundJobManager backgroundJobManager) + { + _backgroundJobManager = backgroundJobManager; + } + + public async Task QueueWelcomeEmailAsync(string email) + { + await _backgroundJobManager.EnqueueAsync( + new EmailSendingArgs + { + To = email, + Subject = "Welcome", + Body = "Your account is ready." + }, + priority: BackgroundJobPriority.Normal, + delay: TimeSpan.FromMinutes(1) + ); + } +} +``` + +A few practical notes: + +- `delay` is useful for short deferrals and back-office workflows. +- `priority` is part of ABP’s abstraction. How it maps operationally depends on the provider. +- Keep argument objects small and serializable. +- Do not pass EF entities or large object graphs into jobs. + +## Setting up Hangfire in an ABP application + +To integrate Hangfire, install the package and wire it into your ABP module. + +### 1. Add the package + +Using ABP CLI: + +```bash +abp add-package Volo.Abp.BackgroundJobs.HangFire +``` + +Or with NuGet: + +```bash +Install-Package Volo.Abp.BackgroundJobs.HangFire +``` + +### 2. Add the module dependency + +Typically this goes into your host module, such as `HttpApiHostModule`: + +```csharp +using Volo.Abp.BackgroundJobs.Hangfire; + +[DependsOn( + typeof(AbpBackgroundJobsHangfireModule) +)] +public class MyProjectHttpApiHostModule : AbpModule +{ +} +``` + +### 3. Configure Hangfire services + +In `ConfigureServices`: + +```csharp +using Hangfire; +using Microsoft.Extensions.Configuration; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + var configuration = context.Services.GetConfiguration(); + + context.Services.AddHangfire(config => + { + config.UseSqlServerStorage( + configuration.GetConnectionString("Default") + ); + }); +} +``` + +If you use PostgreSQL, Redis, or another Hangfire storage provider, configure that instead. The storage decision matters because all servers that process jobs must share the same backing store. + +### 4. Enable the Hangfire dashboard + +In `OnApplicationInitialization`: + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +public override void OnApplicationInitialization(ApplicationInitializationContext context) +{ + var app = context.GetApplicationBuilder(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseAbpHangfireDashboard(); +} +``` + +The dashboard middleware should be added after authentication and authorization middleware. + +At this point, jobs enqueued through `IBackgroundJobManager` should use Hangfire as long as the integration is correctly activated. + + + +![Generated illustration](inline-2.png) + +## End-to-end example: offloading a report export + +A common use case is exporting a report that may take several seconds or minutes. + +Instead of generating the file during the HTTP request: + +- Save an export request record +- Enqueue a background job +- Let the job generate the file +- Notify the user when it is ready + +### Arguments + +```csharp +public class ReportExportJobArgs +{ + public Guid ExportRequestId { get; set; } + public Guid UserId { get; set; } +} +``` + +### Job implementation + +```csharp +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +public class ReportExportJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IReportExportAppService _reportExportAppService; + + public ReportExportJob(IReportExportAppService reportExportAppService) + { + _reportExportAppService = reportExportAppService; + } + + public override async Task ExecuteAsync(ReportExportJobArgs args) + { + await _reportExportAppService.GenerateAsync(args.ExportRequestId, args.UserId); + } +} +``` + +### Enqueue from app service + +```csharp +public async Task RequestExportAsync() +{ + var exportRequestId = GuidGenerator.Create(); + + await _backgroundJobManager.EnqueueAsync( + new ReportExportJobArgs + { + ExportRequestId = exportRequestId, + UserId = CurrentUser.GetId() + } + ); + + return exportRequestId; +} +``` + +This pattern scales much better than holding open a web request while doing CPU-heavy or IO-heavy work. + +## Retries, exceptions, and cancellation + +ABP and Hangfire both care about retries, but you should still design jobs carefully. + +### How ABP behaves + +With ABP background jobs: + +- Unhandled exceptions trigger retries +- Retry intervals increase over time +- Default implementation uses exponential backoff behavior +- Jobs may eventually time out or be marked abandoned depending on configuration + +### What this means for your code + +A job should be: + +- Idempotent whenever possible +- Safe to retry +- Explicit about transient vs permanent failures + +For example, sending the same payment capture twice is dangerous. Sending the same “your report is ready” notification twice is annoying but manageable. Design around the difference. + +### Cancellation handling + +If you use `ICancellationTokenProvider`, be deliberate. If cancellation means “try again later,” let the exception flow. If cancellation means “stop and do not retry,” return gracefully. + +Example: + +```csharp +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.Threading; + +public class DataSyncJob : AsyncBackgroundJob +{ + private readonly ICancellationTokenProvider _cancellationTokenProvider; + + public DataSyncJob(ICancellationTokenProvider cancellationTokenProvider) + { + _cancellationTokenProvider = cancellationTokenProvider; + } + + public override async Task ExecuteAsync(int args) + { + var cancellationToken = _cancellationTokenProvider.Token; + + cancellationToken.ThrowIfCancellationRequested(); + + await Task.Delay(500, cancellationToken); + } +} +``` + +### Practical guidance + +- Keep jobs short and composable +- Persist progress if the job is large +- Use domain/application services inside jobs instead of putting business logic directly into the job class +- Log enough context to diagnose retries and failures + +## Recurring jobs and periodic work + +Not every background task is a one-time job. + +There are two different patterns: + +- Background jobs: one-off, delayed, or fire-and-forget work +- Background workers: periodic or recurring work + +In ABP, recurring processing is usually modeled with background workers rather than standard background jobs. + +### When to use a worker instead of a job + +Use a worker when you need: + +- A scheduled cleanup task +- A recurring sync with another system +- Polling behavior +- A cron-like schedule + +### Hangfire-backed recurring worker + +With Hangfire integration, you can derive from `HangfireBackgroundWorkerBase` and provide a cron expression. + +```csharp +using System.Threading.Tasks; +using Volo.Abp.BackgroundWorkers.Hangfire; + +public class ExpiredSessionsCleanupWorker : HangfireBackgroundWorkerBase +{ + private readonly ISessionCleanupService _sessionCleanupService; + + public ExpiredSessionsCleanupWorker(ISessionCleanupService sessionCleanupService) + { + _sessionCleanupService = sessionCleanupService; + + RecurringJobId = "expired-sessions-cleanup"; + CronExpression = "0 * * * *"; + } + + public override async Task DoWorkAsync() + { + await _sessionCleanupService.CleanupAsync(); + } +} +``` + +A few details matter here: + +- `RecurringJobId` should be stable and unique. +- `CronExpression` controls the schedule. +- Hangfire recurring scheduling is minute-based in normal use, so do not expect second-level precision. + +### Background jobs vs background workers + +A simple rule: + +- If a user action creates work to do later, use a background job. +- If the system itself needs to run something on a schedule, use a background worker. + + + +![Generated illustration](inline-3.png) + +## Queue isolation and scaling across multiple instances + +Once you run more than one application instance, background processing becomes an architecture concern rather than a coding detail. + +### Shared storage is required + +If multiple nodes are going to process Hangfire jobs, they must share the same Hangfire storage. + +Typical setups include: + +- Multiple web instances + one shared SQL Server storage +- Web instances enqueueing jobs + dedicated worker instances processing them +- Separate deployment slots or services sharing the same Hangfire backend + +### Disabling execution on some nodes + +Sometimes you want your web app to enqueue jobs but not execute them. + +ABP supports this: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.BackgroundJobs; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + Configure(options => + { + options.IsJobExecutionEnabled = false; + }); +} +``` + +This is useful when: + +- You run dedicated worker processes +- You want predictable resource allocation +- You do not want front-end nodes competing for background work + +### Queue prefixing in clustered environments + +If multiple applications share the same Hangfire storage, isolate queues intentionally. + +For Hangfire integration in ABP, use `AbpHangfireOptions.DefaultQueuePrefix` to avoid queue collisions between different applications or environments. + +That matters more than teams expect. Without isolation, staging and production can end up looking at the same queues if storage is misconfigured. + +### Queue routing + +Hangfire supports multiple queues, and ABP’s Hangfire integration can route jobs based on conventions or attributes. + +In some scenarios, you may want specific jobs to go to specific queues, for example: + +- `emails` +- `exports` +- `integration` +- `critical` + +This is especially helpful when one queue can become noisy and starve more important work. + + + +![Generated illustration](inline-4.png) + +## Securing the Hangfire dashboard + +The Hangfire dashboard is extremely useful, but it is also an operations surface. Do not expose it casually. + +ABP provides authorization support for the dashboard via `AbpHangfireAuthorizationFilter`. + +A typical setup is to: + +- Require authentication +- Restrict by permission or role +- Optionally consider tenant-specific access rules + +Example: + +```csharp +app.UseAbpHangfireDashboard("/hangfire", new DashboardOptions +{ + Authorization = new[] + { + new AbpHangfireAuthorizationFilter(requiredPermissionName: "Administration.Hangfire") + } +}); +``` + +Even if your app is internal, treat the dashboard like an admin area: + +- Put it behind authorization +- Avoid exposing it publicly without network restrictions +- Audit who can retry or inspect jobs + +## Common pitfalls and behavior differences + +This is the part that usually saves the most time. + +### 1. Jobs still land in `AbpBackgroundJob` instead of Hangfire + +If Hangfire is not properly activated, ABP may continue using its native background job storage and you will see jobs in the `AbpBackgroundJob` table instead of Hangfire storage. + +Check these first: + +- The `Volo.Abp.BackgroundJobs.HangFire` package is installed +- `AbpBackgroundJobsHangfireModule` is added in `[DependsOn]` +- `AddHangfire(...)` is configured correctly +- The application starts with the expected module graph + +If any of those are missing, you may think you are using Hangfire while you are actually still on the default provider. + +### 2. Passing large or complex objects into jobs + +Keep job args small. Prefer identifiers over rich objects. + +Good: + +- `OrderId` +- `UserId` +- `ExportRequestId` + +Bad: + +- Full EF entities +- Large DTO graphs +- Objects with lazy-loading behavior or runtime-only state + +### 3. Non-idempotent job logic + +Retries will happen. If running the same job twice can corrupt data, redesign the workflow. + +Common fixes: + +- Add a processed flag +- Use unique constraints where appropriate +- Check prior execution status before applying side effects +- Make external calls with idempotency keys when supported + +### 4. Assuming recurring jobs run with exact timing + +Hangfire recurring jobs are cron-based and typically evaluated on minute boundaries. That is fine for most scheduled business work, but it is not a real-time scheduler. + +### 5. Ignoring queue isolation in multi-app environments + +If several apps share one Hangfire store, queue naming and prefixing must be explicit. Otherwise, one application can accidentally process another application's jobs. + +## When to use / When NOT to use ABP + Hangfire + +### Use ABP + Hangfire when + +- You want ABP-friendly job abstractions with a stronger execution backend +- You need operational visibility and retry inspection +- You run multiple instances or worker nodes +- You have recurring background tasks +- Your jobs are part of business-critical workflows + +### Do NOT use it when + +- The work must complete synchronously before responding to the user +- The task is so trivial that plain in-memory processing is enough +- You need event streaming rather than job scheduling +- You need ultra-low-latency real-time processing with very tight timing guarantees + +For many line-of-business systems, ABP + Hangfire hits a very practical middle ground: easy enough to implement, strong enough to operate. + +## A production-minded implementation checklist + +Before shipping, verify these points: + +- Jobs are enqueued through `IBackgroundJobManager` unless you explicitly need Hangfire-specific APIs +- Job arguments are small and serializable +- Job logic is retry-safe and preferably idempotent +- Hangfire storage is shared by all processing nodes +- Dashboard access is restricted +- Queue names or prefixes are isolated per app/environment +- Long-running jobs are split into manageable steps where possible +- You know which nodes execute jobs and which only enqueue them + +## TL;DR + +- ABP gives you a clean background job abstraction; Hangfire gives you the production-grade execution engine. +- Keep using `IBackgroundJobManager` for most jobs so your application code stays provider-independent. +- Use background jobs for one-off work and Hangfire-backed background workers for recurring tasks. +- In multi-instance deployments, shared storage, queue isolation, and dashboard security are not optional. +- If jobs still go to `AbpBackgroundJob`, your Hangfire integration is probably not fully activated. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png new file mode 100644 index 0000000000..933dd3399c Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png new file mode 100644 index 0000000000..62cec256e6 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png new file mode 100644 index 0000000000..1d04df4a76 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png new file mode 100644 index 0000000000..a22d1c5d24 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png new file mode 100644 index 0000000000..04444db117 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png differ diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md b/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md new file mode 100644 index 0000000000..b3672e120c --- /dev/null +++ b/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md @@ -0,0 +1,41 @@ +# Deep Dive on ABP AI Agent: The Complete Series + +ABP Studio is a development platform built around ABP Framework. With the introduction of **ABP AI Coding Agent**, it became something more: a platform where an AI agent works inside the same environment you already use to build, run, monitor, and ship ABP solutions. + +![ABP Studio with ABP AI Agent](abp-studio-new-design.png) + +General-purpose AI coding tools are excellent for horizontal, file-shaped work. They read source files, edit them, and run shell commands. But ABP solutions are **system-shaped**, not just file-shaped. A typical ABP solution is split across multiple modules and layers with strict dependency rules, composed of many runnable units (HTTP services, gateways, identity servers, background workers, Docker containers), and built on a strong set of conventions: aggregate roots, repositories, application services, DTOs, permissions, localization, event bus, distributed cache, and background jobs. + +A generic agent has none of that vocabulary. It does not know what a module is, which project is the Domain layer, or that an `ApplicationService` should not depend on a `DbContext` directly. It cannot start your microservices, gateway, and auth server together. It cannot tell you that the latest edit caused a runtime exception in the Identity service, because it has no concept of a running application. + +ABP AI Coding Agent was built to close exactly that gap. The agent is born inside a platform that already understands modules, run profiles, builds, migrations, proxies, Docker containers, monitoring, and Git workflows, and it uses every one of them. + +We wrote a **nine-part deep dive series** to explain how each part of this system works, not as a product tour, but as a practical look at the decisions, controls, and architecture behind the experience. + +## The Series + +1. **[Agent, Plan and Ask Modes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-1-agent-plan-and-ask-modes-62wteg9t)** — The three interaction modes that control how much action the agent is allowed to take: **Ask** for understanding (read-only), **Plan** for designing the approach before editing, and **Agent** for full implementation with builds, tools, and iteration. + +2. **[Supported AI Models + Usage Recommendations](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-2-supported-ai-models-in-abp-3krbc7yc)** — How ABP Studio separates models by role (main, research, browser, text processor, Git review) and why treating model selection as a practical decision based on the task leads to a better balance of capability, speed, and cost. + +3. **[Rules, Skills and Lessons](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-3-rules-skills-and-lessons-ai6kxubt)** — The three mechanisms that give the agent solution-specific memory: **Rules** (always-on conventions), **Skills** (on-demand procedures), and **Lessons** (corrections the agent records and carries forward). + +4. **[Integrated ABP Studio Tools](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-4-integrated-abp-studio-tools-be2xa2om)** — The tools that connect the agent to ABP Studio's runtime environment: monitoring (exceptions, logs, requests), applications, containers, tasks, and build actions, with a practical walkthrough showing the difference between debugging with and without tool access. + +5. **[MCP (Model Context Protocol)](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-5-mcp-model-context-protocol-trb9o4ev)** — How MCP extends the agent beyond the solution boundary to reach external systems like Prometheus, SEO analyzers, or documentation services, with per-tool enable/disable controls and stdio/HTTP server support. + +6. **[ABP Studio Git Integration](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-6-abp-studio-git-integration-09tr41ec)** — The full Git loop inside ABP Studio: branching, diffing, AI-generated commit messages, AI code review on staged changes, GitHub issue context for starting tasks, and pull request feedback for addressing reviewer comments. + +7. **[Scopes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-7-scopes-tfqtkdzu)** — How AI Scopes restrict the agent's working area to specific modules, packages, or solution areas, reducing unrelated exploration, preventing accidental edits, and making diffs easier to review. + +8. **[Parallel Agent Execution](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-8-parallel-agent-execution-1o0cik6g)** — Running multiple agent sessions at the same time, each with its own mode, model, scope, and workflow, plus read-only research subagents that fan out inside a single session. + +9. **[Workflows](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-9-workflows-7jo1adb1)** — Repeatable before/after steps that wrap agent runs: start containers, build packages, add migrations, generate proxies, restart applications, and run validation tasks, so the agent focuses on the code change while the platform handles the deterministic parts. + +## The Bigger Picture + +Each article focuses on one feature, but the real value comes from how they work together. + +Modes decide how much action the agent takes. Models decide which brain handles the work. Rules, Skills, and Lessons shape what the agent knows. Tools and MCP extend what it can reach. Scopes define where it can work. Workflows define what happens around the work. Git Integration makes the result reviewable and recoverable. Parallel Execution lets multiple tasks move forward at the same time. + +That is the ABP AI Coding Agent experience: **not a single AI button, but a set of controls built into a platform that already understands how ABP solutions are developed, run, and maintained.** diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png b/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png new file mode 100644 index 0000000000..d74add4b2d Binary files /dev/null and b/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png differ diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png b/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png new file mode 100644 index 0000000000..9d0d5f5987 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-24-Meet-ABP-at-WeAreDevelopers-World-Congress-2026/post.md b/docs/en/Community-Articles/2026-06-24-Meet-ABP-at-WeAreDevelopers-World-Congress-2026/post.md new file mode 100644 index 0000000000..cd816964d0 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-24-Meet-ABP-at-WeAreDevelopers-World-Congress-2026/post.md @@ -0,0 +1,22 @@ +We are happy to announce that the ABP team will be heading to Berlin for WeAreDevelopers World Congress 2026, one of the largest gatherings of software developers and technology professionals in Europe. + +Taking place from **8-10 July 2026**, the event brings together thousands of developers, architects, engineering leaders, startups, and technology companies to explore the latest trends, tools, and ideas shaping the future of software development. + +We're excited to be part of this global community once again and look forward to connecting with developers from around the world. + +## **Visit Us at the Event\!** + +If you're attending WeAreDevelopers World Congress, make sure to stop by **Hall A, Booth A-41** and meet the ABP team. + +We'll be showcasing the latest developments across the ABP ecosystem, including ABP Framework, ABP Studio, and our newest AI-powered development capabilities. Whether you're building enterprise applications, modernizing existing systems, or exploring new approaches to software development, we'd love to hear about your projects and challenges. + +Our team will be available throughout the event for product demos, technical discussions, and conversations about modern .NET development, modular architecture, microservices, and AI-assisted software development. + +## **See You in Berlin** + +Nothing replaces meeting developers face-to-face\! + +Whether you're already using ABP, evaluating it for a future project, or simply curious about what we're building, we'd be happy to meet you. + +See you in **Hall A, Booth A-41** at WeAreDevelopers World Congress 2026\! + diff --git a/docs/en/Community-Articles/2026-06-25-ABP-Bootcamp-AI-Assisted-Application-Development-with-ABP/post.md b/docs/en/Community-Articles/2026-06-25-ABP-Bootcamp-AI-Assisted-Application-Development-with-ABP/post.md new file mode 100644 index 0000000000..912ab75d30 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-25-ABP-Bootcamp-AI-Assisted-Application-Development-with-ABP/post.md @@ -0,0 +1,86 @@ +AI is changing how software is built. Today, developers can generate features, services, tests, and even entire applications in minutes. Tasks that once took hours can now be completed with a single prompt. + +But speed is no longer the biggest challenge.Reliability is. + +AI generates probabilistic answers. Production software requires deterministic behavior. When developers receive different implementations for the same problem, applications become harder to maintain, harder to scale, and more difficult to evolve over time. + +Building software with AI is a lot like constructing a building with power tools.The tools make construction faster. They do not make poor foundations safer. + +In fact, they allow mistakes to spread much faster. + +The architectural decisions made during the first few months of a project often determine its long-term success. Security, modularity, authorization, maintainability, and development conventions become part of the foundation that everything else depends on. + +This is where ABP comes in. + +For more than a decade, we've been helping development teams build enterprise-grade .NET applications on solid architectural foundations. Today, companies around the world continue to build and maintain production systems with ABP, even as AI becomes an increasingly important part of the software development process. + +We didn't start thinking about AI yesterday. + +We've integrated AI into our own development workflows, evolved our startup templates, created AI-specific development rules, and built ABP Studio AI Agent to help developers work more effectively with ABP-based applications. + +To help developers learn these practices, we're excited to announce our latest bootcamp: + +## **AI-Assisted Application Development with ABP** + +This live, instructor-led bootcamp is not about generating code faster. + +It's about learning how to build applications faster while maintaining architectural consistency, code quality, and long-term maintainability. + +Over three days of hands-on sessions, you'll learn practical AI-assisted engineering workflows using ABP Studio AI Agent and discover how to combine AI productivity with proven software engineering practices. + +## **Bootcamp Details** + +**Dates:** August 25-27, 2026 +**Time:** 17:00-19:00 UTC each day +**Duration:** 6 hours total +**Format:** Live online sessions via Google Meet +**Price:** $399 (discounted from $799) + +*\*Participants who do not already have access to ABP Studio AI Agent will receive **complimentary trial access** **for the duration of the bootcamp**. Additional AI credits will be provided when needed.* + +## **What You'll Learn** + +Throughout the bootcamp, you'll explore real-world AI-assisted software development workflows, including: + +* Using AI to accelerate application development with ABP +* Working effectively with the ABP Studio AI Agent +* Generating features, services, and application components faster +* Understanding how AI can assist with implementation, debugging, and code exploration +* Applying AI-assisted engineering practices in real ABP projects +* Combining developer expertise with AI capabilities to improve productivity + +The focus will be on practical examples, live demonstrations, and hands-on exercises that you can immediately apply in your own projects. + +## **Why Learn From the ABP Team?** + +Many AI development courses teach how to generate code. + +This bootcamp focuses on something more important: how to generate code that remains maintainable, scalable, and consistent as your application grows. + +The ABP team has spent more than 10 years building and evolving one of the most widely used application frameworks in the .NET ecosystem. + +We've worked closely with development teams across industries, helped companies build production systems, and recently invested heavily in AI-powered development tools such as ABP Studio AI Agent. + +The lessons shared in this bootcamp come directly from our own experience building software with AI, not from theoretical examples or isolated experiments. + +You'll learn the same principles, workflows, and practices we use to combine AI-assisted development with real-world software engineering. + +## **Who It's For** + +This bootcamp is ideal for: + +* ABP developers who want to increase productivity with AI +* Software developers interested in AI-assisted software development +* Teams exploring how AI can improve their development workflows +* Technical leaders evaluating AI-powered development practices +* Anyone looking to stay ahead as software engineering continues to evolve + +## **Reserve Your Spot** + +AI-assisted software development is quickly becoming an essential skill for modern development teams. + +This bootcamp is designed to help you understand not only how AI tools work, but how to use them effectively within a real-world application development framework. + +Join us and learn how to build applications faster with ABP and AI. + +Registration is now open, fill the form: [https://docs.google.com/forms/d/e/1FAIpQLSdREtytTXXEfnOrwuMeTnXs7O10LcVXo-dlyhUNVTX\_dMZriw/viewform?usp=publish-editor](https://docs.google.com/forms/d/e/1FAIpQLSdREtytTXXEfnOrwuMeTnXs7O10LcVXo-dlyhUNVTX_dMZriw/viewform?usp=publish-editor) diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/Post.md b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/Post.md new file mode 100644 index 0000000000..d41f5dadba --- /dev/null +++ b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/Post.md @@ -0,0 +1,226 @@ + + + + +# My Speaker's View of CONVEX Summit 2026 Madrid + +Hi, I'm here for another conference review. This time I attended [Convex Summit 2026 Developer](https://www.convexsummit.com/) conference, held in the capital city of Spain, Madrid. It was my first talk in Madrid. The organizer [Plain Concepts](https://www.plainconcepts.com/) arranged a 2 days conf with 3 parallel sessions. 2 of the sessions were in Spanish and one of them was English. The conf dates were 17, 18 June 2026. It was in a very big cinema. In Lithuania I also spoke in a cinema, I guess it's better to arrange a conf in a cinema because of asthenosphere, acoustic and state visibility for visitors. + +There is a small moment before every conference talk when the room becomes quiet, the slides are ready, and you suddenly remember why the topic matters. For me, that moment happened at Convex. My topic was “Turn any database into a conversational reporting engine.” I can admit it was a cool talk. But I was also there as a listener, a software architect, and someone trying to understand where enterprise AI is really going after the first wave of demos and experiments. CONVEX was interesting because it brought software development, architecture, and AI into the same conversation. These topics are often discussed separately, but in real companies they are tightly connected. AI features do not live in isolation. They live inside applications, databases, identity systems, permission models, workflows, dashboards, and business expectations. That was the real theme I felt throughout the event. + +## Speaking at CONVEX + +![My CONVEX 2026 speaker badge before the sessions started](images/IMG_20927.jpg) + +My talk focused on a question that is becoming more important for enterprise software teams: + +> What if users could ask their business data questions in natural language and receive safe, useful, validated answers? + +I started my slide with **Sobrino de Botin restaurant**. It's the oldest restaurant in the world according to the Guinness Book of World Records. Sobrino de Botín **has been open since 1725**. The taste never changed, the restaurant **is keeping the same classic taste with the same cooking techniques** but in the age of AI; we developers should **use new techniques for reporting**. We are not running a restaurant and this is how the development works. Adjust to the tech standards, new techniques and recent practices. + +![image-20260624200005106](images/the-oldest-restaurant-world.png) + +And in one part of my talk, I need to ask questions like “Show me the top customers by revenue this quarter.” So the LLM answers in my language. And I learnt some Spanish sentences before the conf. Even for this I used AI. So I translated 10 different English sentences to Spanish. Later I asked ChatGPT: "*Can you score my pronunciation for these sentences.*". ChatGPT gave the highest ratings to 3 of my sentences' pronunciations. And I talked those during my interview with my reporting AI tool. And that was fantastic eye-catching moment of my talk. + +![Some photos from my talk 1](images/my-pictures-1.jpg) + +*My session focused on building conversational reporting experiences without giving up control, validation, or security.* + +I asked the attendees how many of you have written SQL and created reporting screens, and %80 of people wrote SQL and created reporting UIs. And the same number of people also use .NET. + +![Some photos from my talk 2](images/my-pictures-2.jpg) + +> **AI can make data more accessible, but architecture must define the boundaries.** + +--- + +## What I Learned From the Other Sessions + +There was a track with completely English sessions. One of the reasons I enjoyed Convex was that the English sessions did not treat AI as magic. + +The strongest message I heard across different sessions was this: + +> **AI is becoming part of real systems and real systems have constraints.** + +And I can see, software development is rapidly evolving with AI agentic tools. + +> **We cannot say development is dead, but hand-made development is dead.** + +From now on, we'll use our time less on typing and more on thinking about features, user experiences and robust infrastructure. + +**In the age of AI, writing code by hand is the software equivalent of sending a fax to prove commitment.** + +![hand-made-coding](images/hand-made-coding.png) + +For a while, many AI discussions were focused on what AI could generate: code, tests, text, SQL, documentation, designs. +At CONVEX, the more interesting question was: + +**What happens after AI generates something?** + +- Who validates it? +- Who owns the decision? +- Can we say I don't know to a question about how that works!? +- If something blows up or happens a data leakage, who is accountable for it? +- How does it fit into the architecture? +- How do we make it useful for the business instead of impressive for five minutes? + +### Architecture is also a people problem + +One session that stayed with me used the idea of the **Prisoner’s Dilemma** to describe the tension between product priorities and architectural work. The slide I captured showed a collaboration payoff matrix: when product management optimizes only for short-term business value, architecture can suffer; when architects focus only on architecture, market opportunity can be lost. The win-win scenario appears when both sides optimize for business value and architecture together. + +![A collaboration payoff matrix from an architecture session](images/IMG_20898.jpg) + +*The architecture sessions connected technical decisions with incentives, collaboration, and long-term system health.* + +Another slide suggested practical ways forward: learn the business, understand the competition, avoid “big bang” changes, work incrementally, create options, make trade-off decisions with the business and **become a business value creator** rather than someone who only responds to requests. + +I liked that message. Architecture is much more valuable when it helps the business create options, not when it only explains why something is risky. + +--- + +### Power, knowledge and decision-making + +Another interesting thread was about power in organizations. One talk referenced **Power-With**, I found it useful because it shifts the conversation away from control and toward collaboration. The slides connected power with access to knowledge, authority, charisma and the way decisions move through an organization. + +![A session slide discussing Power-With and organizational dynamics](images/IMG_20914.jpg) + +*Some of the most interesting moments connected architecture with people, incentives, and organizational reality.* + +There are 2 powers: + +1. **Power-Over**: Conquer other people's mind, control, force and order for a work output. +2. **Power-With**: Co-operate with others, use everyone's power into a big power, move together for a work output. + +This may sound less technical than a database, a framework, or a deployment pipeline. But in practice, many technical decisions fail or succeed because of organizational dynamics. A clean architecture can still fail if teams are not aligned. + +> A promising AI feature can still fail if no one trusts the output. + +### So Let's Think What's Charisma at Work + +- **What's charisma actually?** Let me tell you my opinion; charisma is experience, wisdom, grace, listening more and speaking less, way of looking to life, trustability (reliability), being a role model and an inspiration to others. +- **Why charisma is important?** It makes your words to be listened by other people **naturally** (without any need of dictation). + +One slide quoted the idea that knowledge workers “think for a living.” Another referenced Peter Naur’s **Programming as Theory Building**, where program text and documentation are not always enough to carry the most important design ideas. That felt very relevant in the age of AI-generated code. If code becomes easier to produce, shared understanding becomes even more valuable. + +As a summary; AI can often understand the architecture from the code. What it usually can't know reliably is **why** + +- the architecture ended up that way; the design decisions +- trade-offs +- historical context +- assumptions that exist mostly in the team's shared understanding rather than in the code itself. + +--- + +### What AI Agents can do and cannot do?! + +One of the most thought-provoking slides at the conf explored the current boundaries of AI agents. Instead of asking whether AI will replace humans, it asked a more interesting question: **What can AI agents actually do today, what can't they do and which limitations might disappear over time?** + +The first column listed the things AI agents already do well. They can execute tasks reliably without getting tired, maintain context across long-running work, report progress, detect problems, follow established processes, generate alternatives, document their work, and scale by running thousands of instances simultaneously. In short, AI excels at **execution**. + +The second column focused on capabilities that are fundamentally human today. AI can perform the role of a manager, mentor, or teammate, but it cannot truly *be* one. It cannot be held accountable for its decisions, earn trust through years of shared experience, genuinely care about an outcome, feel the weight of failure, or belong to a team. It can disagree or refuse a request, but not from genuine conviction or personal values. These qualities come from human relationships, responsibility, and lived experience, not from generating the next token. + +The third column added an important nuance. It wasn't titled "Impossible," but rather "Cannot, but maybe will one day." Some capabilities are already beginning to emerge. Multi-agent systems can delegate work to other agents. Better memory and continuity may allow AI to build trust over time. Multimodal models are becoming better at reading context, and reinforcement learning is an early form of learning from mistakes. The point wasn't that these problems are solved, but that some of today's limitations may become tomorrow's capabilities. + +The overall message was refreshingly balanced. AI agents should not be viewed as human employees, nor should they be underestimated. They are exceptional at executing work at scale, but organizations are built on more than execution. Trust, accountability, judgment, conviction, and belonging remain deeply human qualities, for now. + + + +![A slide comparing what AI agents can and cannot do](images/IMG_20935.jpg) + +*The AI agent discussion was interesting because it did not only focus on automation; it also highlighted accountability, trust, judgment, and team dynamics.* + +That is a healthy way to talk about AI. Not “*AI will replace everything*,” and not “*AI is useless.*” + +> **The real question is where AI can help a team and where humans still need to own the decision.** + +### Better decisions need better records + +I also followed sessions about architecture principles and decision records. One slide explained principles as priorities, beliefs, guardrails and a way to connect requirements to architectural decisions. Another used a simple architectural decision example: **Data Store per Service**, where each service owns its data and other services access it through APIs or events instead of direct database queries. + +![A slide about architecture principles and decisions](images/IMG_20952.jpg) + +*Architecture principles were presented as guardrails that connect requirements, trade-offs, and decisions.* + +Let me first define what's ADR:.. An **ADR (Architecture Decision Record)** is a short document that explains **an important technical decision, why it was made, and what the consequences are**. See the [Microsoft Document about ADR](https://learn.microsoft.com/en-us/azure/well-architected/architect-role/architecture-decision-record). + +> **Code tells you *what* the system does. An ADR tells you *why* it was designed that way.** + +This connected nicely with another slide about ADRs. A minimal ADR, based on Michael Nygard’s format, includes a name, status, context, decision, and consequences. A more comprehensive ADR can also include related requirements, assumptions, constraints, options, reasoning and trade-offs. + +![A slide explaining the minimal ADR structure](images/IMG_20960.jpg) + +*The ADR discussions were a reminder that good architecture is not only about making decisions, but also about preserving the reasoning behind them.* + +For .NET teams, these ideas are practical. AI can sit on top of strong foundations around backend services, identity, data access, cloud integration, and enterprise applications, but it should not bypass them. + +If anything, AI makes good engineering discipline more important. + +## The Conference Experience + +The venue, Kinépolis Ciudad de la Imagen in Madrid, gave the conference a different feeling from a typical hotel-based event. The rooms, stage, and screens made the sessions feel cinematic. Outside the session rooms, the networking areas were active throughout the day. People were not only exchanging LinkedIn profiles; they were continuing the technical debates from the talks. + +![Participants networking at CONVEX Summit 2026](images/convex-ambiance.jpg) + +*The networking areas were busy between sessions, creating space for conversations beyond the formal agenda.* + +I met people from my country as well from Bosch and Aselsan companies...For me, the most valuable conversations happened after the talk. I made new friends and learn what other people do. + +## My Key Takeaways + +1. **AI features need data boundaries.** The more natural the interface becomes, the more important permissions, context, and allowed actions become. +2. **Natural language is becoming a product interface.** Users increasingly want answers, not get lost in the UI and not navigation paths. +3. **Architecture is becoming more important, not less.** AI can accelerate delivery, but it cannot remove product constraints, security requirements, or organizational complexity. + + > When the calculator was first invented, they didn't think problem solving finished with this invention; people spent more time solving problems rather than calculating.... +4. **Decisions need memory.** Principles, ADRs, trade-offs and exceptions help teams preserve reasoning. +5. **Conferences still matter.** A hallway discussion after a session can sometimes teach you more than a full article or video. It's **a way of motivation**, a way of socializing for developers. You see what others do, you discuss with them, you know your customers, and you know where you're at in development. + +🖼 All photos of Convex Summit 2026 are available 👉 https://www.flickr.com/photos/204742998@N04/albums/72177720334500733/with/55369833471 + +## My Cultural Visits + +I visited Toledo and Madrid's most important tourist attractions and museums. Now I know way more than I knew before about Spanish culture and lifestyle. But this is the most impressive moment for me. As you may know I'm Turkish. My grand grandfathers were Ottomans coming from Mongolia to Anatolia. In 1571 the Ottomans were in a war with Spain, Genoa, Malta and Italy. The battle was in the sea near Greece. It's called Sea Battle of Lepanto. In the pictures below, you can see the Ottoman's highest-level sea commander's personal items. We call him **Kaptan-ı Derya** -the captain of the seas-. He died in this war. For those who want to see it, it's in the Royal Palace of Madrid, and the items are in the Royal Armory department. + +If you're interested in this war, you can also read this part: + +### The Battle of Lepanto ⚔ + +The Ottoman army was invading Cyprus. Angered by this, European countries asked Spain, one of the strongest kingdoms of the period for help. To retake the island, a Holy Christian fleet was assembled under Spanish leadership. On 7 October 1571, the forces arrived at the Gulf of Patras in Greece for what would become known as the Battle of Lepanto. On one side was the Ottoman army, commanded by Ali Pasha (the Grand Admiral). On the opposing side were the major European powers: Spain, Venice, the Papacy, Genoa, the Knights of Malta, and Italy. It would become the last major naval battle fought with oared warships. The Ottomans lost the battle and Sokollu Mehmed Pasha (he's normally Serbian Turk and he's the most powerful manager after Sultan) said the following famous saying: “*You have cut off our beard, but we have cut off your arm. A beard grows back.*” More than 200 Ottoman ships were lost. Tens of thousands of soldiers were killed or captured. In the below photograph, you will see war trophies taken from Ali Pasha. In Spain, this battle would be called “*La Defensa de la Cristiandad*” means “*The Defense of Christianity*” and would be used for propaganda for years. These trophies were used as symbols. The first time I saw the exhibit in a museum, I stood in front of it for 15–20 minutes, simply looking at it. + +![royal-palace-0](images/royal-palace-0.jpg) + +![royal-palace-1](images/royal-palace-1.png) + +![royal-palace-2](images/royal-palace-2.png) + +### What's the thing with Cervantes and Ottomans? + +There may be one more little-known detail about the Battle of Lepanto. Miguel de Cervantes, the author of the famous novel Don Quixote, also took part in this battle. During the conflict, Cervantes served aboard the Spanish ship Marquesa. Despite suffering from a fever, he insisted on joining the battle. The Ottoman army wounded Cervantes in the chest and left arm. As a result, he lost much of the use of his left hand. For this reason, he became known as “*El manco de Lepanto*,” meaning “*the one-handed hero of Lepanto.*” + +![don-kisot](images/don-kisot.png) + +### Bullfighting 🦬 + +I met a real matador in Toledo, and after talking to him, my perspective on everything changed dramatically! + +From the outside, it looks like nothing more than a “brutal spectacle,” but apparently there is a surprisingly deep philosophy behind it. These were the most interesting things I took away from what the matador told me: + +- **Bulls don’t react to the color red:** They are actually color-blind. What triggers them is the movement of the cape, not its color. Red is purely for visual aesthetics. +- **The selected bull is a special, wild breed:** The animal has almost never seen a human before entering the arena. It sees the matador as an enemy and wants to destroy him. +- **The greatest honor is to survive:** If a bull shows exceptional nobility and courage, the audience waves white handkerchiefs to ask for its pardon. That bull never enters the arena again and spends the rest of its life like a king on a farm. +- **A dance with death:** Matadors do not see this as a sport, but as a way of confronting death. When making the most critical strike, the matador must also put his own life at risk, he cannot simply stab the bull from behind. He has to face the bull head-on, bravely. In that sense, there is a strange bond of respect between them. The bull is powerful, but the matador is intelligent. He cannot defeat it through strength, only through skill, timing, and agility. +- **It is a controversial subject**, but hearing it firsthand in its own context changed my perspective considerably. At a time when animal rights are more important than ever, this tradition still continues. And I have now learned that bullfighting has a much deeper philosophy behind it than I had realized. + +![bull-fight](images/bull-fight.jpg) + +## Closing + +I left Convex 2026 and Spain with new ideas, useful feedback, and a stronger belief that the next phase of enterprise AI will be less about impressive demos and more about trusted systems. + +For me, the most interesting AI features are not the ones that look magical. They are the ones that quietly solve a real problem, respect the architecture around them, and help users make better decisions. + +Thank you to the Convex organizers, the speakers, and everyone who joined my session or continued the conversation afterward. + +Madrid was a great place to talk about AI, .NET, architecture, and the future of enterprise software. I hope to see many of you again at the next event. + +--- diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20898.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20898.jpg new file mode 100644 index 0000000000..eb95029543 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20898.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20914.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20914.jpg new file mode 100644 index 0000000000..3603ee8037 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20914.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20927.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20927.jpg new file mode 100644 index 0000000000..039bcdf1e0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20927.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20935.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20935.jpg new file mode 100644 index 0000000000..235118bfc7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20935.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20952.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20952.jpg new file mode 100644 index 0000000000..f34333e988 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20952.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20960.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20960.jpg new file mode 100644 index 0000000000..40d15268c9 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/IMG_20960.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/bull-fight.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/bull-fight.jpg new file mode 100644 index 0000000000..e96a721afd Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/bull-fight.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/convex-ambiance.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/convex-ambiance.jpg new file mode 100644 index 0000000000..854c25ef69 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/convex-ambiance.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/cover.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/cover.jpg new file mode 100644 index 0000000000..27917025b8 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/cover.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/don-kisot.png b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/don-kisot.png new file mode 100644 index 0000000000..803685e4fc Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/don-kisot.png differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/hand-made-coding.png b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/hand-made-coding.png new file mode 100644 index 0000000000..d71ea044cb Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/hand-made-coding.png differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-1.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-1.jpg new file mode 100644 index 0000000000..d3f9b43334 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-1.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-2.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-2.jpg new file mode 100644 index 0000000000..a7741b804c Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/my-pictures-2.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-0.jpg b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-0.jpg new file mode 100644 index 0000000000..bd12a45c6f Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-0.jpg differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-1.png b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-1.png new file mode 100644 index 0000000000..e559a5c5c4 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-1.png differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-2.png b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-2.png new file mode 100644 index 0000000000..4c291f0b84 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/royal-palace-2.png differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/the-oldest-restaurant-world.png b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/the-oldest-restaurant-world.png new file mode 100644 index 0000000000..66dd237590 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/images/the-oldest-restaurant-world.png differ diff --git a/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/volosoft-presentation.pptx b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/volosoft-presentation.pptx new file mode 100644 index 0000000000..8b25522e54 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-Convex-Summit-2026-Recap/volosoft-presentation.pptx differ diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/Post.md b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/Post.md new file mode 100644 index 0000000000..a36eab93e8 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/Post.md @@ -0,0 +1,349 @@ +A lot of the current AI discussion in software development swings between two extremes: either AI will write everything, or it is just autocomplete with better marketing. + +Neither view is especially useful. + +What the evidence shows is more practical: AI coding tools can improve developer throughput on certain tasks, especially repetitive work, scaffolding, and first drafts. But they do not remove the need for developers. In many teams, they actually create a new category of work around review, verification, security, and long-term maintainability. + +That is the real story. AI is not replacing developers. It is changing what developers do, what teams optimize for, and where engineering judgment matters most. + +## The productivity gains are real, but they are not magic + +There is enough data now to move beyond hot takes. + +Across multiple studies and industry reports, AI coding assistants show measurable productivity gains, but those gains are usually modest rather than transformational: + +- A BlueOptima analysis across 30,000 developers in 18 enterprises reported an average productivity uplift of 5.4%, with the most active users seeing gains closer to 20%. +- An open source study found roughly a 6.5% project-level productivity increase after Copilot adoption. +- GitHub survey data from more than 2,000 developers showed strong perceived benefits: improved flow, less mental drain on repetitive tasks, and greater job satisfaction. +- A longitudinal study from a large public-sector engineering organization found that developers using Copilot were already highly active, and while they reported productivity improvements, commit-based metrics did not show a statistically significant post-adoption jump. + +That last point matters. + +Perceived productivity and actual output are not always the same thing. Developers may feel faster because they spend less time on boilerplate, search, or syntax recall. That feeling is valuable. Less friction often means better focus. But it does not automatically translate into dramatically more shipped business value. + +In other words, AI helps, but it does not suspend the usual constraints of software delivery: + +- unclear requirements still slow teams down +- poor architecture still creates drag +- bad testing practices still leak defects +- messy codebases are still messy codebases + +If your delivery bottleneck is typing, AI looks revolutionary. If your bottleneck is product ambiguity, compliance, integration complexity, or production risk, AI helps less than the marketing suggests. + +## What AI coding tools are actually good at + +The strongest use case for AI in development is not autonomous software engineering. It is acceleration of narrow, well-bounded tasks. + +AI coding assistants are usually good at: + +- generating boilerplate +- filling in repetitive CRUD patterns +- writing simple tests and test skeletons +- suggesting refactors +- producing documentation drafts +- translating between languages or frameworks +- helping developers recall APIs and syntax +- creating a first pass for routine utility code + +This is why many developers genuinely like these tools. They reduce low-value friction. + +A practical example: + +A developer building an ABP-based application might use AI to: + +- scaffold DTO mappings +- draft validation rules +- generate basic unit test cases +- create repository query examples +- summarize a service class before refactoring + +Those are useful accelerators. But the same tool is much less reliable when asked to decide: + +- whether a module boundary is correct +- how to model a permission system +- what tradeoff to make between consistency and performance +- how multi-tenancy affects data access rules +- which abstraction will still be maintainable a year later + +That is the dividing line. AI handles local code generation better than system-level reasoning. + + + +![Generated illustration](inline-1.png) + +## Where developers are still irreplaceable + +The most valuable parts of software development were never just typing code. + +Developers are still responsible for the parts AI consistently struggles with: + +### Understanding the problem behind the ticket + +Business requirements are often incomplete, contradictory, or politically constrained. A human developer can ask the uncomfortable question, spot hidden assumptions, and translate vague intent into a workable implementation. + +AI can generate an answer. It cannot reliably challenge the question. + +### Making architecture tradeoffs + +Real systems involve tradeoffs, not ideal answers. + +Should this feature live in an existing module or a new service? Is eventual consistency acceptable here? Are we optimizing for onboarding speed, runtime performance, auditability, or cost control? + +These decisions depend on context that usually lives outside the prompt window. + +### Working safely in large, imperfect codebases + +Most production systems are not greenfield demos. They include legacy code, weird integrations, undocumented conventions, and historical constraints. + +This is where experienced developers earn their keep. They know that the technically correct change is not always the operationally safe change. + +### Taking responsibility for outcomes + +An AI assistant does not get paged at 2 a.m. It does not own the incident review. It does not explain a data leak to legal, security, or customers. + +Software engineering is not just generation. It is accountability. + +## The hidden cost: verification debt + +One of the most important ideas in the current AI coding debate is verification debt. + +AI can generate code quickly, but that speed often shifts effort downstream. Instead of spending time writing code, teams spend time validating whether the generated code is correct, secure, idiomatic, and maintainable. + +That creates a new form of debt: + +- code is produced faster than it is reviewed properly +- weak suggestions slip into the codebase because they look plausible +- reviewers must inspect more generated code with lower trust +- maintenance costs rise later because low-context code ages badly + +Recent survey data points in the same direction: + +- 72% of developers reported using AI tools daily +- AI contributes a substantial share of committed code in some teams +- 96% of developers do not fully trust AI-generated code +- less than half consistently review AI-generated code before committing +- 38% say reviewing AI code can take longer than reviewing human-written code + +That combination should worry engineering leaders. + +If teams accept more machine-generated code while also trusting it less, the result is not full automation. The result is a fragile review pipeline. + +This is why senior engineers are not becoming obsolete. Their work is shifting toward validation, standards, and system integrity. + + + +![Generated illustration](inline-2.png) + +## Security is the clearest reason AI won’t replace developers + +If you want one hard reality check, it is security. + +AI-generated code often looks polished. That makes insecure output more dangerous, not less dangerous. + +Research and industry testing have found recurring problems such as: + +- flawed input validation +- weak authentication or authorization logic +- unsafe serialization patterns +- insecure defaults +- cross-site scripting exposure +- log injection issues +- dependency and configuration mistakes + +A Veracode study covering 100 LLMs across 80 coding tasks found that about 45% of AI-generated code samples contained security flaws. Reported failure rates were especially high in some languages and security-sensitive tasks. + +That aligns with what many teams see in practice: AI can produce code that appears complete while quietly missing the exact defensive details that matter in production. + +There is a second security problem too: the tools themselves. + +Recent research into AI-enabled IDE workflows has highlighted risks such as: + +- prompt injection through project content +- data exfiltration from workspace context +- misuse of tool permissions +- remote code execution paths via compromised assistant workflows + +So the risk surface is now two-layered: + +1. the generated code may be unsafe +2. the coding assistant environment may itself introduce supply-chain and data exposure risks + +That is not a path to replacing developers. It is a path to needing more disciplined developers. + + + +![Generated illustration](inline-3.png) + +## Why junior and senior developers benefit differently + +AI does not help every developer in the same way. + +Less experienced developers often benefit the most from: + +- faster onboarding +- easier exploration of unfamiliar APIs +- reduced time spent on repetitive syntax work +- quick examples to unblock momentum + +That is a good thing. Used well, AI can shorten the distance between "I know the concept" and "I can build the first version." + +But there is a catch. + +If juniors over-rely on generated solutions they do not understand, they can ship code without building judgment. That creates a team with higher output but thinner engineering depth. + +Senior developers usually get less value from raw generation and more value from targeted acceleration. Their role shifts toward: + +- architectural direction +- code review and design review +- defining guardrails +- mentoring developers on when not to trust the tool +- shaping prompts and workflows around quality + +That is not replacement. It is role redistribution. + +## The best teams treat AI like a power tool, not a developer + +The most productive framing is simple: AI is a power tool. + +A power tool can make a skilled worker much faster. It can also let an unskilled worker make bigger mistakes faster. + +Teams getting real value from AI coding assistants usually do a few things consistently. + +### They define where AI is allowed to help + +For example: + +- okay for scaffolding and test drafts +- okay for documentation summaries +- okay for refactoring suggestions in low-risk modules +- not okay for auth flows without explicit review +- not okay for security-sensitive changes without human design approval +- not okay for direct commits to critical paths + +### They keep human review non-negotiable + +AI-generated code should be reviewed like code from a new team member who is fast, confident, and occasionally wrong in subtle ways. + +That means checking: + +- correctness +- security +- consistency with project conventions +- operational impact +- maintainability six months from now + +### They invest in guardrails + +Useful guardrails include: + +- secure coding standards +- mandatory tests for generated code +- SAST and dependency scanning +- branch protection and review policies +- secret scanning +- documented AI usage rules +- prompt hygiene, especially around sensitive data + +### They optimize for maintainability, not just speed + +The wrong metric is lines generated. + +Better metrics include: + +- cycle time without increased incident rate +- review burden +- escaped defects +- time to understand generated code later +- security findings per change + + + +![Generated illustration](inline-4.png) + +## When AI helps most — and when it helps least + +A balanced view is more useful than either fear or hype. + +### When to use AI-assisted coding + +AI is a strong fit when: + +- the task is repetitive or pattern-based +- the scope is narrow and easy to verify +- the code is low-risk and well-tested +- you need a first draft, not a final answer +- developers understand the output well enough to challenge it +- the team has solid review and security practices + +Examples: + +- generating DTOs, mappings, and validation stubs +- producing test cases for straightforward services +- drafting migration scripts that will be reviewed carefully +- summarizing unfamiliar code before manual refactoring + +### When not to rely on AI-assisted coding + +AI is a poor fit when: + +- business rules are complex or ambiguous +- security is central to the change +- architecture decisions are still in flux +- the code touches compliance-heavy or highly regulated paths +- the surrounding codebase has lots of undocumented behavior +- the team is unlikely to review the output carefully + +Examples: + +- permission and tenancy boundaries +- payment or identity workflows +- critical infrastructure automation +- cross-service consistency logic +- sensitive data handling and audit trails + +## What this means for the future of software teams + +AI is changing software development, but not in the simplistic way people often describe. + +The likely outcome is not fewer developers because code writes itself. The more plausible outcome is a different distribution of engineering work: + +- more generated code +- more review and verification work +- more emphasis on architecture and systems thinking +- more value placed on security awareness +- more leverage for developers who can guide tools effectively + +There are also organizational effects. + +If AI tools can remove some low-level friction, teams may ship faster. Some studies and industry analyses even project large macroeconomic gains from AI-augmented software work. But inside engineering organizations, those gains depend on whether speed is paired with discipline. + +Without discipline, AI increases noise. + +With discipline, AI increases leverage. + +That is the distinction leaders should care about. + +## The developer job is not disappearing — it is getting more judgment-heavy + +The strongest developers in the AI era will not be the ones who generate the most code. They will be the ones who can: + +- define the problem clearly +- evaluate tradeoffs +- spot incorrect assumptions +- review machine output efficiently +- protect quality under delivery pressure +- turn generated fragments into coherent systems + +That is a more senior version of software engineering, not a smaller one. + +Typing code was never the whole profession. It was just the most visible part. AI is making that easier, which means the less visible parts now matter even more. + +And those parts are deeply human: judgment, context, responsibility, and taste. + +## TL;DR + +- AI coding tools improve productivity on repetitive, bounded tasks, but the gains are usually incremental, not total automation. +- Developers are still needed for architecture, business logic, tradeoffs, security, and accountability. +- AI-generated code often adds verification debt, increasing review and maintenance work later. +- Security remains a major limitation, both in generated code and in AI-assisted development workflows. +- The winning teams use AI as a power tool with strong guardrails, not as a replacement for engineering judgment. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/cover.png b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/cover.png new file mode 100644 index 0000000000..9e5cfb860b Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-1.png b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-1.png new file mode 100644 index 0000000000..d2c734c3b7 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-2.png b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-2.png new file mode 100644 index 0000000000..724d15d84e Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-3.png b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-3.png new file mode 100644 index 0000000000..ba64f15192 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-4.png b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-4.png new file mode 100644 index 0000000000..ddbbdb74fd Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-ai-isnt-replacing-developers-its-changing-what-good/inline-4.png differ diff --git a/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/Post.md b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/Post.md new file mode 100644 index 0000000000..6c9a4e4a76 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/Post.md @@ -0,0 +1,631 @@ +Caching is one of those topics that looks simple until an application starts scaling. The first version works fine with direct database reads. Then traffic grows, page loads become inconsistent, and suddenly the team is debating Redis, stale data, invalidation, and why one node sees fresh data while another still serves old results. + +ABP Framework gives you a solid caching foundation, but the important part is choosing the right caching strategy for the job. Not everything should be cached the same way. A read-only lookup list, a tenant-specific settings object, and an entity that changes every minute do not have the same caching needs. + +This article walks through the practical caching strategies in ABP Framework, what each one is good at, how to configure them, and the mistakes that usually show up in production. + +## Understand ABP's caching model first + +ABP builds its caching support on top of `Microsoft.Extensions.Caching.Distributed.IDistributedCache`. That matters because ABP does not invent a completely separate caching universe. Instead, it adds practical features developers actually need in real systems: + +- typed cache abstractions +- automatic serialization and deserialization +- tenant-aware cache keys +- configurable key prefixes +- batch operations +- optional Unit of Work awareness +- safer error handling defaults + +Out of the box, the default distributed cache implementation is `MemoryDistributedCache`. Despite the name, this is still wired through the distributed cache abstraction, but the storage is in-memory for the current app instance. + +That is fine for: + +- local development +- demos +- single-node monoliths +- low-risk cached reads + +It is not enough for: + +- load-balanced deployments +- Kubernetes or App Service scale-out +- background workers sharing cached data with web apps +- any scenario where multiple instances must see the same cache state + +In those cases, you should move to a real distributed provider such as Redis. + + + +![Generated illustration](inline-1.png) + +## Strategy 1: Use typed distributed cache for application data + +For most ABP applications, the default and most useful strategy is the generic typed distributed cache. + +ABP provides: + +- `IDistributedCache` +- `IDistributedCache` + +These abstractions remove a lot of repetitive work. You do not have to manually serialize objects, invent every cache key shape yourself, or worry about tenant ID inclusion for common cases. + +### Why typed distributed cache is usually the best starting point + +It works well when you want to cache: + +- lookup lists +- settings snapshots +- permission-related read models +- dashboard widgets +- expensive API responses +- aggregated DTOs used by the UI + +This strategy is usually better than caching raw entities because cached application-facing models tend to be: + +- smaller +n- more stable +- easier to version +- less coupled to domain changes + +### Example: cache a product summary DTO + +```csharp +using Microsoft.Extensions.Caching.Distributed; +using Volo.Abp.Caching; + +[CacheName("ProductSummary")] +public class ProductSummaryCacheItem +{ + public Guid Id { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } + public bool IsAvailable { get; set; } +} + +public class ProductAppService : ApplicationService +{ + private readonly IDistributedCache _cache; + private readonly IRepository _productRepository; + + public ProductAppService( + IDistributedCache cache, + IRepository productRepository) + { + _cache = cache; + _productRepository = productRepository; + } + + public async Task GetSummaryAsync(Guid id) + { + return await _cache.GetOrAddAsync( + id, + async () => + { + var product = await _productRepository.GetAsync(id); + + return new ProductSummaryCacheItem + { + Id = product.Id, + Name = product.Name, + Price = product.Price, + IsAvailable = product.StockCount > 0 + }; + }, + () => new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10), + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + } + ); + } +} +``` + +A few good things are happening here: + +- the cache item is small and explicit +- the key is strongly typed +- expiration is defined close to the use case +- both sliding and absolute expiration are used + +That last point is important. Sliding expiration alone can keep hot items alive indefinitely. Absolute expiration alone can evict popular items too aggressively. In many business cases, combining them gives you a better balance. + +### When to use + +Use typed distributed cache when: + +- you want a simple, explicit cache around a read operation +- the cached model is a DTO or a lightweight read model +- invalidation can be handled in application logic +- you need tenant-aware behavior without extra plumbing + +### When NOT to use + +Avoid it when: + +- the underlying data changes extremely often and stale reads are unacceptable +- the object is very large and serialization cost outweighs the benefit +- cache invalidation is too complex to reason about safely +- the query is already cheap and highly selective + +## Strategy 2: Use entity cache for read-heavy entity access + +ABP also provides an entity cache abstraction for read-only entity-level caching. This is useful when you repeatedly fetch entities or entity-based DTOs by ID and want cache invalidation to happen automatically on update or delete. + +This is where entity cache can save real effort. Instead of manually wiring remove calls in every update path, you lean on the framework's invalidation behavior. + +### What entity cache is good at + +Entity cache is a good fit for: + +- catalogs +- countries, regions, tax definitions +- organization units that are read often but changed infrequently +- profile-like records fetched by ID repeatedly + +It is a bad fit for highly volatile entities where every read risks becoming stale within seconds. + +### Example use case + +Suppose your application repeatedly loads a `Category` record by ID from both HTTP requests and background jobs. That category changes maybe once a week. Entity cache is a better fit than manually managing many distributed cache entries across the codebase. + +The main advantage is operational simplicity: + +- read-through usage is straightforward +- updates and deletes trigger invalidation automatically +- you get consistency improvements without scattering cache removal logic everywhere + +### A practical warning about entity versioning + +ABP supports entity versioning through `IHasEntityVersion`. If an entity implements it, ABP increments the `EntityVersion` on updates and uses that in invalidation-related behavior. + +That is useful, but there is one common trap: direct SQL updates outside the normal application flow bypass entity versioning and the domain pipeline. + +If your team runs scripts like this: + +```sql +update Products set Name = 'New Name' where Id = '...' +``` + +then your cache may not be invalidated as expected. + +If you use entity cache, make sure updates go through the application and domain stack whenever possible. If operational SQL scripts are unavoidable, explicitly account for cache invalidation. + +### When to use + +Use entity cache when: + +- reads are frequent and mostly by entity key +- entities change infrequently +- automatic invalidation on update/delete is valuable +- you want less manual cache removal code + +### When NOT to use + +Avoid it when: + +- the read model should differ significantly from the entity shape +- data is updated too frequently +- your team often bypasses the application layer with direct SQL updates +- the cached object graph is large or expensive to serialize + + + +![Generated illustration](inline-2.png) + +## Strategy 3: Prefer Redis for real distributed deployments + +A lot of caching problems are not about API design. They are deployment problems. + +If you run multiple application instances and still use the default in-memory distributed cache implementation, each node will maintain its own private cache state. That means: + +- node A may have fresh data +- node B may have stale data +- invalidation on one node does not magically update the others +- behavior becomes inconsistent under load balancing + +For production scale-out, Redis is usually the practical answer. + +ABP provides Redis integration through `Volo.Abp.Caching.StackExchangeRedis`. + +### Basic setup idea + +Install the Redis caching package and configure distributed caching as your backing provider. ABP then continues to use its caching abstractions, while Redis stores the actual cache entries. + +A typical module configuration looks like this: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Caching; +using Volo.Abp.Modularity; + +[DependsOn(typeof(AbpCachingStackExchangeRedisModule))] +public class MyProjectModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + var configuration = context.Services.GetConfiguration(); + + context.Services.AddStackExchangeRedisCache(options => + { + options.Configuration = configuration["Redis:Configuration"]; + }); + + Configure(options => + { + options.KeyPrefix = "MyApp"; + options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20); + options.HideErrors = true; + }); + } +} +``` + +### Why the key prefix matters + +If the same Redis server is shared by multiple applications or environments, a global key prefix is not optional in practice. Without it, key collisions become surprisingly easy. + +Good examples: + +- `MyApp-Prod` +- `SalesService` +- `TenantPortal` + +Bad example: + +- leaving it empty and hoping naming conventions elsewhere are enough + +## Strategy 4: Use batch cache operations for high-volume reads + +If you need to fetch many cache entries at once, ABP supports batch operations such as: + +- `GetManyAsync` +- `SetManyAsync` +- `RemoveManyAsync` + +This matters most in list and aggregation scenarios. + +For example, imagine a product page that needs cached summaries for 50 product IDs. Doing 50 individual round-trips is not ideal. If the provider supports batch operations well, this can reduce latency significantly. + +### Example: batch loading summaries + +```csharp +public async Task> GetManySummariesAsync(Guid[] ids) +{ + var cachedItems = await _cache.GetManyAsync(ids); + + var missingIds = ids + .Where(id => !cachedItems.ContainsKey(id) || cachedItems[id] == null) + .ToArray(); + + if (missingIds.Any()) + { + var products = await _productRepository.GetListAsync(x => missingIds.Contains(x.Id)); + + var newItems = products.ToDictionary( + x => x.Id, + x => new ProductSummaryCacheItem + { + Id = x.Id, + Name = x.Name, + Price = x.Price, + IsAvailable = x.StockCount > 0 + }); + + await _cache.SetManyAsync( + newItems, + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + + foreach (var item in newItems) + { + cachedItems[item.Key] = item.Value; + } + } + + return ids + .Where(id => cachedItems.ContainsKey(id) && cachedItems[id] != null) + .Select(id => cachedItems[id]) + .ToList(); +} +``` + +Provider support matters here. With Redis and ABP's Redis package, batch operations are especially useful. If the underlying provider does not support them efficiently, ABP can fall back to single operations. + +That means batch APIs are still worth using from an application-code perspective, but you should validate the real performance characteristics in your deployed environment. + +## Strategy 5: Make cache writes Unit of Work aware when consistency matters + +One subtle but valuable ABP feature is the `considerUow` flag on typed distributed cache operations. + +This is easy to overlook, but it can prevent a nasty class of bugs. + +Imagine this sequence: + +1. You update an entity. +2. You write a corresponding cache value immediately. +3. The database transaction later fails and rolls back. +4. The cache now contains data representing a change that never actually committed. + +That is classic stale-or-phantom cache state. + +When `considerUow` is enabled, ABP can defer cache writes until the Unit of Work completes successfully. + +### Example + +```csharp +await _cache.SetAsync( + id, + cacheItem, + options: new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) + }, + considerUow: true +); +``` + +Use this when cache state depends on transactional data changes in the same operation. + +### When to use + +Use `considerUow` when: + +- you update data and cache in the same business operation +- transaction rollback is possible +- cache correctness matters more than immediate write timing + +### When NOT to use + +You may skip it when: + +- you are caching purely read-side data after a committed fetch +- the operation is outside transactional boundaries +- eventual cache population is acceptable + + + +![Generated illustration](inline-3.png) + +## Strategy 6: Treat multi-tenancy as a cache design concern, not a detail + +ABP automatically includes the current tenant ID in cache keys for typed distributed cache scenarios unless multi-tenancy is explicitly ignored. + +This is one of those features that quietly prevents serious data leaks. + +Without tenant-aware cache keys, this can happen: + +- tenant A requests a settings object +- it gets cached under a generic key +- tenant B requests the same logical object +- tenant B receives tenant A's cached data + +That is not just a bug. In many systems, it is a security incident. + +### Practical guidance + +For multi-tenant systems: + +- keep tenant-aware caching enabled by default +- only ignore multi-tenancy for truly global shared data +- review custom key-building logic carefully +- test cache behavior with at least two tenants in integration tests + +If a cache item is intentionally global, make that decision explicit and document it. + +## Strategy 7: Be deliberate about expiration policy + +A lot of bad caching behavior comes from expiration values chosen almost randomly. + +ABP lets you define expiration using `DistributedCacheEntryOptions`, including: + +- `AbsoluteExpiration` +- `AbsoluteExpirationRelativeToNow` +- `SlidingExpiration` + +ABP also supports global defaults through `AbpDistributedCacheOptions`. If you do not specify item-level options, a default sliding expiration is commonly configured as 20 minutes. + +### A simple rule of thumb + +- Use sliding expiration for frequently accessed, low-volatility items. +- Use absolute expiration when freshness has a hard upper bound. +- Use both when you want hot items to stay warm, but not forever. + +### Example global configuration + +```csharp +Configure(options => +{ + options.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20); + options.HideErrors = true; + options.KeyPrefix = "MyApp"; +}); +``` + +### Common expiration patterns + +**Reference data** + +- sliding: 30 to 60 minutes +- absolute: 6 to 24 hours + +**User-specific dashboard data** + +- sliding: 5 to 15 minutes +- absolute: 15 to 60 minutes + +**Highly dynamic operational metrics** + +- short absolute expirations, or no cache at all + +These are not universal numbers, but they are more realistic than setting every cache entry to 24 hours and calling it done. + +## Strategy 8: Keep cache items small and serialization-friendly + +Distributed caching always includes serialization and deserialization overhead. ABP handles this for you, with JSON serialization by default, but the cost still exists. + +That means cache item design matters. + +### Prefer this + +- lean DTO-style cache items +- primitive properties +- only fields needed by the consuming path +- stable shapes that do not change constantly + +### Avoid this + +- huge object graphs +- navigation-heavy entities +- deeply nested collections when only a few fields are used +- caching everything just because it was already available in memory + +A cache entry should usually be optimized for read efficiency, not for domain completeness. + +If a page needs only `Name`, `Price`, and `Status`, do not cache the entire entity graph with audit fields, children, and metadata. + +## Strategy 9: Decide how hard cache failures should fail + +ABP defaults to a practical stance: cache errors are hidden and logged so your application can continue functioning. + +This default is often correct. + +If Redis has a transient issue, it is usually better for the request to fall back to the database than to fail completely. Caching should improve performance, not become a single point of failure. + +You can control this behavior globally with `AbpDistributedCacheOptions.HideErrors` and per operation with the `hideErrors` parameter. + +### Good default thinking + +Keep `HideErrors = true` when: + +- cache is a performance optimization +- falling back to source data is acceptable +- temporary cache outages should not break user flows + +Consider stricter behavior when: + +- cache is part of a critical coordination pattern +- silent fallback would overload downstream systems +- you are diagnosing a production issue and want failures surfaced more aggressively + +In most business applications, hidden-and-logged cache failures are the safer default. + +## What about automatic method-level caching? + +You may have seen community implementations that add automatic method-level caching through interception and a `[Cache]` attribute. + +That pattern can be attractive because it reduces boilerplate: + +- decorate a method +- define expiration +- cache the return value transparently +- optionally connect invalidation to entity changes + +It is a useful pattern, but it is important to say clearly: this is not part of ABP core. + +So treat it as an architectural choice, not a built-in feature. + +### Why teams like it + +- less repetitive cache code +- centralized cache policy +- easier adoption for query-heavy services + +### Why teams get into trouble with it + +- invalidation becomes less explicit +- stale data bugs are harder to trace +- cache scope decisions can become too magical +- developers may not realize when a method result is tenant-specific or user-specific + +If you adopt method-level caching, document it aggressively and be strict about invalidation rules. It can be productive, but only when the team fully understands the behavior. + +## Common mistakes in ABP caching + +Here are the mistakes that cause the most pain. + +### Using in-memory distributed cache in a multi-instance production setup + +This is probably the most common one. It works in testing, then becomes inconsistent under scale-out. + +Fix: use Redis or another true distributed cache provider. + +### Caching entities instead of read models by default + +This increases serialization cost and couples cache shape to domain shape. + +Fix: cache DTOs or purpose-built cache items unless entity cache is clearly the better fit. + +### Forgetting invalidation paths + +Manual caches live or die by invalidation quality. + +Fix: centralize writes, remove cache entries on updates, and use entity cache where automatic invalidation helps. + +### Relying only on sliding expiration + +Hot keys may stay forever. + +Fix: combine sliding and absolute expiration for many scenarios. + +### Ignoring tenant boundaries + +This can leak data across tenants. + +Fix: rely on ABP's tenant-aware key behavior and be very careful with custom key generation. + +### Writing to cache before transaction success + +This creates cache values for changes that later roll back. + +Fix: use `considerUow` for transactional cache writes. + +### Treating cache outages as impossible + +Eventually, your cache provider will have a bad day. + +Fix: decide upfront whether fallback or fail-fast behavior is right for each path. + +## A practical decision guide + +If you just want a sensible default approach for most ABP projects, this is a good starting point: + +1. Use typed distributed cache for expensive read models and DTOs. +2. Use Redis for anything beyond a single instance. +3. Use entity cache for read-heavy entities fetched by ID when automatic invalidation is valuable. +4. Combine sliding and absolute expiration for most business data. +5. Keep cache items small. +6. Use tenant-aware keys by default. +7. Use `considerUow` for cache writes tied to transactions. + +That covers a large percentage of real-world ABP caching needs without overengineering the system. + +## When to use / When NOT to use caching in ABP + +### Use caching when + +- the same data is read frequently +- computing or querying the result is expensive +- modest staleness is acceptable +- the cache key can be defined clearly +- invalidation rules are understandable + +### Do NOT use caching when + +- the underlying data changes constantly +- every read must reflect the latest committed value immediately +- the query is already cheap +- object serialization cost is high relative to the saved work +- the team cannot confidently maintain invalidation rules + +Caching is a performance tool, not a default architecture layer for every service method. + +## TL;DR + +- In ABP, typed distributed cache is the best default for caching DTOs and read models. +- `MemoryDistributedCache` is fine for single-instance apps, but scaled deployments should use Redis. +- Entity cache is useful for read-heavy entity access with automatic invalidation on update and delete. +- Use tenant-aware keys, sensible expiration policies, and `considerUow` to avoid subtle consistency bugs. +- Keep cache items small, explicit, and easy to invalidate. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/cover.png b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/cover.png new file mode 100644 index 0000000000..c95b525828 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-1.png b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-1.png new file mode 100644 index 0000000000..f64ec05422 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-2.png b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-2.png new file mode 100644 index 0000000000..5e7881c7d3 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-3.png b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-3.png new file mode 100644 index 0000000000..0c340d8700 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-caching-strategies-in-abp-framework/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/Post.md b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/Post.md new file mode 100644 index 0000000000..b8384ca165 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/Post.md @@ -0,0 +1,693 @@ +Domain events look simple on paper: something happened, react to it. In a real ABP microservices solution, the hard part is not raising the event. The hard part is deciding which event belongs inside the service, which one should cross service boundaries, and how to publish it without losing data or coupling your modules into a distributed monolith. + +ABP gives you the primitives to do this well: local events, distributed events, aggregate-root support, and built-in outbox/inbox infrastructure. Used correctly, they let you keep your domain model clean while still coordinating work across microservices. + +This article walks through a practical way to implement domain events in ABP microservices, including the boundary between domain and integration events, the transactional flow, outbox/inbox configuration, and the pitfalls that usually show up after the first production incident. + +## Start with the right event boundary + +The most important design choice is this: + +- **Domain events** are internal to a bounded context. +- **Integration events** are for other microservices. + +These are not interchangeable, even if the payload looks similar. + +### Domain events + +A domain event represents something meaningful that happened inside your domain model. + +Examples: + +- `OrderPlacedDomainEvent` +- `PaymentCapturedDomainEvent` +- `ProductStockDecreasedDomainEvent` + +These events are typically handled **in-process**. In ABP, that usually means the **local event bus** or ABP's domain event dispatching from aggregates tracked by the ORM. + +Use domain events when you want to: + +- trigger side effects inside the same microservice +- keep aggregate logic focused +- avoid bloated application services +- coordinate rules across domain services without hard references + +### Integration events + +An integration event is a contract for communication between microservices. + +Examples: + +- `OrderPlacedEto` +- `StockCountChangedEto` +- `CustomerDeletedEto` + +In ABP, these go through the **distributed event bus**. With a real provider like RabbitMQ, Kafka, or Azure Service Bus, they leave the current process and get consumed elsewhere. + +Use integration events when you want to: + +- notify another microservice +- update a local projection in another service +- drive eventual consistency across bounded contexts + +### The rule that keeps systems healthy + +A good practical rule is: + +1. Raise a **domain event** from the aggregate or domain layer. +2. Handle it inside the same service. +3. From that handler, publish a **distributed event** if another microservice needs to know. + +That separation prevents leaking internal domain details into your external contracts. + + + +![Generated illustration](inline-1.png) + +## What ABP gives you out of the box + +ABP already supports the eventing model most microservices need. + +### Local event bus + +The local event bus is in-process. It is appropriate for: + +- domain events +- module-to-module communication inside the same app +- internal side effects that should not leave the service boundary + +### Distributed event bus + +The distributed event bus is for cross-process communication. + +A few practical notes matter here: + +- Without a real provider configured, it behaves effectively in-process. +- With RabbitMQ, Kafka, or another provider, it becomes actual inter-service messaging. +- It works best with **ETOs** instead of domain entities. + +### Aggregate roots and generated events + +ABP aggregate roots can generate events directly. In practice, if your entity inherits from `AggregateRoot`, you can use methods like: + +- `AddDomainEvent(...)` +- `AddDistributedEvent(...)` + +ABP collects these events and dispatches them during persistence, typically around `SaveChanges` in EF Core-based applications. + +That means your aggregate can say, "this happened," without knowing who will react. + +## A practical implementation flow + +Let's use a simple example: an Ordering microservice places an order, and an Inventory microservice needs to update its local stock view. + +### Step 1: Raise a domain event in the aggregate + +The aggregate should express business meaning, not infrastructure concerns. + +```csharp +public class Order : AggregateRoot +{ + public OrderStatus Status { get; private set; } + public Guid CustomerId { get; private set; } + + public void Place() + { + if (Status != OrderStatus.Draft) + { + throw new BusinessException("Order is not in draft state."); + } + + Status = OrderStatus.Placed; + + AddDomainEvent(new OrderPlacedDomainEvent(Id, CustomerId)); + } +} + +public record OrderPlacedDomainEvent(Guid OrderId, Guid CustomerId); +``` + +This is internal and business-oriented. It says nothing about RabbitMQ, contracts, queues, or other services. + +### Step 2: Handle the domain event inside the same microservice + +Now handle that event in-process. + +Typical responsibilities here: + +- update other local models +- start internal workflows +- publish an integration event for external consumers + +```csharp +public class OrderPlacedDomainEventHandler : + ILocalEventHandler, + ITransientDependency +{ + private readonly IDistributedEventBus _distributedEventBus; + + public OrderPlacedDomainEventHandler(IDistributedEventBus distributedEventBus) + { + _distributedEventBus = distributedEventBus; + } + + public async Task HandleEventAsync(OrderPlacedDomainEvent eventData) + { + await _distributedEventBus.PublishAsync( + new OrderPlacedEto + { + OrderId = eventData.OrderId, + CustomerId = eventData.CustomerId + } + ); + } +} +``` + +This is where the boundary is enforced: + +- domain event in +- integration event out + +### Step 3: Define a lean ETO + +Your Event Transfer Object should be serializable and intentionally small. + +```csharp +public class OrderPlacedEto +{ + public Guid OrderId { get; set; } + public Guid CustomerId { get; set; } +} +``` + +A few ABP-friendly rules for ETOs: + +- keep only the properties consumers actually need +- avoid navigation properties +- avoid circular references +- avoid polymorphic object graphs unless you really control serialization end to end +- prefer public setters or structures that deserialize cleanly + +Do not publish your aggregate itself. That creates versioning and serialization problems fast. + +### Step 4: Consume the distributed event in another microservice + +In the Inventory microservice, handle the integration event through the distributed event bus. + +```csharp +public class OrderPlacedHandler : + IDistributedEventHandler, + ITransientDependency +{ + private readonly IInventorySyncService _inventorySyncService; + + public OrderPlacedHandler(IInventorySyncService inventorySyncService) + { + _inventorySyncService = inventorySyncService; + } + + [UnitOfWork] + public virtual async Task HandleEventAsync(OrderPlacedEto eventData) + { + await _inventorySyncService.HandleOrderPlacedAsync( + eventData.OrderId, + eventData.CustomerId + ); + } +} +``` + +The `UnitOfWork` attribute is important when the handler writes to the local database. + +## Domain events vs distributed events in ABP + +A lot of design mistakes come from treating these as the same thing. They are not. + +### Domain events + +Characteristics: + +- in-process +- internal to one bounded context +- part of domain modeling +- can trigger multiple internal handlers +- often dispatched during the same persistence flow + +Typical example: + +- an `Order` was placed, so calculate loyalty points internally + +### Distributed events + +Characteristics: + +- cross-process +- integration contract between services +- serialized and brokered +- eventually consistent by nature +- must tolerate retries, duplication, and delayed delivery + +Typical example: + +- Ordering tells Inventory that an order was placed + +### The key difference in failure behavior + +If a local domain event handler fails, that failure is usually part of the current application's execution path. + +If a distributed event consumer fails in another microservice, the original transaction is already committed. You are now in the world of retries, poison messages, compensation, and idempotency. + +That is why integration events need a different level of discipline. + + + +![Generated illustration](inline-2.png) + +## Using AddDistributedEvent directly on aggregates + +ABP also allows aggregates and domain services to add distributed events directly. + +```csharp +public class Product : AggregateRoot +{ + public int StockCount { get; private set; } + + public void ChangeStock(int newCount) + { + StockCount = newCount; + + AddDistributedEvent(new StockCountChangedEto + { + ProductId = Id, + NewCount = newCount + }); + } +} + +public class StockCountChangedEto +{ + public Guid ProductId { get; set; } + public int NewCount { get; set; } +} +``` + +This is convenient, and ABP supports it well. + +Still, I would use it selectively. + +### When it works well + +- the event contract is stable +- the aggregate genuinely owns the integration signal +- the payload is simple +- the team is disciplined about not leaking internal state + +### When to be careful + +- the integration event may change independently from domain behavior +- multiple external contracts may be derived from one domain event +- you want the domain layer isolated from integration messaging concerns + +In larger systems, the domain-event-then-integration-event pattern usually ages better. + + + +![Generated illustration](inline-3.png) + +## The outbox pattern: the part that saves you in production + +Without outbox, the classic failure is simple: + +1. Save business data to the database. +2. Try publishing to the broker. +3. App crashes between the two. +4. Your data is committed, but the event is gone. + +Now one microservice thinks the operation happened, and the others never hear about it. + +Outbox exists to remove that gap. + +### How outbox works in ABP + +With outbox enabled: + +1. Your business data is saved. +2. The outgoing distributed event is also stored in the same database transaction. +3. A background worker reads pending outbox records. +4. It publishes them to the message broker. +5. Published records are marked processed and later cleaned up. + +That gives you transactional safety between your local state change and the fact that an event must be published. + +### EF Core outbox configuration + +Your DbContext needs to participate in event outbox support. + +```csharp +public class OrderingDbContext : AbpDbContext, IHasEventOutbox +{ + public DbSet OutgoingEvents { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ConfigureEventOutbox(); + } +} +``` + +Then configure the outbox: + +```csharp +Configure(options => +{ + options.Outboxes.Configure(config => + { + config.UseDbContext(); + config.Selector = type => true; + }); +}); +``` + +The selector lets you choose which events go through that outbox. This becomes useful when a solution has multiple modules or database contexts. + +### Why selectors matter + +In modular ABP solutions, not every event should use every outbox. + +Selectors help you: + +- route specific event types through a specific context +- separate concerns between modules +- avoid a single shared event persistence strategy for everything + +That flexibility matters more as the solution grows. + +## The inbox pattern: the consumer-side safety net + +Outbox protects publishing. Inbox protects consumption. + +Without inbox, a consumer can receive an event and fail mid-processing, leaving you unsure whether the local change happened, whether to retry, or whether the event was already partially applied. + +### How inbox works in ABP + +With inbox enabled: + +1. The incoming event is persisted first. +2. ABP processes it in a transactional scope. +3. Processed records are tracked. +4. Duplicate deliveries can be detected and ignored safely. + +This gives you practical idempotency support and much better operational behavior. + +### EF Core inbox configuration + +Your consumer DbContext participates similarly. + +```csharp +public class InventoryDbContext : AbpDbContext, IHasEventInbox +{ + public DbSet IncomingEvents { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ConfigureEventInbox(); + } +} +``` + +Then wire it up: + +```csharp +Configure(options => +{ + options.Inboxes.Configure(config => + { + config.UseDbContext(); + config.EventSelector = type => true; + config.HandlerSelector = type => true; + }); +}); +``` + +### Important operational trade-off + +Inbox and outbox improve reliability, but they add: + +- extra tables/collections +- polling and background processing +- a little more latency +- more database activity + +That trade-off is usually worth it for microservices. It is often unnecessary for a simple monolith. + +## Pre-defined entity distributed events + +ABP can automatically publish distributed entity lifecycle events. + +Common built-in types include: + +- `EntityCreatedEto` +- `EntityUpdatedEto` +- `EntityDeletedEto` + +These are useful when another service needs basic CRUD-oriented synchronization rather than a rich business workflow event. + +### Enabling auto entity events + +```csharp +Configure(options => +{ + options.AutoEventSelectors.Add(); + options.EtoMappings.Add(); +}); +``` + +And the mapped ETO: + +```csharp +public class ProductEto +{ + public Guid Id { get; set; } + public string Name { get; set; } + public int StockCount { get; set; } +} +``` + +### When this is a good fit + +- reference data synchronization +- local read model updates in another service +- straightforward create/update/delete propagation + +### When not to use it + +- when business meaning matters more than CRUD state +- when consumers should react to a specific business action, not a generic update +- when publishing all entity changes leaks too much internal behavior + +A `ProductUpdated` technical event is not the same as a meaningful `StockCountChanged` business event. + +## Entity synchronizer for local copies of remote data + +One common microservice pattern is keeping a local copy of remote entities for querying or validation. + +For example: + +- Catalog owns `Product` +- Ordering keeps a local product snapshot for order creation rules + +ABP's entity synchronizer support helps consume create/update/delete events and persist local copies. This is useful for eventual consistency scenarios where each service needs its own storage and query model. + +This pattern works well when: + +- read performance matters +- cross-service synchronous calls would be too chatty +- temporary staleness is acceptable + +It works poorly when: + +- the downstream service requires strict immediate consistency +- the data changes constantly and synchronization cost gets high +- teams assume replicated data is always current + +## Event naming and contract design + +ABP uses the event type's full class name by default unless you specify an event name explicitly. + +That default is convenient, but contracts deserve some care. + +### Good contract design principles + +- keep ETOs small +- include identifiers and values the consumer truly needs +- avoid domain behavior and private invariants in the payload +- design for versioning from day one +- prefer additive changes over breaking changes + +### A bad ETO usually looks like this + +- dozens of properties copied from the aggregate +- nested child collections that consumers barely use +- serialization-unfriendly types +- assumptions that all consumers share the same domain model + +### A better ETO usually looks like this + +- stable identifiers +- a small number of primitive fields +- explicit timestamps or version fields if useful +- business meaning that survives service evolution + +## Real-world pattern: publish from a domain handler, not the application service + +Many examples online publish distributed events directly from app services after repository calls. That works, but it tends to make orchestration logic pile up in the application layer. + +A cleaner ABP approach is often: + +- aggregate raises domain event +- local handler reacts +- local handler publishes distributed event + +Why this usually scales better: + +- the aggregate stays expressive +- the app service stays thin +- internal reactions remain composable +- multiple handlers can subscribe without changing the original use case + +It also makes testing easier because the business event becomes the seam. + +## Failure modes you should design for + +If you are using distributed events, assume these will happen eventually: + +- duplicate message delivery +- delayed delivery +- consumer failure after partial processing +- contract evolution across independently deployed services +- producer publishes faster than consumers can handle + +### Practical defenses + +- enable outbox on producers +- enable inbox on consumers +- make handlers idempotent +- keep events small and versionable +- avoid side effects that cannot be retried safely +- use compensating actions for multi-service workflows + +A distributed event is not a database transaction stretched across services. Treat it as asynchronous coordination. + +## When to use / When NOT to use + +### Use domain events in ABP when + +- you want to decouple internal side effects +- multiple parts of the same microservice should react to a business action +- your aggregate should express business intent without knowing infrastructure details +- you want cleaner application services + +### Do not use domain events when + +- a plain method call inside the same class is clearer +- the logic is not really event-driven and has only one obvious synchronous step +- the event abstraction makes the code harder to understand than the original flow + +### Use distributed events when + +- another microservice needs to react asynchronously +- eventual consistency is acceptable +- you want to avoid synchronous runtime coupling between services +- local replicas or read models must stay updated + +### Do not use distributed events when + +- the consumer requires immediate consistency before the current request can finish +- the workflow cannot tolerate asynchronous delays +- you have not planned for retries, idempotency, and failure handling +- you are using microservices in name only and everything still depends on lockstep behavior + +## A reference implementation shape + +In a typical ABP microservice, the structure often looks like this: + +### In the domain layer + +- aggregates call `AddDomainEvent(...)` +- optionally aggregates call `AddDistributedEvent(...)` for very stable external contracts +- domain logic stays free from broker-specific code + +### In the application or domain event handling layer + +- implement `ILocalEventHandler` +- translate domain events into integration ETOs +- publish using `IDistributedEventBus` + +### In infrastructure + +- configure RabbitMQ or another provider +- configure outbox/inbox on the relevant DbContexts +- tune event box options for polling, batching, cleanup + +### In consuming microservices + +- implement `IDistributedEventHandler` +- wrap data updates in a unit of work +- make processing idempotent + +That division keeps the model understandable and avoids most of the coupling problems teams introduce accidentally. + +## Common mistakes in ABP event-driven microservices + +### 1. Publishing entities instead of contracts + +This leaks internals and breaks consumers when your domain evolves. + +### 2. Treating domain events as public integration events + +Internal events and external contracts change at different speeds. Keep them separate. + +### 3. Skipping outbox in production + +It works until the day you hit the save-then-crash gap. + +### 4. Forgetting idempotency on consumers + +Brokers and retries do not guarantee single delivery in the way many teams assume. + +### 5. Emitting generic CRUD events for business workflows + +A business process usually deserves a business event, not just `EntityUpdated`. + +### 6. Putting too much data in ETOs + +Large event contracts create versioning pain, serialization issues, and unnecessary coupling. + +## Final recommendations + +If you are implementing domain events in ABP microservices, optimize for clear boundaries first and infrastructure reliability second. + +The pattern that works well in most real systems is: + +- raise domain events inside aggregates +- handle them locally +- publish explicit integration events for other services +- protect publishing with outbox +- protect consumption with inbox + +ABP already gives you the building blocks. The main challenge is not framework support. It is resisting the temptation to blur domain events, application events, and integration contracts into one catch-all mechanism. + +If you keep those boundaries sharp, your services remain easier to evolve, test, and operate. + +## TL;DR + +- In ABP, use domain events for in-process reactions inside one microservice and distributed events for cross-service communication. +- Prefer raising domain events from aggregates, then translating them into lean ETOs in local handlers. +- Enable outbox on producers and inbox on consumers to avoid lost events and improve idempotency. +- Use built-in entity events for synchronization scenarios, but prefer business events when workflow meaning matters. +- Keep integration contracts small, serializable, stable, and separate from your domain model. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/cover.png b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/cover.png new file mode 100644 index 0000000000..d7aa34a813 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/cover.png differ diff --git a/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-1.png b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-1.png new file mode 100644 index 0000000000..dff7d67d5c Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-2.png b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-2.png new file mode 100644 index 0000000000..5574439be2 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-3.png b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-3.png new file mode 100644 index 0000000000..0d35b894ae Binary files /dev/null and b/docs/en/Community-Articles/2026-06-25-implementing-domain-events-in-abp-microservices/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/POST.md b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/POST.md new file mode 100644 index 0000000000..4a97b9d7cb --- /dev/null +++ b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/POST.md @@ -0,0 +1,419 @@ +# Working with Dapr Workflows in the ABP Framework + +Most real business processes don't finish in a single request. + +An order gets placed, inventory gets checked, a payment gets charged, and the customer gets notified. Each step can fail, time out, or need a retry. And the whole thing has to survive a process restart without losing its place or charging someone twice. + +We usually solve this with a pile of queues, a state table, and a lot of defensive code to track where each process is. It works, but the business logic ends up scattered across handlers and database rows, and nobody can read the flow top to bottom anymore. + +[I covered **Elsa** in two earlier articles](https://abp.io/community/search?tag=elsa) as one way to handle workflows in ABP. **Dapr Workflow** takes a different path: instead of an in-app engine, the workflow engine runs in the [**Dapr sidecar**](https://docs.dapr.io/concepts/dapr-services/sidecar/), and you write the process as ordinary C# code that Dapr makes durable. If the host crashes halfway through, the workflow picks up right where it left off. + +In this article, we'll build a small Dapr Workflow inside a fresh ABP project and run it end to end. By the time you reach the bottom, you should be able to copy the code, run it, and watch a workflow march through its steps. + +> **Note:** Versions matter here, because both ABP and Dapr move fast. This article is written in June 2026 against **ABP 10.4** (.NET 10), **Dapr 1.18**, and the **`Dapr.Workflow` 1.18.x** package. The `Dapr.Workflow` package was rewritten in Dapr 1.17, so older tutorials you find online may use a different API. + +## What Dapr Workflow Actually Is? + +You define a [**workflow**](https://docs.dapr.io/developing-applications/building-blocks/workflow/) that orchestrates a process, and [**activities**](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/#workflows-and-activities) that do the actual work (call a database, hit an API, send an email). + +> **This is orchestration rather than choreography:** one place drives the process, instead of services reacting to each other's events. The definitions live in your app, but the engine that executes them runs in the Dapr sidecar next to it. + +![Dapr workflow execution architecture diagram](./mermaid1.png) + +The key idea is **durable execution**. Dapr records every step to a state store, so the workflow can be replayed from history at any time. A crash, a deployment, or a scale-out event doesn't lose progress, and a workflow can run for seconds or for months. + +> ⚠️ One rule follows from this: **workflow code must be deterministic**. No `DateTime.Now`, no random values, no direct I/O. Anything non-deterministic goes into an activity. Even logging is affected, so inside a workflow you use `context.CreateReplaySafeLogger()` instead of a normal logger, otherwise every replay repeats your log lines. + +Under the hood, this all runs on [**Dapr actors**](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-overview/), which is why the state store has to support actors. The good news is that the default local setup already handles this, as you'll see in a moment. + +--- + +## A Quick Note on ABP and Dapr + +ABP already ships a set of Dapr integration packages: `Volo.Abp.Dapr` (the core package), `Volo.Abp.EventBus.Dapr` and `Volo.Abp.AspNetCore.Mvc.Dapr.EventBus` (distributed event bus over Dapr pub/sub), `Volo.Abp.Http.Client.Dapr` (service invocation), and `Volo.Abp.DistributedLocking.Dapr` (distributed locking). You can read all about them in the [ABP Dapr integration documentation](https://abp.io/docs/latest/framework/dapr). + +These cover pub/sub, service-to-service calls, and locking. **Workflows are not part of ABP's Dapr integration**, and that's fine. Dapr Workflow has its own first-class .NET SDK (`Dapr.Workflow`), and you plug it straight into your ABP app like any other .NET library. So in this article we use the Dapr SDK directly, inside an ABP startup template. + +> **Note:** If you'd like to see deeper Dapr integration in ABP, or you'd like us to build a dedicated piece around Dapr Workflow, feel free to open a new issue on the [ABP GitHub repository](https://github.com/abpframework/abp/issues). Telling us what you need is the best way to help us prioritize it. + +--- + +## What We'll Build + +To keep this concrete, we'll build a small **order processing** workflow, the classic example for this kind of thing. + +The workflow takes an order, checks inventory, charges the customer, then notifies them. If the item is out of stock, it stops early and returns a rejected result. Nothing fancy on the business side, but it's enough to show the parts that matter: how a workflow chains activities, how state survives across steps, and how you start and track an instance. + +Here's the flow we're aiming for: + +- An order comes in with a product, a quantity, and a price +- **Check inventory**: if there isn't enough stock, reject the order and stop +- **Process payment**: charge the customer +- **Notify the customer**: let them know the order went through +- Return a final result + +Each of those steps will be an **activity**, and the workflow is the code that orchestrates them. Let's set up the project and build it. + +## Prerequisites + +Before we start, make sure you have these installed: + +- **.NET 10 SDK** +- **ABP CLI** (the current Studio CLI). Install it with `dotnet tool install -g Volo.Abp.Studio.Cli` (or update with `dotnet tool update -g Volo.Abp.Studio.Cli`) +- **Docker**, running on your machine +- [**Dapr CLI**, initialized once with `dapr init`](https://docs.dapr.io/getting-started/) + +That last step matters. When you run `dapr init` in self-hosted mode, Dapr pulls a few containers (including Redis) and writes a default `statestore.yaml` component. That default state store already has `actorStateStore: "true"` set, which is exactly what Dapr Workflow needs. So once `dapr init` finishes, you can run workflows locally with zero extra configuration. + +![dapr-init-run-result](./dapr-init-run-result.png) + +> **Pro Tip:** If you ever swap the default Redis store for your own component, double-check that it sets `actorStateStore: "true"`. Without it, workflows silently fail to start, and it's the line people forget most often. + +## Create the Project + +In this article I'll create a new layered solution with **EF Core** as the database provider, using the ABP CLI. + +> If you already have an ABP project, you don't need a new one. You can apply the following steps to your existing solution and skip this section. + +Create a new solution named `DaprWorkflowDemo` (or whatever you want): + +```bash +abp new DaprWorkflowDemo +``` + +Once the download finishes, your project boilerplate is ready. Open the solution in your IDE and run the `DaprWorkflowDemo.Web` project once to confirm the app starts and the UI works. + +> Since, we have created the solution via ABP Studio CLI, it automatically runs the initial-tasks, which init database, seed initial data and run `abp install-libs` command, so, no need run the **DbMigrator* project. + +> Default admin username is **admin** and the password is **1q2w3E***. You can use these credentials to login... + +We'll do all the workflow work inside the `DaprWorkflowDemo.Web` project, since that's the running host where the workflow engine connects to the sidecar. + +## Install the Dapr.Workflow Package + +Open a terminal in the `DaprWorkflowDemo.Web` project folder and add the package: + +```bash +dotnet add package Dapr.Workflow +``` + +-> **This single package gives you everything:** the base `Workflow` and `WorkflowActivity` types, the `AddDaprWorkflow` registration helper, and the `DaprWorkflowClient` you use to start and query workflows from code. + +## Define the Workflow and Its Activities + +Now let's write the order processing flow we sketched out earlier. + +First, create a `Workflows` folder in the `DaprWorkflowDemo.Web` project. We'll keep everything there for simplicity. + +Every input and output in a workflow gets serialized to the state store, so the types you pass around should be simple, JSON-friendly records (**_ensure they are serializable!_**). Let's define them: + +```csharp +namespace DaprWorkflowDemo.Web.Workflows; + +public record OrderPayload(string OrderId, string ProductName, int Quantity, decimal TotalPrice); + +public record InventoryResult(bool InStock); + +public record OrderResult(string OrderId, string Status); +``` + +Now the workflow itself. A workflow derives from `Workflow` and reads top to bottom like a normal method, even though every step is durably persisted: + +```csharp +using Dapr.Workflow; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; + +namespace DaprWorkflowDemo.Web.Workflows; + +public class OrderProcessingWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + var logger = context.CreateReplaySafeLogger(); + logger.LogInformation("Starting order {OrderId}: {Quantity} x {ProductName}", + order.OrderId, order.Quantity, order.ProductName); + + // 1. Check inventory + var inventory = await context.CallActivityAsync( + nameof(CheckInventoryActivity), order); + + if (!inventory.InStock) + { + logger.LogWarning("Order {OrderId} rejected: out of stock", order.OrderId); + return new OrderResult(order.OrderId, "Rejected: out of stock"); + } + + // 2. Process the payment + await context.CallActivityAsync(nameof(ProcessPaymentActivity), order); + + // 3. Notify the customer + await context.CallActivityAsync(nameof(NotifyCustomerActivity), order); + + logger.LogInformation("Order {OrderId} completed", order.OrderId); + return new OrderResult(order.OrderId, "Completed"); + } +} +``` + +A couple of things worth pointing out here. + +- `CallActivityAsync` does not invoke the activity directly. It schedules the work with the workflow engine, which records the result once the activity completes. If the process dies right after the payment step, Dapr replays the workflow, feeds it the already-recorded results for the completed steps, and resumes at the notification step. The customer never gets charged twice. This is the **task chaining** pattern. +- Notice the replay-safe logger too. Because the engine replays the workflow to rebuild its state, a normal logger would print the same lines over and over. `context.CreateReplaySafeLogger()` logs only on the first real pass. +- Now the activities. An activity is where the real work happens, and the only place you're allowed to be non-deterministic. It derives from `WorkflowActivity` and supports constructor injection, so you can pull in your ABP services, repositories, or any registered dependency: + +```csharp +using Dapr.Workflow; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; + +namespace DaprWorkflowDemo.Web.Workflows; + +public class CheckInventoryActivity : WorkflowActivity +{ + private readonly ILogger _logger; + + public CheckInventoryActivity(ILogger logger) + { + _logger = logger; + } + + public override Task RunAsync(WorkflowActivityContext context, OrderPayload order) + { + _logger.LogInformation("Checking inventory for {ProductName}", order.ProductName); + + // Pretend we queried a stock service or a repository here. + var inStock = order.Quantity <= 100; + + return Task.FromResult(new InventoryResult(inStock)); + } +} + +public class ProcessPaymentActivity : WorkflowActivity +{ + private readonly ILogger _logger; + + public ProcessPaymentActivity(ILogger logger) + { + _logger = logger; + } + + public override Task RunAsync(WorkflowActivityContext context, OrderPayload order) + { + _logger.LogInformation("Charging {TotalPrice:C} for order {OrderId}", + order.TotalPrice, order.OrderId); + + // Call your real payment provider here. + return Task.FromResult(null); + } +} + +public class NotifyCustomerActivity : WorkflowActivity +{ + private readonly ILogger _logger; + + public NotifyCustomerActivity(ILogger logger) + { + _logger = logger; + } + + public override Task RunAsync(WorkflowActivityContext context, OrderPayload order) + { + _logger.LogInformation("Notifying customer about order {OrderId}", order.OrderId); + + // Send an email, push a notification, publish an event, etc. + return Task.FromResult(null); + } +} +``` + +Each activity is isolated, so Dapr can retry a failed one without re-running the whole workflow. The two activities that don't return anything useful use `object?` as their output type and return `null`. That's why the workflow calls them with the non-generic `CallActivityAsync`, which ignores the result. + +Here's the shape of the process we just wrote: + +![Order processing workflow flowchart](./mermaid2.png) + +## Register the Workflow + +Workflows and activities need to be registered so the engine knows about them. Open your `DaprWorkflowDemoWebModule` class and register them in `ConfigureServices`. Most of the existing code is abbreviated for simplicity: + +```csharp +using DaprWorkflowDemo.Web.Workflows; +using Dapr.Workflow; + +public override void ConfigureServices(ServiceConfigurationContext context) +{ + var hostingEnvironment = context.Services.GetHostingEnvironment(); + var configuration = context.Services.GetConfiguration(); + + // ... existing ABP configuration ... + + //Configure Dapr Workflows... + context.Services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + + options.RegisterActivity(); + options.RegisterActivity(); + options.RegisterActivity(); + }); +} +``` + +> `AddDaprWorkflow` does two things for us. It registers a background worker that connects to the sidecar's workflow engine and hosts your workflow definitions, and it registers a `DaprWorkflowClient` in the dependency injection container so you can start and query workflows from your own code later. + +That's all the wiring. There's no component YAML to write, because Dapr ships a built-in workflow component named `dapr` that runs on top of the actor state store we already have. + +## Run It With the Dapr Sidecar + +Here's the part that's different from a normal `dotnet run`. The workflow engine lives in the Dapr sidecar, so the app has to run **alongside** a sidecar. The Dapr CLI handles that for us. + +> In this section, I assume that you already run `dapr init` command before, as explained above. If you haven't run it yet, please first run it and then follow the instructions/commands below. + +First, make sure your database is migrated (run `DaprWorkflowDemo.DbMigrator` if you haven't). Then, from the `DaprWorkflowDemo.Web` project folder, start the app with Dapr: + +```bash +dapr run --app-id dapr-workflow-demo --dapr-http-port 3500 -- dotnet run +``` + +A few notes on this command: + +- `--app-id` is the identity of your app within Dapr. We'll use it nowhere else in this example, but Dapr needs it. +- `--dapr-http-port 3500` pins the sidecar's HTTP port so we know where to send requests. You can leave it out and let Dapr pick one, but pinning it keeps the next step simple. +- Everything after `--` is the command Dapr runs for your app. `dapr run` injects the sidecar's connection details (like the gRPC port) as environment variables, and the `Dapr.Workflow` worker reads them automatically to connect to the engine. + +Notice we don't pass `--app-port` here. That flag is only needed when Dapr has to call **into** your app (for pub/sub or service invocation). For workflows, your app connects **out** to the sidecar over gRPC, so we don't need it for this scenario. + +Once it's running, you'll see both the ABP app logs and the Dapr sidecar logs in the same terminal. + +## Does It Actually Work? + +The quickest way to test is to talk to the sidecar's **Workflow management HTTP API** directly. This hits Dapr, not your app, which makes it a clean smoke test with no extra endpoint code. + +Start a workflow instance. The component name is `dapr` (the built-in one), the workflow name is the class name, and we pass our own instance ID so it's easy to query: + +```bash +curl -i -X POST "http://localhost:3500/v1.0/workflows/dapr/OrderProcessingWorkflow/start?instanceID=order-001" \ + -H "Content-Type: application/json" \ + -d '{"OrderId":"order-001","ProductName":"Mechanical Keyboard","Quantity":2,"TotalPrice":59.90}' +``` + +The request body is the workflow input, and Dapr passes it straight through to your `OrderPayload`. You should get a `202 Accepted` back with the instance ID: + +```json +{ "instanceID": "order-001" } +``` + +Now query the status of that instance: + +```bash +curl "http://localhost:3500/v1.0/workflows/dapr/order-001" +``` + +After the workflow finishes, you'll see a `COMPLETED` status along with the serialized output: + +```json +{ + "instanceID": "order-001", + "workflowName": "OrderProcessingWorkflow", + "createdAt": "2026-06-29T15:30:15.038490Z", + "lastUpdatedAt": "2026-06-29T15:30:15.360885500Z", + "runtimeStatus": "COMPLETED", + "properties": { + "dapr.workflow.input": "{\"ProductName\":\"Mechanical Keyboard\",\"Quantity\":2,\"OrderId\":\"order-001\",\"TotalPrice\":59.9}", + "dapr.workflow.output": "{\"orderId\":\"order-001\",\"status\":\"Completed\"}" + } +} +``` + +If you check the terminal, you'll also see the log lines from the workflow and each activity in order: + +![dapr-workflow-response](./dapr-workflow-response.png) + +The same management API also lets you `terminate`, `pause`, `resume`, and `purge` instances, and `raiseEvent` to send external events into a waiting workflow. For example: + +```bash +# Permanently delete a finished workflow's state +curl -X POST "http://localhost:3500/v1.0/workflows/dapr/order-001/purge" +``` + +The `DaprWorkflowClient` exposes the same operations in code (terminating, suspending and resuming, purging, and raising external events on an instance), which is the way to go for anything beyond a quick manual test. + +## Triggering Workflows From Your ABP Code + +Hitting the sidecar API by hand is great for a quick check, but in a real app you'll start workflows from your own code, and this is the recommended path. That's what the `DaprWorkflowClient` is for, and `AddDaprWorkflow` already registered it for you. + +You can inject it anywhere, for example into a controller or an application service. Here's a minimal controller in the `DaprWorkflowDemo.Web` project that starts an order and reads its status: + +```csharp +using System.Threading.Tasks; +using DaprWorkflowDemo.Web.Workflows; +using Dapr.Workflow; +using Microsoft.AspNetCore.Mvc; + +namespace DaprWorkflowDemo.Web.Controllers; + +[ApiController] +[Route("api/orders")] +public class OrderController : ControllerBase +{ + private readonly DaprWorkflowClient _workflowClient; + + public OrderController(DaprWorkflowClient workflowClient) + { + _workflowClient = workflowClient; + } + + [HttpPost] + public async Task StartAsync(OrderPayload order) + { + var instanceId = await _workflowClient.ScheduleNewWorkflowAsync( + name: nameof(OrderProcessingWorkflow), + instanceId: order.OrderId, + input: order); + + return Accepted($"/api/orders/{instanceId}", new { instanceId }); + } + + [HttpGet("{instanceId}")] + public async Task GetStatusAsync(string instanceId) + { + var state = await _workflowClient.GetWorkflowStateAsync(instanceId); + + if (state is null || !state.Exists) + { + return NotFound(); + } + + return Ok(new + { + RuntimeStatus = state.RuntimeStatus.ToString(), + Output = state.ReadOutputAs() + }); + } +} +``` + +`ScheduleNewWorkflowAsync` returns immediately and the workflow runs in the background, so this fits the asynchronous request pattern nicely: return `202 Accepted` and let the client poll the status endpoint. + +> One ABP-specific thing to keep in mind: ABP enforces antiforgery validation for unsafe HTTP methods on cookie-authenticated requests. Server-to-server or `curl` calls without an auth cookie usually pass straight through, but if you call the `POST` endpoint from a logged-in browser session and get a `400` antiforgery error, you can relax the auto validation for this controller through `AbpAntiForgeryOptions`, the same way the Elsa articles did for the Elsa endpoints. + +## Going Further + +We built a simple linear flow, but **Dapr Workflow** supports the patterns you'll actually need in production, all in plain C#: + +- **Fan-out / fan-in**: schedule many activities in parallel and aggregate the results (it's just `Select` plus `Task.WhenAll`). +- **External events**: pause a workflow until a human approves something or another system calls back. This is great for approval flows. +- **Timers**: durably wait for minutes, days, or months without holding a thread. +- **Child workflows**: break a big process into smaller workflows with their own history and status. +- **Retry policies**: give an activity an exponential backoff policy so transient failures recover on their own. + +## Conclusion + +**Dapr Workflow** gives you durable execution for long-running processes without bolting a heavy orchestration engine into your code. The process is plain C# that reads top to bottom, Dapr makes it fault-tolerant by replaying from the state store, and the orchestration stays deterministic while the side effects live in activities. + +The nice part for us is that none of this fights with ABP. You create a normal ABP solution, add the `Dapr.Workflow` package, register your workflows in a module, and run with `dapr run`. ABP's own Dapr packages still cover pub/sub, service invocation, and locking, so you can mix all of these in the same solution when you need them. + +All the code in this article is self-contained, so you can copy it into a fresh ABP project and follow along from top to bottom. + +Thanks for reading, see you in the next one! diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/cover-image.png b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/cover-image.png new file mode 100644 index 0000000000..e880a97ab0 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/cover-image.png differ diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-init-run-result.png b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-init-run-result.png new file mode 100644 index 0000000000..38ab1848e5 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-init-run-result.png differ diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-workflow-response.png b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-workflow-response.png new file mode 100644 index 0000000000..a26143b053 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/dapr-workflow-response.png differ diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid1.png b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid1.png new file mode 100644 index 0000000000..c73af2bf63 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid1.png differ diff --git a/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid2.png b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid2.png new file mode 100644 index 0000000000..5f108a4842 Binary files /dev/null and b/docs/en/Community-Articles/2026-06-28-working-with-dapr-workflows/mermaid2.png differ diff --git a/docs/en/Community-Articles/2026-06-29-customizing-the-abp-framework/POST.md b/docs/en/Community-Articles/2026-06-29-customizing-the-abp-framework/POST.md new file mode 100644 index 0000000000..a343173dbf --- /dev/null +++ b/docs/en/Community-Articles/2026-06-29-customizing-the-abp-framework/POST.md @@ -0,0 +1,216 @@ +# Customizing the ABP Framework: A Developer's Guide to LeptonX Theme Overrides in Angular and the Transition to React UI + +Enterprise ASP.NET Boilerplate (ABP) projects rarely stay with default theme behavior for long. At some point, teams need stricter brand alignment, user experience (UX) consistency across modules, or product-specific shell behavior that goes beyond palette and typography tweaks. + +This article explains a practical way to customize the LeptonX theme in Angular projects through two primary layers : + +1. **Style Overriding:** Utilizing design tokens, global CSS custom properties (variables), and component-level styling. +2. **Element Overriding:** Replacing or extending UI fragments and layout pieces using ABP's built-in services. + +Finally, we connect this customization mindset to ABP’s new React direction, where application development teams own more of the user interface (UI) implementation directly from day one. + +## Why Overriding Matters in Real ABP Solutions + +In enterprise software engineering, frontend customization is not a cosmetic task. Instead, it directly supports core technical and architectural goals : + +- **Brand System Compliance:** Enforcing strict color palettes, layouts, and typography across tenant-facing portals and internal back-office administration pages. +- **Accessibility (a11y) Improvements:** Optimizing focus states, color contrast ratios, screen reader compatibility, and keyboard navigation to meet WCAG standards. +- **Product Differentiation:** Structuring distinct top-level layouts, sidebar behavior, and navigation elements to separate multiple products within the same suite. +- **Operational Usability:** Reorganizing application spaces to match domain-specific workflows and simplify intensive data-entry tasks. + +To avoid building fragile CSS overrides that break during framework updates, development teams must follow a strict, highly structured hierarchy of customization : + +| Level | Customization Type | Technical Mechanism | Strategic Role | +| :---: | :--- | :--- | :--- | +| **1** | **Token-Level Variables** | CSS Custom Properties | 🛡️ *First Line of Defense* | +| **2** | **Component-Style Patch** | Class-Based Overrides | 🎨 *Moderate Visual Tweaks* | +| **3** | **Element Replacement** | ReplaceableComponents | 🏗️ *Deep Structural Overrides* | + +Adhering to this hierarchy reduces "style debt" and ensures that theme upgrades remain manageable throughout the application lifecycle. + +### Layer 1: Style Overriding in LeptonX (Angular) + +Style overriding is the safest and most maintainable way to alter your application's presentation layer. The LeptonX engine relies heavily on CSS custom properties (variables) defined at the `:root` level. + +### Customizing Brand Colors and Typography Tokens + +To modify the default colors and branding assets, developers can define custom properties within the global `src/styles.scss` file : + +```scss +:root { + /* Set the primary brand color used on active elements, buttons, and focuses */ + --lpx-brand: #1e3a8a; + + /* Set the physical paths for the application logos */ + --lpx-logo: url('/assets/images/logo.png'); + --lpx-logo-icon: url('/assets/images/logo-icon.png'); /* Displayed when sidebar is collapsed */ +} +``` + +For applications utilizing multi-theme layouts (such as LeptonX Pro's Light, Dark, or Dim modes), variables can be scoped under individual theme classes to dynamically swap brand colors or assets : + +```scss +/* Scoping theme-specific logos to prevent visibility issues on dark backgrounds */ +:root.lpx-theme-dark, :root.lpx-theme-dim { + --lpx-logo: url('/assets/images/logo-light.png'); + --lpx-logo-icon: url('/assets/images/logo-icon-light.png'); +} +``` + +#### Solving the "Visual Branding Blink" on Initial Page Load + +A common issue in production occurs when the default LeptonX logo is briefly displayed on screen before the client browser parses the custom stylesheet. This latency creates a noticeable "blink" or flicker. + +To eliminate this rendering gap, bypass the CSS variable load phase by replacing the physical logo assets inside the web host project's public directory. Write your custom branding files directly to `/images/logo/leptonx/logo-light.png` inside the server's public folder. Because the fallback variable defaults directly to this location, the client browser displays the custom logo asset immediately without waiting to parse the custom CSS rules. + +Additionally, note that styles registered solely in the application's global `styles.scss` may fail to apply to the **Account Layout** (such as the standard login page) because it compiles within an isolated module lifecycle. To ensure your styling overrides apply globally, register the assets and styles in the Virtual File System (VFS) of the.NET backend host, making them universally accessible across all client routing contexts. + +### Layer 2: Element Overriding in LeptonX (Angular) + +When CSS modifications cannot support your required user experience (such as adding search interfaces, custom profile controls, or custom action layouts), teams must override the underlying UI elements. + +ABP provides the `ReplaceableComponentsService` to dynamically replace pre-built layout pieces with custom, project-owned Angular components without breaking core module logic. + +### Troubleshooting the Mobile User Profile Freeze + +In compiled editions of the LeptonX Lite layout library (specifically versions 3.1.x through 4.3.1), developers have identified a rendering bug affecting mobile layouts. When a user logs in via a mobile device and taps the profile dropdown menu, the page freezes. Instead of displaying the profile options, the sidebar area recursively renders a duplicate copy of the active route page. This layout loop completely breaks navigation until the page is refreshed. + +The root cause is a layout bug inside the compiled LeptonX library template (`mn-user-profile.component.html`), where the template markup is wrapped inside an `` tag instead of a structurally neutral `` tag. + +To resolve this issue, you can implement a custom component replacement : + +1. Generate a custom mobile profile component using the Angular CLI + + ```bash + ng g component components/my-mobile-profile + ``` + +2. Implement the component template, ensuring the wrapper elements utilize `` instead of ``. +3. Inject the `ReplaceableComponentsService` into your root `app.component.ts` to swap the underlying component keys during application bootstrap : + + ```tsx + import { Component, OnInit } from '@angular/core'; + import { ReplaceableComponentsService } from '@abp/ng.core'; + import { eThemeLeptonXComponents } from '@volosoft/ngx-lepton-x'; + import { MyMobileUserProfileComponent } from './components/my-mobile-profile.component'; + + @Component({ + selector: 'app-root', + template: '' + }) + export class AppComponent implements OnInit{ + private replaceableComponents = inject(ReplaceableComponentsService); + + ngOnInit() { + this.replaceableComponents.add({ + component: MyMobileUserProfileComponent, + key: eThemeLeptonXComponents.MobileUserProfile + }); + } + } + ``` + + +### Template Context: From LeptonX Demo Setup to Real ABP Application Templates + +When transitioning customized designs from local prototypes to production environments, development teams must choose between two operating modes : + +| **Operational Mode** | **Core Architecture** | **Rationale & Trade-offs** | +| --- | --- | --- | +| **Standard Template Mode** | Consumes LeptonX packages as standard dependencies (`@abp/ng.theme.lepton-x`) from npm registries. All overrides are applied at the application layer. | **Highly Recommended.** Keeps local project codebases clean, simplifies dependency updates, and avoids style debt. | +| **Source-Inspection Mode** | Utilizes the ABP CLI `get-source` command to download the raw theme code and configure temporary local path aliases. | **Diagnostic Only.** Best used for deep debugging, prototyping layout behaviors, or tracing framework-level bugs. | + +### Resolving Strict MIME Type CSS Loading Exceptions + +During local development or initial production deployments of LeptonX Lite Angular applications, browsers may refuse to apply the theme's styles. This issue manifests as a console exception: + +`Refused to apply style from 'http://localhost:4200/bootstrap-dim.css' because its MIME type ('text/html') is not a supported stylesheet MIME type, and strict MIME checking is enabled.` + +This error occurs when the browser requests static layout stylesheets from paths that do not exist, causing the back-end host to return a default 404 HTML fallback page. To resolve this, run the installation command in your client-side workspace : + +```bash +abp install-libs +``` + +This command forces the ABP CLI to parse package dependencies, copy the compiled stylesheets directly into the physical output directories, and make them available to the web server. + +### Deep Implementation: Integrating Theme Source Code and the Upgrade Trade-Off + +For complex enterprise scenarios requiring structural changes that cannot be achieved via standard token configurations or component replacements, developers have the option to bypass compiled packages entirely and integrate the theme’s raw source code. + +### How to Retrieve the Source Code + +ABP Commercial customers have full access to the complete source code of the LeptonX Pro theme. This can be downloaded directly through the ABP Suite user interface or by executing the following command in the ABP CLI within your project directory : + +``` +abp get-source Volo.Abp.LeptonXTheme +``` + +This command downloads the raw C# and Angular source files directly into your local solution structure. Once downloaded, you can modify the underlying HTML templates, restructure Angular modules, and alter core layout scripts to meet your product requirements. + +#### The Upgrade Warning: Maintenance Overhead and Style Debt + +While direct access to the source code provides complete design freedom, it comes with a major warning regarding long-term maintenance : + +- **Bypassing the Update Stream:** Once you replace official package references (such as `@volosoft/abp.ng.theme.lepton-x` or NuGet packages) with local project references, your application is disconnected from the automatic update pipeline. +- **Manual Merge Burden:** When Volosoft releases framework updates, security patches, or compatibility fixes (such as aligning with newer Angular or.NET compiler baselines), these updates will not automatically apply to your customized code. Your team must manually compare, diff, and merge upstream changes, which can introduce regressions and increase technical debt. +- **VFS and APIs as the First Line of Defense:** Before choosing a full source code integration, try using the Virtual File System (VFS) on the backend or standard component replacement APIs in the frontend to override only the specific elements you need to change. This allows you to customize the UI while keeping the rest of your theme packages fully upgradeable. + +### Connecting the Mindset to ABP’s New React Era + +The introduction of the React UI option in ABP 10.4 represents a major architectural shift. While the Angular implementation relies on structured layout packages and runtime component overrides, the React architecture prioritizes **direct developer ownership** of the presentation layer. + +```mermaid +graph TD + %% Styling + classDef react fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#0d47a1; + classDef dotnet fill:#f3e5f5,stroke:#8e24aa,stroke-width:2px,color:#4a148c; + classDef proxy fill:#fff3e0,stroke:#fb8c00,stroke-width:2px,color:#e65100; + classDef tool fill:#f5f5f5,stroke:#757575,color:#333; + + %% React App Box + subgraph ReactApp ["React App Repository"] + C1["Custom Business Components
(Local Source Code)"]:::react + C2["TanStack Router & Query
(Type-Safe Client Routes)"]:::react + + T1["Vite Dev Server & Bundling
(Fast HMR, Vitest)"]:::tool + T2["Tailwind CSS / shadcn/ui
(Accessible UI Components)"]:::tool + + C1 --> C2 + T1 --> T2 + end + + %% Backend Box + subgraph NetCore ["ASP.NET Core Web API Host"] + P1["Dynamic API Client Proxies
(Auto-Generated Endpoints)"]:::proxy + A1["ABP Admin Console
(Delivered via NuGet)"]:::dotnet + + P1 <==> A1 + end + + %% Inter-Repository Flow + ReactApp -- "Generates Dynamic Proxies" --> P1 + + %% Layout Tweaks + style ReactApp fill:#fafafa,stroke:#1e88e5,stroke-width:1px,stroke-dasharray: 5 5; + style NetCore fill:#fafafa,stroke:#8e24aa,stroke-width:1px,stroke-dasharray: 5 5; +``` + +### What Stays Consistent vs. What Changes + +Understanding how patterns transfer between frameworks is key for teams migrating to the React UI: + +- **What Stays Consistent:** Core DDD infrastructure, backend integration, dynamic API proxy generation, multi-tenancy models, and permission-aware routing configurations. +- **What Changes:** Direct ownership of page layouts, faster iteration of UI composition, and modern utility-first styling tools. + +### A New Frontend Philosophy + +In the Angular model, developers import pre-built layouts from compiled packages and selectively override elements using classes or replacing components. While structured, this approach can sometimes feel like "fighting" the framework. + +The React UI model, by contrast, gives developers direct control over the UI components from day one. Standard administrative pages (such as Identity, Tenants, and Settings) are managed separately by the **ABP Admin Console** on the back-end host, while all application layouts and views remain locally in your React project. + +Built with modern tools like **Vite**, **Tailwind CSS**, and **shadcn/ui**, developers can customize and extend components directly in their local source files without needing complex overriding wrappers. + +Additionally, because the layout and page templates reside in local source directories rather than compiled packages, this architecture is highly optimized for AI-driven development. Automated coding agents (such as the ABP Studio AI Agent) can easily inspect and modify local layouts, run API proxy generation, and deploy updates quickly. + +Whether your enterprise solution leverages the structured, component-driven architecture of ABP's Angular UI or is stepping into the modern, developer-owned era of the Vite-powered React UI , establishing an intentional, upgrade-safe customization strategy is crucial. By resolving design changes through token-level custom properties first, documenting structural element overrides, and preparing public-facing technical resources to be highly citable by conversational search agents , development teams can insulate their codebases from technical debt. Ultimately, the transition from rigid theme packages to direct frontend ownership not only streamlines day-to-day software delivery but also ensures that your application framework remains flexible, performant, and visible in an AI-driven ecosystem. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-06-30-state-management-for-angular/POST.md b/docs/en/Community-Articles/2026-06-30-state-management-for-angular/POST.md new file mode 100644 index 0000000000..8028b23923 --- /dev/null +++ b/docs/en/Community-Articles/2026-06-30-state-management-for-angular/POST.md @@ -0,0 +1,344 @@ +# Angular 22 State Management: Signals, SignalStore, or NgRx? + +Angular has been steadily moving toward a signal-first architecture since the introduction of Signals in Angular 16. With Angular 22, that transition reaches another milestone. Signals are now at the center of Angular's reactive programming model, while APIs such as Resource and Signal Forms have matured into production-ready solutions. Combined with the framework's continued investment in zoneless change detection, these improvements significantly influence how Angular applications should manage state. + +This shift also changes the role of NgRx. While the classic NgRx Store remains a powerful solution for large, event-driven applications, many scenarios that previously required reducers, selectors, and effects can now be implemented with much simpler, feature-scoped signal stores. Rather than replacing NgRx, Angular 22 encourages developers to choose the right state management strategy based on the scope and complexity of the problem. + +In this article, we'll explore how Angular 22 changes the state management landscape, compare the classic NgRx Store with NgRx SignalStore, and demonstrate best practices for building modern Angular applications. We'll also discuss how Angular's new reactive APIs fit into enterprise applications and what these changes mean for projects built with the ABP Framework. + +## Why Angular 22 Changes State Management + +Angular Signals introduced a fundamentally different approach by providing fine-grained reactivity built directly into the framework. Instead of propagating changes through Observable streams, Signals allow Angular to track exactly which pieces of state are consumed and update only the affected parts of the UI. This results in more predictable rendering, less boilerplate, and improved runtime performance. + +Angular 22 builds on this foundation by making Signals the preferred reactive primitive throughout the framework. New APIs such as **Resource** for asynchronous data loading and **Signal Forms** for reactive forms integrate naturally with Signals, reducing the need for custom RxJS pipelines in many common scenarios. + +For developers using NgRx, this doesn't mean abandoning existing applications or rewriting every store. Instead, it changes how state management should be approached. Component-local state can often be managed with plain Signals, feature-level state fits naturally into SignalStore, and the classic NgRx Store continues to excel for large-scale applications that benefit from centralized event streams, auditing, and global state synchronization. + +Understanding these changing responsibilities is the key to designing maintainable Angular applications in the Angular 22 era. A practical way to think about state management is to start with the simplest solution and introduce additional abstractions only when the application's complexity requires them. + +## Use Signals for Local Component State + +Plain Angular Signals are ideal for state that belongs exclusively to a single component. Examples include dialog visibility, selected tabs, loading indicators, filter values, or temporary form data. + +Signals provide a straightforward API with minimal overhead and integrate seamlessly with Angular's change detection. For state that never needs to be shared outside a component or its immediate children, introducing a dedicated store often adds unnecessary complexity. + +A settings page often contains UI state that doesn't need to be shared with the rest of the application. Using a dedicated store for this would introduce unnecessary complexity. + +```ts +@Component({...}) +export class UserListComponent { + readonly search = signal(''); + readonly showInactive = signal(false); + + readonly filteredUsers = computed(() => + this.users().filter(user => + user.name.includes(this.search()) && + (this.showInactive() || user.active) + ) + ); +} +``` + +This state is entirely local to the component and doesn't justify introducing a SignalStore. + +## Use NgRx SignalStore for Feature State + +As applications grow, state often needs to be shared across multiple components within the same feature. Examples include user profiles, shopping carts, administration screens, dashboards, or settings pages. + +NgRx SignalStore is designed specifically for these scenarios. It combines Angular Signals with a lightweight, feature-oriented architecture where state, computed values, and business logic are defined together. Instead of scattering logic across reducers, selectors, effects, and services, developers can keep everything related to a feature inside a single store. + +SignalStore also integrates naturally with Angular's signal-based APIs, making it an excellent choice for modern Angular applications built around Resources and Signal Forms. + +A User Management module is shared by multiple pages. The selected user, filters, and loaded entities should remain synchronized across those pages. + +```ts +export const UserStore = signalStore( + withState({ + users: [] as User[], + selectedUserId: null as number | null, + loading: false, + }), + + withComputed(({ users, selectedUserId }) => ({ + selectedUser: computed(() => + users().find(x => x.id === selectedUserId()) + ), + })), + + withMethods((store) => ({ + selectUser(id: number) { + patchState(store, { selectedUserId: id }); + }, + })), +); +``` + +Everything related to the feature lives in one place: state, derived values, and business operations. + +## NgRx Store vs. NgRx SignalStore + +Although both solutions belong to the NgRx ecosystem, they are designed to solve different architectural problems. + +The classic NgRx Store follows the Redux pattern, where every state change is represented by an action that flows through reducers before producing a new immutable state. This explicit, event-driven architecture provides excellent traceability and scales well for applications with extensive global interactions. + +SignalStore takes a different approach. Instead of centering the application around dispatched actions, it treats state as a reactive service built with Angular Signals. A SignalStore typically contains three core building blocks: + +- **State**, which represents the application's reactive data. +- **Computed signals**, which derive values from existing state. +- **Methods**, which encapsulate business logic and state updates. + +This functional model significantly reduces boilerplate while remaining predictable and testable. Since it builds directly on Angular Signals, it also integrates naturally with Angular's fine-grained change detection without requiring selectors or `async` pipes for many common scenarios. + +The following comparison summarizes the strengths of each approach. + + +| Feature | Classic NgRx Store | NgRx SignalStore | +| ---------------- | ------------------------------- | --------------------------------- | +| Architecture | Redux-based global store | Feature-oriented reactive store | +| Reactivity | RxJS Observables | Angular Signals | +| Boilerplate | Higher | Lower | +| State Scope | Global application state | Feature or route state | +| Side Effects | Effects | Store methods or `rxMethod` | +| Change Detection | Observable subscriptions | Native signal reactivity | +| Best For | Large event-driven applications | Modern feature-based applications | + + +For most new Angular 22 applications, SignalStore is an excellent default choice for feature-level state management because it embraces the framework's signal-first architecture while keeping code concise and maintainable. The classic NgRx Store remains indispensable for applications that rely heavily on centralized event processing, global synchronization, or advanced debugging capabilities. + +Instead of asking *"Which one should I use?"*, the better question is *"Which scope of state am I trying to manage?"* The answer usually determines the appropriate solution. + +## Angular 22 Features That Improve State Management + +Angular 22 introduces several framework APIs that naturally complement modern state management patterns. Rather than replacing NgRx, these APIs reduce the amount of custom infrastructure developers previously had to build around it. + +### Resource API + +One of the most significant additions is the **Resource API**, which provides a signal-based approach to asynchronous data loading. + +Historically, fetching remote data in Angular involved coordinating `HttpClient`, RxJS operators, subscriptions, loading flags, and error handling. While these patterns remain valid, they often require considerable boilerplate even for straightforward scenarios. + +Resources encapsulate these concerns into a single reactive abstraction. A Resource automatically tracks the signals it depends on, performs requests when those dependencies change, cancels obsolete requests, and exposes its lifecycle through reactive state such as the current value, loading status, and errors. + +This makes Resources particularly well suited for read-oriented operations where data should stay synchronized with application state. + +For example, changing a selected user ID can automatically trigger a new request without manually wiring `switchMap` or managing subscription lifecycles. + +```tsx +const userResource = httpResource(() => ({ + url: `/api/users/${selectedUserId()}` +})); +``` + +### Signal Forms + +Another major improvement is the stabilization of **Signal Forms**. + +Traditional Reactive Forms expose their state through `FormControl` and `FormGroup` instances, requiring developers to query validation status, dirty state, touched state, and values through an imperative API. + +Signal Forms expose these properties as signals instead. Every field becomes reactive by default, making templates easier to read while eliminating much of the manual state synchronization commonly found in form-heavy applications. + +```html +@if (profileForm.email.invalid() && profileForm.email.touched()) { + Please enter a valid email. +} +``` + +Because field state is already reactive, components rarely need additional subscriptions or helper observables to keep the UI synchronized. + +It's important to note that Signal Forms are responsible for **UI state**, while business operations such as saving data, loading entities, or handling server responses still belong in a dedicated service or SignalStore. Keeping these responsibilities separate results in components that remain focused on presentation while stores continue to own application logic. + +## Best Practices for Building Modern SignalStores + +SignalStore significantly reduces the ceremony traditionally associated with state management, but the same architectural principles still apply. A well-designed store should encapsulate business logic without becoming responsible for concerns that belong elsewhere. + +1. Keep Stores Focused on a Single Feature + A SignalStore should represent a cohesive business feature rather than becoming a global container for unrelated state. + For example, an administration module might expose separate stores for users, roles, and permissions instead of combining all administrative functionality into a single, monolithic store. Smaller stores are easier to test, understand, and maintain over time. +2. Store Business State, Not UI State + Not every piece of state belongs in a store. + Transient UI concerns such as dialog visibility, selected tabs, expanded panels, or temporary input values are usually better managed with plain Signals inside the component. + Stores should own state that represents the application's business domain—entities, filters, permissions, settings, or data shared across multiple components. +3. Derive State Instead of Duplicating It + Whenever possible, compute values instead of storing them. + SignalStore's `withComputed()` feature makes it easy to derive reactive values from existing state, reducing the likelihood of inconsistent or stale data. + Instead of storing both a list of users and an active user count, derive the count directly from the collection. + ```tsx + withComputed(({ users }) => ({ + activeUsers: computed(() => + users().filter(user => user.active).length + ), + })) + ``` + Keeping a single source of truth simplifies updates and reduces maintenance. +4. Prefer Immutable State Updates + Although SignalStore simplifies updates through `patchState()`, state should still be treated as immutable. + Updating only the affected portions of state makes changes predictable and allows Angular's signal system to efficiently notify dependent computations. + ```tsx + patchState(store, { + users: [...store.users(), newUser] + }); + ``` + +## Integrating Resources with SignalStore + +Resources and SignalStore solve different problems, and understanding their responsibilities leads to a cleaner architecture. + +A **Resource** is responsible for synchronizing data with a remote source. It knows how to load data, react to parameter changes, expose loading and error states, and keep requests up to date. + +A **SignalStore**, on the other hand, owns the application's business state. It coordinates operations, exposes domain-specific methods, derives computed values, and serves as the single source of truth for a feature. + +Rather than replacing one another, they work best together. + +A common pattern is to use a Resource for loading entities while allowing the store to expose business operations that modify those entities. + +```tsx +export const UserStore = signalStore( + withState({ + selectedUserId: undefined as number | undefined, + }), + + withComputed(({ selectedUserId }) => ({ + userResource: httpResource(() => { + const id = selectedUserId(); + + return id + ? { + url: `/api/users/${id}`, + } + : undefined; + }), + })), + + withMethods((store) => ({ + selectUser(id: number) { + patchState(store, { + selectedUserId: id, + }); + }, + })), +); +``` + +In this example, changing the selected user automatically causes the Resource to fetch new data. The store doesn't need to manage subscriptions or manually coordinate loading indicators because the Resource already exposes this information through signals. + +This separation keeps data synchronization declarative while allowing the store to remain focused on business behavior. + +## Integrating Signal Forms with SignalStore + +Signal Forms and SignalStore naturally complement one another because both are built on Angular Signals. However, they should not be treated as interchangeable. + +Signal Forms are responsible for managing user input and validation, while SignalStore coordinates business operations such as loading, updating, and persisting data. + +A common workflow consists of four steps: + +1. Load the entity through the store. +2. Populate the Signal Form. +3. Allow the user to edit the data. +4. Submit the updated values back to the store. + +The component remains responsible only for orchestrating the interaction between the form and the store. + +```tsx +@Component({ + // ... +}) +export class UserEditorComponent { + readonly store = inject(UserStore); + + readonly form = form({ + name: '', + email: '', + }); + + async save() { + if (this.form.invalid()) { + return; + } + + await this.store.updateUser(this.form.value()); + } +} +``` + +This approach keeps presentation concerns inside the component while allowing business rules to remain centralized in the store. + +## Handling Asynchronous Operations + +One challenge when combining Signal Forms with SignalStore is coordinating asynchronous operations. + +A form submission typically expects an asynchronous operation to complete before updating its own state. Meanwhile, the store is responsible for managing loading indicators, server errors, and successful updates. + +Instead of placing HTTP requests directly inside components, expose descriptive methods such as `createUser()`, `updateProfile()`, or `changePassword()` from the store. Components simply invoke these methods and react to the outcome. + +This keeps components lightweight while making business logic reusable across multiple views. + +## A Clear Separation of Responsibilities + +A useful guideline is to divide responsibilities as follows: + + +| Concern | Recommended Owner | +| ------------------- | ------------------------------------------ | +| User input | Signal Forms | +| Validation | Signal Forms | +| Loading remote data | Resource | +| Business rules | SignalStore | +| State mutations | SignalStore | +| HTTP persistence | Service or repository invoked by the store | + + +Following these boundaries results in components that focus on presentation, stores that encapsulate business logic, and Resources that handle server synchronization. Each part has a single responsibility, making the application easier to understand, test, and maintain as it grows. + +## Migrating from Classic NgRx to SignalStore + +Migrating to SignalStore doesn't require replacing an entire application's state management strategy overnight. In fact, most enterprise applications can adopt SignalStore incrementally while continuing to use the classic NgRx Store where it provides the greatest value. + +A practical migration strategy is to start with isolated features rather than the application's global state. + +### Keep the Classic Store for Global State + +Global concerns such as authentication, user sessions, application configuration, notifications, and cross-feature communication often continue to benefit from the centralized architecture of the classic NgRx Store. + +These areas typically rely on dispatched actions and event-driven workflows that remain well suited to Redux patterns. + +### Introduce SignalStore for New Features + +New feature modules are excellent candidates for SignalStore. + +Instead of creating actions, reducers, selectors, and effects, developers can define state, computed values, and business methods in a single store. This reduces boilerplate while aligning the feature with Angular's signal-first architecture. + +Existing features can also be migrated gradually as they evolve, avoiding large-scale refactoring efforts. + +### Move Component State First + +The easiest migration is often replacing component-local Observables and `BehaviorSubject`s with Signals. + +Many components don't require a dedicated store at all. Converting temporary UI state to Signals simplifies the codebase immediately and familiarizes teams with Angular's reactive model before introducing SignalStore. + +Incremental adoption minimizes risk while allowing teams to modernize applications at a sustainable pace. + +## What This Means for ABP Applications + +Angular 22's signal-first architecture aligns well with ABP's modular application model. + +Most ABP applications consist of independent feature modules such as Identity, Tenant Management, SaaS, or CMS. These modules naturally map to feature-scoped SignalStores, allowing state and business logic to remain encapsulated within each module. However, the full support will be introduced in the next version. + +As Angular continues investing in Signals, Resources, and Signal Forms, future ABP applications can increasingly rely on the framework's native reactive APIs instead of custom state management patterns. + +This doesn't diminish the importance of RxJS or the classic NgRx Store. RxJS remains an essential foundation of Angular's HTTP infrastructure and many third-party libraries, while the traditional Store continues to provide an excellent solution for complex global state management. + +Instead, Angular 22 encourages developers to use each reactive tool where it provides the greatest value. + +Whether you're upgrading an existing ABP application or starting a new project, adopting SignalStore for feature-level state can simplify development while remaining fully compatible with Angular's evolving ecosystem. + +## Conclusion + +Angular's evolution toward a signal-first architecture represents more than a new reactive API—it changes how applications should be designed. + +Rather than treating every piece of state as part of a centralized store, Angular now encourages developers to choose the appropriate abstraction for each responsibility. Plain Signals excel at local component state, SignalStore provides a lightweight solution for feature-level business logic, Resources simplify server synchronization, and Signal Forms modernize user input management. + +The classic NgRx Store continues to play an important role in large, event-driven applications, but it no longer needs to be the default choice for every state management scenario. + +By embracing these complementary tools, developers can build Angular applications that are simpler to maintain, require less boilerplate, and integrate naturally with the framework's latest capabilities. + +As Angular continues to evolve around Signals and fine-grained reactivity, adopting these patterns today will help applications remain aligned with the framework's direction while providing a solid foundation for future improvements. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/Post.md b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/Post.md new file mode 100644 index 0000000000..0a2a7ea1cb --- /dev/null +++ b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/Post.md @@ -0,0 +1,1330 @@ +# Building Scalable Enterprise Applications with ABP Framework + +If you have ever started an enterprise ASP.NET Core project with good intentions, you already know how the story usually goes. The first version is clean. A few months later, business rules are spread across controllers, repositories start leaking EF Core details everywhere, authorization gets duplicated, and cross-cutting concerns like audit logging, background jobs, and multi-tenancy become expensive retrofits. + +That is exactly the gap ABP Framework tries to close. + +ABP Framework is an open-source application framework for .NET that gives you a strong architectural baseline for building modular, maintainable, and scalable applications. It is opinionated in the right places: Domain-Driven Design, layered architecture, modularity, dependency injection, unit of work, repository abstractions, permission management, and multi-tenancy are already part of the platform instead of being left as team conventions. + +In this article, we will look at what ABP Framework is, why it fits enterprise applications well, how its architecture works, and how to apply it in a real implementation. The examples use a Library Management System, but the same approach works for internal business systems, SaaS products, and large back-office platforms. + +## What Is ABP Framework and Why It Matters for Enterprise Apps + +ABP Framework is a modular application framework built on top of ASP.NET Core. It provides infrastructure and conventions for building business applications without forcing you to rebuild the same plumbing on every project. + +At a practical level, ABP helps with problems enterprise teams hit repeatedly: + +- Managing complex business rules +- Keeping code maintainable as teams grow +- Separating domain logic from infrastructure concerns +- Supporting multiple tenants and deployment models +- Standardizing security, permissions, logging, validation, and settings +- Avoiding boilerplate around common application patterns + +### A short history and philosophy + +ABP was developed by Volosoft and evolved from the earlier ASP.NET Boilerplate ecosystem into a modern .NET framework centered on modularity and DDD-friendly design. + +Its core philosophy is straightforward: + +- Convention over configuration +- Reusable modules over copy-paste architecture +- Clear separation of concerns +- Built-in support for enterprise cross-cutting concerns +- Keep domain code independent from infrastructure + +That combination matters because enterprise applications usually fail from architectural drift, not from missing one more ORM feature. + +### ABP vs plain ASP.NET Core + +Plain ASP.NET Core gives you a solid web framework, but it does not prescribe your application architecture. That flexibility is great for small apps and libraries, but in enterprise systems it often turns into inconsistency. + +With plain ASP.NET Core, you usually need to assemble or define: + +- Layering rules +- Repository and unit of work patterns +- Domain events +- Permission infrastructure +- Multi-tenancy model +- Audit logging +- Background job processing integration +- Modular composition strategy +- DTO conventions and API exposure rules + +ABP gives you these as a coherent whole. + +That does not mean ABP is always the right choice. It means ABP is a better fit when your application has enough business complexity that architecture is no longer optional. + +### When to use ABP / When NOT to use ABP + +Use ABP when: + +- You are building a long-lived business application +- The project needs modularity and team scalability +- You expect complex authorization rules +- Multi-tenancy is a requirement or likely future need +- You want DDD and layered architecture without building all the infrastructure yourself +- You want consistency across multiple services or products + +Do not use ABP when: + +- You are building a tiny CRUD app with a short lifespan +- The team wants ultra-minimal infrastructure and is comfortable creating architecture from scratch +- The domain is trivial and unlikely to grow +- You need a highly custom low-level stack with minimal conventions + +For many enterprise teams, ABP is not about adding complexity. It is about preventing accidental complexity later. + +## Core Architectural Principles Behind ABP + +ABP is not just a package collection. Its real value is the architectural direction it gives your codebase. + +### Domain-Driven Design + +ABP strongly supports Domain-Driven Design concepts: + +- Entities +- Aggregate roots +- Value objects +- Domain services +- Repositories +- Domain events +- Specifications and domain rules + +The key idea is simple: business rules should live in the domain model, not be scattered across controllers, EF Core query code, or UI handlers. + +For example, in a library system, the rule "a book cannot be borrowed if there are no available copies" belongs in the domain layer, ideally in the aggregate root or a domain service. That rule should not depend on a controller or HTTP request. + +### Layered architecture + +A typical ABP layered solution separates responsibilities clearly: + +- Domain: business model and rules +- Application: use cases and orchestration +- Infrastructure: persistence and external integrations +- Presentation: APIs and UI + +This structure reduces coupling and makes code easier to test. It also gives teams a shared mental model: everyone knows where a new piece of logic belongs. + +### Clean Architecture ideas + +ABP aligns well with Clean Architecture principles, especially dependency direction. + +Dependencies should point inward: + +- UI depends on application layer +- Application depends on domain +- Infrastructure implements abstractions defined by inner layers +- Domain should not depend on EF Core, web frameworks, or UI libraries + +That matters in real projects because the domain usually changes more slowly than infrastructure. You can replace EF Core, expose a new API, or add a worker process without rewriting business rules. + +### SOLID principles in practice + +ABP naturally encourages SOLID design: + +- Single Responsibility: application services coordinate, domain objects enforce business rules +- Open/Closed: modules can extend behavior without rewriting existing code +- Liskov Substitution: abstractions like repositories and services are interface-driven +- Interface Segregation: contracts project keeps client-facing abstractions focused +- Dependency Inversion: inner layers define abstractions, outer layers implement them + +### Dependency Injection as a first-class citizen + +ABP uses Microsoft.Extensions.DependencyInjection under the hood, but adds strong conventions and auto-registration support. + +That means less setup code and more consistency. Application services, domain services, repositories, controllers, and many framework components are registered by convention. + + + +![Generated illustration](inline-1.png) + +## Built-in Features of ABP Framework + +ABP ships with a large set of enterprise-oriented features. The important point is not just that these features exist, but that they work together within one application model. + +### Modular system + +The modular system is one of ABP's strongest features. Every module derives from `AbpModule` and declares dependencies with `DependsOn`. + +Example: + +```csharp +[DependsOn( + typeof(AbpIdentityApplicationModule), + typeof(AbpPermissionManagementApplicationModule) +)] +public class LibraryApplicationModule : AbpModule +{ +} +``` + +Why it matters: + +- Encourages bounded contexts +- Makes features reusable +- Supports modular monolith and microservice styles +- Keeps startup configuration organized + +A common mistake is creating too many modules too early. Modules should follow meaningful business boundaries, not every folder. + +### Dependency Injection + +ABP automatically registers many services by convention. You can also use marker interfaces such as: + +- `ITransientDependency` +- `IScopedDependency` +- `ISingletonDependency` + +Example: + +```csharp +public class IsbnGenerator : ITransientDependency +{ + public string Generate() => Guid.NewGuid().ToString("N")[..13]; +} +``` + +This removes repetitive registration code while keeping lifetime choices explicit. + +### Repository pattern + +ABP provides generic repositories like `IRepository`. + +Example use in an application service: + +```csharp +public class BookAppService : ApplicationService +{ + private readonly IRepository _bookRepository; + + public BookAppService(IRepository bookRepository) + { + _bookRepository = bookRepository; + } + + public async Task GetAsync(Guid id) + { + var book = await _bookRepository.GetAsync(id); + return ObjectMapper.Map(book); + } +} +``` + +This keeps the domain and application layers independent from direct EF Core usage. + +### Unit of Work + +ABP applies unit of work automatically for application service methods, repository operations, and many framework workflows. + +Benefits: + +- Transaction management is consistent +- Multiple repository actions can succeed or fail together +- You write less transaction boilerplate + +In practice, this is one of those features you only fully appreciate after working on a project without it. + +### Domain events + +Domain events let your domain model announce important business events without tightly coupling components. + +Example events in a library system: + +- `BookBorrowedEvent` +- `BookReturnedEvent` +- `OverdueNoticeTriggeredEvent` + +Use local domain events when the reaction stays inside the same application boundary. Use distributed events when other modules or services need to react. + +### Entity Framework Core integration + +ABP integrates deeply with EF Core while keeping the dependency in the infrastructure layer. + +You get: + +- DbContext integration +- Code-first migrations +- Repository implementations +- Query support +- Concurrency and common entity conventions + +This is a good balance: you still use EF Core where it fits, but you do not let EF Core define your whole architecture. + +### Multi-tenancy + +ABP supports three common multi-tenancy approaches: + +- Single database: all tenants share tables, separated by `TenantId` +- Database per tenant: each tenant has its own database +- Hybrid: some tenants share, some get dedicated databases + +Entities can implement `IMultiTenant`, and ABP applies tenant filtering automatically. + +Example: + +```csharp +public class Book : AggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; private set; } + public string Title { get; private set; } +} +``` + +This is a major advantage for SaaS applications because tenant-awareness is built into the application model rather than bolted on later. + +### Authorization and permission management + +ABP's permission system is more flexible than hardcoded role checks. + +Instead of writing authorization logic like this everywhere: + +- if user is admin, allow +- else if user has manager role, allow +- else deny + +You define permissions centrally and use them consistently. + +Example: + +```csharp +public static class LibraryPermissions +{ + public const string GroupName = "Library"; + public const string BooksDefault = GroupName + ".Books"; + public const string BooksCreate = GroupName + ".Books.Create"; +} +``` + +Then protect application services with permission attributes or policies. + +This becomes especially valuable when roles differ per tenant or evolve over time. + +### Identity management + +ABP includes the Identity module, which builds on ASP.NET Core Identity and works well with token-based authentication setups. + +You get: + +- Users and roles +- Claims support +- Password and security management +- Integration with external authentication approaches + +### Localization + +Localization is built in through resource files and framework conventions. + +For enterprise systems serving multiple regions, this saves a lot of custom plumbing. + +### Validation + +DTO validation is automatic for many common scenarios. You can use data annotations and integrate FluentValidation when needed. + +This keeps application services focused on use cases rather than repetitive input checks. + +### Audit logging + +Audit logging is essential in enterprise systems, especially in regulated or internal administrative applications. + +ABP can log: + +- Requests +- Method calls +- User actions +- Entity changes +- Exceptions + +That gives teams traceability without rewriting the same logging logic across modules. + +### Exception handling + +ABP standardizes exception handling and maps known exceptions to appropriate HTTP responses. + +That leads to cleaner APIs and more consistent client behavior. + +### Background jobs + +For non-interactive work, ABP supports background jobs and integrations with providers like Hangfire or Quartz. + +Typical uses: + +- Sending overdue reminders +- Rebuilding search indexes +- Generating reports +- Syncing data with external systems + +### Distributed event bus + +When your system grows into multiple modules or services, distributed events help decouple workflows. + +Example: + +- Library service publishes `MemberSuspended` +- Billing service stops auto-renewals +- Notification service sends an email +- Reporting service updates tenant analytics + +### Caching + +ABP supports in-memory and distributed caching. + +Good caching targets include: + +- Permission lookups +- Settings +- Read-heavy reference data +- Tenant-specific configuration + +In real systems, distributed cache usually matters more than memory cache once you have multiple instances behind a load balancer. + +### Setting management + +ABP supports application and tenant-level settings. + +That is useful for configurable enterprise software where behavior differs between customers or environments. + +Examples: + +- Max borrow days per tenant +- SMTP settings +- Branding settings +- Feature limits + +## Understanding the ABP Project Structure + +One of the most useful things ABP gives new teams is a solution structure that already reflects good architectural boundaries. + +Let us walk through the common projects in a layered solution. + +### `MyProject.Domain` + +This is the heart of the business model. + +Typical contents: + +- Entities +- Aggregate roots +- Value objects +- Domain services +- Repository interfaces +- Domain events +- Business rules + +Why it exists: + +- Keeps business logic independent from infrastructure +- Makes the domain testable in isolation +- Prevents EF Core or HTTP concerns from leaking into core rules + +### `MyProject.Application` + +This layer implements use cases. + +Typical contents: + +- Application services +- Orchestration logic +- DTO mapping +- Permission checks +- Transaction boundaries through unit of work + +Why it exists: + +- Coordinates domain objects and repositories +- Exposes business capabilities to clients cleanly +- Keeps controllers thin or fully unnecessary in many cases + +### `MyProject.Application.Contracts` + +This project is the public contract of the application layer. + +Typical contents: + +- DTOs +- Application service interfaces +- Permission definitions +- Setting definitions + +Why it exists: + +- Allows clients to depend on contracts without depending on implementation +- Keeps API models explicit +- Helps generated clients and UI layers stay decoupled + +### `MyProject.EntityFrameworkCore` + +This is the EF Core integration layer. + +Typical contents: + +- DbContext +- EF Core entity configuration +- Migration files +- Repository implementations + +Why it exists: + +- Contains persistence-specific code +- Prevents infrastructure concerns from contaminating the domain +- Lets you switch or extend persistence strategy more cleanly + +### `MyProject.HttpApi` + +This project exposes the application to HTTP clients. + +Typical contents: + +- API controllers +- API configuration +- Swagger integration +- HTTP endpoint concerns + +Why it exists: + +- Separates transport concerns from use case implementation +- Makes the application accessible to web, mobile, and external systems + +### `MyProject.HttpApi.Client` + +This project usually contains generated or reusable client-side proxies. + +Why it exists: + +- Simplifies service-to-service or UI-to-API communication +- Reduces manual HTTP client boilerplate +- Keeps clients aligned with the server contract + +### `MyProject.Web` + +This is the presentation layer for MVC, Razor Pages, Blazor, or similar UI approaches. + +Why it exists: + +- Contains user-facing concerns +- Reuses application contracts or API clients +- Keeps UI code out of business and persistence layers + +### `MyProject.DbMigrator` + +This project is easy to underestimate, but it matters a lot in deployment. + +Typical responsibilities: + +- Apply database migrations +- Seed initial data +- Prepare tenant databases + +Why it exists: + +- Decouples schema upgrade from web app startup +- Fits better into CI/CD pipelines +- Makes production deployment safer and more predictable + +## Building a Sample Enterprise Project: Library Management System + +To make this concrete, let us build a simplified Library Management System. + +The goal is not to show every file. The goal is to show how ABP's architecture shapes a realistic implementation. + +### Domain design + +We will model: + +- `Book` as an aggregate root +- `Author` as a related entity or separate aggregate depending on your domain needs +- `Loan` as another aggregate root +- `Isbn` as a value object + +### Creating an aggregate root + +A `Book` should protect its own invariants. + +```csharp +public class Book : AggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; private set; } + public string Title { get; private set; } + public string Isbn { get; private set; } + public int TotalCopies { get; private set; } + public int BorrowedCopies { get; private set; } + + protected Book() + { + } + + public Book(Guid id, Guid? tenantId, string title, string isbn, int totalCopies) + : base(id) + { + TenantId = tenantId; + Title = Check.NotNullOrWhiteSpace(title, nameof(title)); + Isbn = Check.NotNullOrWhiteSpace(isbn, nameof(isbn)); + TotalCopies = totalCopies; + BorrowedCopies = 0; + } + + public void Borrow() + { + if (BorrowedCopies >= TotalCopies) + { + throw new BusinessException("Library:NoAvailableCopies"); + } + + BorrowedCopies++; + } + + public void Return() + { + if (BorrowedCopies <= 0) + { + throw new BusinessException("Library:InvalidReturn"); + } + + BorrowedCopies--; + } +} +``` + +This is a good example of domain logic staying inside the aggregate instead of being spread across services. + +### Adding a value object + +If you want stronger modeling, represent ISBN as a value object rather than a string. + +```csharp +public class Isbn : ValueObject +{ + public string Value { get; private set; } + + private Isbn() + { + } + + public Isbn(string value) + { + Value = Check.NotNullOrWhiteSpace(value, nameof(value)); + } + + protected override IEnumerable GetAtomicValues() + { + yield return Value; + } +} +``` + +Use value objects when they carry meaning, validation, and equality semantics. Do not create them just to look architecturally sophisticated. + +### Repository interface + +In the domain layer, define repository abstractions when needed. + +```csharp +public interface IBookRepository : IRepository +{ + Task FindByIsbnAsync(string isbn); +} +``` + +Then implement the repository in `MyProject.EntityFrameworkCore`. + +### Application service + +The application service orchestrates the use case. + +```csharp +public class BookAppService : ApplicationService, IBookAppService +{ + private readonly IBookRepository _bookRepository; + + public BookAppService(IBookRepository bookRepository) + { + _bookRepository = bookRepository; + } + + public async Task CreateAsync(CreateBookDto input) + { + var book = new Book( + GuidGenerator.Create(), + CurrentTenant.Id, + input.Title, + input.Isbn, + input.TotalCopies + ); + + await _bookRepository.InsertAsync(book, autoSave: true); + + return ObjectMapper.Map(book); + } +} +``` + +Notice what the application service does and does not do. + +It does: + +- Receive DTO input +- Create domain objects +- Call repositories +- Return DTO output +- Use tenant context and infrastructure abstractions + +It does not: + +- Contain persistence details +- Manually manage SQL or DbContext +- Hold core borrowing rules + +### DTOs and contracts + +ABP encourages clear application contracts. + +```csharp +public class CreateBookDto +{ + [Required] + public string Title { get; set; } + + [Required] + public string Isbn { get; set; } + + [Range(1, 1000)] + public int TotalCopies { get; set; } +} + +public class BookDto : EntityDto +{ + public string Title { get; set; } + public string Isbn { get; set; } + public int TotalCopies { get; set; } + public int BorrowedCopies { get; set; } +} +``` + +### AutoMapper profile + +Mapping stays centralized. + +```csharp +public class LibraryApplicationAutoMapperProfile : Profile +{ + public LibraryApplicationAutoMapperProfile() + { + CreateMap(); + } +} +``` + +### CRUD APIs + +ABP can expose application services as APIs with minimal boilerplate, depending on your setup. + +That means common CRUD operations do not require hand-written controllers unless you need custom behavior. + +Typical endpoints: + +- `GET /api/app/books/{id}` +- `GET /api/app/books` +- `POST /api/app/books` +- `PUT /api/app/books/{id}` +- `DELETE /api/app/books/{id}` + +### Authentication and permissions + +For the library example, define permissions such as: + +- `Library.Books` +- `Library.Books.Create` +- `Library.Books.Edit` +- `Library.Books.Delete` +- `Library.Loans.Create` +- `Library.Loans.Return` + +Then apply them at the application service or method level. + +### Database migration + +Once entities are mapped in EF Core, generate migrations and run them through the `DbMigrator` project. + +That approach is safer than relying on app startup to modify production schema. + + + +![Generated illustration](inline-2.png) + +## Dependency Injection in ABP + +ABP makes DI feel almost invisible, which is usually a good sign. + +### Lifetimes explained + +#### Transient + +A new instance is created each time it is requested. + +Use transient for: + +- Application services +- Domain services +- Stateless helpers + +#### Scoped + +One instance is created per request or scope. + +Use scoped for: + +- DbContext +- Request-specific services +- Services that depend on scoped state + +#### Singleton + +One instance exists for the lifetime of the application. + +Use singleton for: + +- Stateless shared services with no scoped dependencies +- Caches with thread-safe behavior +- Expensive-to-create infrastructure components + +### Automatic service registration + +ABP registers many services by convention. + +Examples include: + +- Types deriving from `ApplicationService` +- Types deriving from `DomainService` +- Implementations using marker interfaces like `ITransientDependency` +- Controllers and other framework-integrated services + +This reduces startup clutter and nudges teams toward consistent patterns. + +### A practical warning about lifetimes + +The usual DI rules still apply. + +Do not inject scoped services into singletons. Do not store request state inside singleton services. If a service needs tenant-aware or user-aware context, it is almost never a singleton. + +## Domain-Driven Design with ABP + +ABP is one of the better .NET frameworks for teams that want DDD support without building an entire internal platform. + +### Entities and aggregate roots + +An entity has identity. An aggregate root is the consistency boundary. + +In the library example: + +- `Book` is an aggregate root if it owns borrowing-related invariants +- `Loan` can be another aggregate root if loan lifecycle has its own rules + +A useful rule of thumb: if a change must preserve invariants atomically, it probably belongs inside one aggregate. + +### Domain services + +Use a domain service when business logic does not fit naturally inside a single entity. + +Example: + +- Evaluating whether a member can borrow based on overdue items, membership level, and tenant settings + +That logic is still domain logic, but may span multiple aggregates or policies. + +### Domain events + +Domain events help you model side effects cleanly. + +A typical flow might be: + +1. A loan is created. +2. The `Book` aggregate updates available copies. +3. A domain event is raised. +4. A handler schedules a notification or updates read models. + +This avoids giant application service methods that try to do everything directly. + +### Repositories + +Repositories should represent aggregate access, not become a dumping ground for every imaginable query. + +Good repository methods: + +- Find a book by ISBN +- Get active loans for a member +- Load an aggregate for a transaction + +Less good repository methods: + +- Huge query APIs mixing reporting, filtering, exports, and admin dashboard logic + +For reporting-heavy scenarios, consider dedicated query services or projections. + +### Specifications + +Specifications are useful when domain selection rules become complex and reusable. + +Examples: + +- Members eligible for renewal +- Loans overdue by more than 7 days +- Books that can be archived + +ABP does not force a single specification implementation style, but the pattern fits well when query rules need to stay explicit and reusable. + +### DDD flow in prose + +A typical request flow in an ABP DDD application looks like this: + +1. An HTTP request reaches the API layer. +2. The API layer calls an application service. +3. The application service validates input, checks permissions, and starts the use case. +4. Repositories load aggregates. +5. Aggregates and domain services enforce business rules. +6. Changes are persisted inside a unit of work. +7. Domain events trigger follow-up behavior. +8. The application service returns DTOs to the client. + +That flow is predictable, testable, and much easier to maintain than controller-centric business logic. + +## Multi-Tenancy in ABP + +Multi-tenancy is one of those features that is painful to retrofit. ABP handles it as a foundational concern. + +### Single database approach + +All tenants share the same database and usually the same tables. Rows are separated by `TenantId`. + +Pros: + +- Simple deployment +- Lower infrastructure cost +- Easy onboarding for new tenants + +Cons: + +- Lower isolation +- Noisy-neighbor risks at scale +- Harder to tune per tenant + +This works well for small to mid-sized SaaS applications. + +### Database per tenant + +Each tenant gets its own database. + +Pros: + +- Stronger data isolation +- Easier per-tenant backup and restore +- Better fit for regulated customers + +Cons: + +- More operational complexity +- More connection and migration management +- Higher cost + +This is common in enterprise SaaS where large customers demand isolation. + +### Hybrid approach + +Some tenants share infrastructure, while large or regulated tenants get dedicated databases. + +Pros: + +- Flexible commercial model +- Better cost/isolation balance +- Easier migration path from shared to dedicated + +Cons: + +- More operational complexity than either pure model + +### How ABP supports multi-tenancy + +ABP provides: + +- Tenant resolution pipeline +- Tenant-aware entities via `IMultiTenant` +- Automatic data filters +- Per-tenant settings and permissions +- Support for different tenant database strategies + +In practice, this means your code can stay mostly business-focused while the framework handles tenant context propagation. + +### Multi-tenancy pitfalls + +Be careful with: + +- Caching keys that ignore tenant context +- Background jobs that accidentally execute under the wrong tenant +- Reports or batch operations that bypass filters unintentionally +- Shared resources that should actually be tenant-specific + +Multi-tenancy bugs are often subtle because the app works fine in single-tenant local development. + + + +![Generated illustration](inline-3.png) + +## Authentication and Authorization in ABP + +Authentication proves who the user is. Authorization decides what they can do. ABP supports both well, but its real strength is authorization. + +### Identity module + +ABP's Identity module gives you user, role, and claim management on top of ASP.NET Core Identity. + +This covers most enterprise basics out of the box. + +### OpenIddict and token-based authentication + +For API-centric systems, OpenIddict is a strong option in the ABP ecosystem for issuing tokens and supporting standard authentication flows. + +For SPA or mobile clients, JWT-based authentication is a common setup. + +A typical flow looks like this: + +1. The client authenticates against the identity server or auth endpoint. +2. A token is issued. +3. The client calls ABP HTTP APIs with the token. +4. ABP resolves the user, roles, claims, and permissions. +5. Application service methods enforce authorization rules. + +### Role management vs permission management + +Roles are useful, but permissions are the more precise tool. + +Prefer this approach: + +- Define permissions by capability +- Assign permissions to roles +- Let tenants customize role-permission mappings + +That is much more maintainable than hardcoding logic around role names in application code. + +### Claims + +Claims are useful for identity context and external provider integration, but they should not replace a clear permission model. + +Use claims for identity data. Use permissions for business capabilities. + +## Developing a Custom ABP Module: InventoryModule + +A custom module is a good way to package a coherent business capability. + +Let us imagine an `InventoryModule` for tracking stock movement across branches. + +### Define the module + +```csharp +[DependsOn( + typeof(AbpDddApplicationModule), + typeof(AbpEntityFrameworkCoreModule) +)] +public class InventoryModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Register services, configure options, add mappings + } +} +``` + +### Why module boundaries matter + +A good module should: + +- Have a clear business purpose +- Expose explicit contracts +- Depend only on what it actually needs +- Avoid reaching directly into internal details of sibling modules + +### Service registration and configuration + +Inside a module you typically configure: + +- AutoMapper profiles +- Permission definitions +- Localization resources +- Background jobs or event handlers +- Repository implementations + +### API exposure + +A module can expose its own application services and APIs. That makes it easier to reuse in a modular monolith or extract later into a separate service if needed. + +This is one of the most practical advantages of ABP's design: module boundaries can become future service boundaries if your system evolves. + + + +![Generated illustration](inline-4.png) + +## Performance Optimization in ABP Applications + +ABP gives you strong architecture, but architecture alone does not guarantee performance. + +### Use distributed cache for shared deployments + +If your app runs on multiple instances, prefer distributed cache for data that must be shared consistently. + +Good candidates: + +- Settings +- Permission snapshots +- Tenant-specific configuration +- Frequently accessed reference data + +Redis is a common choice. + +### Use memory cache carefully + +Memory cache is fine for: + +- Small local read optimizations +- Data that can tolerate per-instance variance +- Lightweight computed values + +Do not rely on memory cache for data that must stay synchronized across instances. + +### Favor async programming end to end + +Use async repository, application service, and external I/O calls consistently. + +The main benefit is not raw speed. It is better scalability under concurrent load. + +### Optimize repository usage + +Common performance mistakes include: + +- Loading full aggregates for simple list screens +- N+1 query patterns +- Missing pagination +- Performing row-by-row updates for bulk operations + +Better patterns: + +- Project directly to DTOs for read-heavy endpoints +- Paginate lists aggressively +- Use batch operations where appropriate +- Keep aggregate loading focused on business transactions + +### Pagination and filtering + +Never return unbounded lists in enterprise APIs. + +Even if the first tenant has 200 records, the fifth customer may have 20 million. + +### Batch operations + +For imports, exports, and scheduled processing: + +- Chunk large workloads +- Use background jobs when user interaction is not required +- Avoid long-running web requests + +## Testing Enterprise Applications with ABP + +ABP's layered structure makes testing easier because responsibilities are clearer. + +### Unit testing + +Best targets for unit tests: + +- Entities +- Value objects +- Domain services +- Business rules + +These tests should run without web or database dependencies whenever possible. + +### Integration testing + +Use integration tests for: + +- Application services +- Repository behavior +- Authorization flows +- Module wiring +- Transaction behavior + +ABP provides test base infrastructure that helps bootstrap realistic test contexts. + +### Mock repositories vs real database testing + +Use mocks when you want fast, focused tests around business logic. + +Use real database-backed tests when you need confidence in: + +- EF Core mappings +- Query behavior +- Transaction boundaries +- Multi-tenancy filters + +A practical strategy is to keep most domain tests fast and add a smaller set of integration tests around high-risk workflows. + +### What to test first + +If time is limited, prioritize: + +- Aggregate invariants +- Permission-sensitive application services +- Tenant-aware behavior +- Critical workflows such as order placement, payment, borrowing, or approval paths + +## Deployment Considerations for ABP Applications + +Enterprise deployment is where clean architecture starts paying off. + +### Docker + +Containerizing ABP applications is usually straightforward and gives you consistent runtime environments. + +Typical container targets: + +- Web UI +- HTTP API host +- Auth server if separated +- Background worker +- DbMigrator as a job or pipeline step + +### Kubernetes + +Kubernetes makes sense when you need: + +- Horizontal scaling +- Rolling deployments +- Service isolation +- Centralized operations across environments + +It is powerful, but also operationally expensive. Do not adopt it just because it sounds modern. + +### Azure, IIS, and Linux + +ABP applications remain standard .NET applications, so they can run in familiar hosting environments: + +- Azure App Service or containers +- IIS for traditional Windows-hosted setups +- Linux VMs or container platforms + +Choose based on your team's operational maturity and constraints, not trends. + +### CI/CD + +A practical pipeline usually includes: + +1. Build and run tests +2. Package the application +3. Run database migrations through `DbMigrator` +4. Deploy services +5. Run smoke checks + +### Database migration strategy + +Do not leave schema updates to chance. + +For enterprise environments: + +- Version migrations carefully +- Run them in controlled deployment steps +- Seed tenant and host data explicitly +- Test rollback assumptions before production day + +## Common Challenges in ABP Projects + +ABP solves many problems, but it does not remove the need for design discipline. + +### Module dependency issues + +A common mistake is creating circular dependencies between modules. + +Avoid this by: + +- Defining clean contracts +- Depending on abstractions, not internals +- Moving shared concepts into a lower-level shared module only when necessary + +### Circular dependencies in layers + +The classic smell looks like this: + +- Domain depends on Application +- Application depends on Infrastructure +- Infrastructure depends back on Domain details in the wrong direction + +Stick to the dependency direction and the problem mostly disappears. + +### Performance bottlenecks + +ABP's abstractions do not automatically prevent slow queries. + +Watch for: + +- Over-fetching with repositories +- Poor indexing +- Heavy tenant-shared tables +- Chatty distributed calls + +### Incorrect repository usage + +A repository is not a replacement for every query pattern. + +Use repositories for aggregate access and domain-oriented persistence. For analytics, dashboards, or reporting screens, dedicated read models are often cleaner. + +### Multi-tenancy pitfalls + +The biggest mistakes are usually operational rather than syntactic: + +- Data leaks due to wrong tenant context +- Cross-tenant caches +- Misconfigured connection strings +- Jobs running against the host instead of the intended tenant + +## Best Practices for Building Scalable ABP Applications + +These are the habits that consistently pay off. + +### Keep application services thin + +Application services should coordinate the use case, not become the business logic layer. + +### Put business rules in the domain layer + +If a rule matters to the business, it should not live only in a controller, UI component, or ad hoc EF query. + +### Avoid direct DbContext usage outside repositories + +You can always bypass abstractions, but you usually pay for it later with inconsistency and harder tests. + +### Design reusable modules + +A module should represent a coherent capability, not just a code folder with a nice name. + +### Use DTOs instead of exposing entities + +Entities are domain objects, not API contracts. + +Exposing entities directly couples clients to your internal model and makes change expensive. + +### Prefer permissions over hardcoded authorization logic + +Permissions scale better than role-name checks spread across the codebase. + +### Be intentional about aggregate boundaries + +Do not make aggregates too large or too anemic. + +Large aggregates hurt performance and concurrency. Weak aggregates let invariants leak into services. + +### Start simple, then modularize where it matters + +ABP supports a lot, but you do not need every pattern at maximum depth on day one. Use the framework to create clear boundaries early, then refine where complexity actually appears. + +## TL;DR + +- ABP Framework gives enterprise .NET teams a strong architectural baseline with modularity, DDD support, multi-tenancy, permissions, and built-in cross-cutting features. +- Its layered project structure helps keep business rules in the domain, use cases in application services, and infrastructure concerns isolated. +- For real systems, ABP shines when you need consistency across modules, tenant-aware behavior, maintainability, and long-term scalability. +- The biggest wins come from using ABP as intended: thin application services, rich domain logic, clean module boundaries, and disciplined repository usage. +- ABP does not replace good design, but it removes a huge amount of architectural boilerplate so teams can focus on business problems. \ No newline at end of file diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/cover.png b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/cover.png new file mode 100644 index 0000000000..55c2950d28 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/cover.png differ diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-1.png b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-1.png new file mode 100644 index 0000000000..65cb32d200 Binary files /dev/null and b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-1.png differ diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-2.png b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-2.png new file mode 100644 index 0000000000..55193a127d Binary files /dev/null and b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-2.png differ diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-3.png b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-3.png new file mode 100644 index 0000000000..839be08ebd Binary files /dev/null and b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-3.png differ diff --git a/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-4.png b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-4.png new file mode 100644 index 0000000000..3d614eca2f Binary files /dev/null and b/docs/en/Community-Articles/2026-07-03-building-scalable-enterprise-applications-with-abp/inline-4.png differ diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 32c47a832e..118e4bb264 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -1111,6 +1111,10 @@ "text": "Dynamic C# API Clients", "path": "framework/api-development/dynamic-csharp-clients.md" }, + { + "text": "IdentityModel Clients", + "path": "framework/api-development/identitymodel-clients.md" + }, { "text": "Integration Services", "path": "framework/api-development/integration-services.md" @@ -1354,6 +1358,10 @@ "text": "Auth", "path": "framework/ui/mvc-razor-pages/javascript-api/auth.md" }, + { + "text": "Clock", + "path": "framework/ui/mvc-razor-pages/javascript-api/clock.md" + }, { "text": "Current User", "path": "framework/ui/mvc-razor-pages/javascript-api/current-user.md" @@ -1657,6 +1665,10 @@ "text": "Localization", "path": "framework/ui/angular/localization.md" }, + { + "text": "Document Title Strategy", + "path": "framework/ui/angular/title-strategy.md" + }, { "text": "Hybrid Localization", "path": "framework/ui/angular/hybrid-localization.md" @@ -1876,6 +1888,14 @@ "text": "Card", "path": "framework/ui/angular/card-component.md" }, + { + "text": "Tree", + "path": "framework/ui/angular/tree-component.md" + }, + { + "text": "Lookup Search", + "path": "framework/ui/angular/lookup-search-component.md" + }, { "text": "Dynamic Forms", "path": "framework/ui/angular/dynamic-form-module.md" @@ -1884,6 +1904,10 @@ "text": "Password Complexity Indicator", "path": "framework/ui/angular/password-complexity-indicator-component.md" }, + { + "text": "Commercial UI Components (Pro)", + "path": "framework/ui/angular/commercial-ui.md" + }, { "text": "Lookup Components(Pro)", "path": "framework/ui/angular/lookup-components.md" @@ -2080,6 +2104,10 @@ "text": "MongoDB", "path": "framework/data/mongodb" }, + { + "text": "In-Memory Database", + "path": "framework/data/memorydb" + }, { "text": "Dapper", "path": "framework/data/dapper" @@ -2615,6 +2643,10 @@ "text": "Background Jobs", "path": "modules/background-jobs.md" }, + { + "text": "Blogging", + "path": "modules/blogging.md" + }, { "text": "Chat (Pro)", "path": "modules/chat.md" @@ -2691,7 +2723,7 @@ }, { "text": "URL Forwarding System", - "path": "modules/cms-kit-pro/URL-forwarding.md" + "path": "modules/cms-kit-pro/url-forwarding.md" }, { "text": "Poll System", @@ -2733,7 +2765,19 @@ }, { "text": "Identity", - "path": "modules/identity.md" + "isLazyExpandable": true, + "path": "modules/identity.md", + "items": [ + { + "text": "Overview", + "path": "modules/identity.md", + "isIndex": true + }, + { + "text": "User Lookup and Synchronization", + "path": "modules/identity/user-synchronization.md" + } + ] }, { "text": "Identity (Pro)", diff --git a/docs/en/framework/api-development/auto-controllers.md b/docs/en/framework/api-development/auto-controllers.md index 2c6f0ee39a..ee6d790e22 100644 --- a/docs/en/framework/api-development/auto-controllers.md +++ b/docs/en/framework/api-development/auto-controllers.md @@ -93,6 +93,16 @@ Then the route for getting a book will be '**/api/volosoft/book-store/book/{id}* * Normalization can be customized by setting the `UrlActionNameNormalizer` option. It's an action delegate that is called for every method. * If there is another parameter with 'Id' postfix, then it's also added to the route as the final route segment (like '/phoneId'). +When the `UrlControllerNameNormalizer` option is not set, the final controller name also removes suffixes configured in `AbpConventionalControllerOptions.IgnoredUrlSuffixesInControllerNames` (a custom normalizer replaces this ignored-suffix step, so the ignored suffixes are not applied). The default list contains `Integration`, so `PaymentIntegrationService` uses `payment` as its controller route name. You can replace the list when another suffix convention is required: + +```csharp +Configure(options => +{ + options.IgnoredUrlSuffixesInControllerNames = + ["Integration", "Endpoint"]; +}); +``` + #### Customizing the Route Calculation `IConventionalRouteBuilder` is used to build the route. It is implemented by the `ConventionalRouteBuilder` by default and works as explained above. You can replace/override this service to customize the route calculation strategy. @@ -223,4 +233,4 @@ services.Configure(options => ```` ## See Also -* [Video tutorial](https://abp.io/video-courses/essentials/auto-api-controllers) \ No newline at end of file +* [Video tutorial](https://abp.io/video-courses/essentials/auto-api-controllers) diff --git a/docs/en/framework/api-development/dynamic-csharp-clients.md b/docs/en/framework/api-development/dynamic-csharp-clients.md index d560b536eb..bfacc26886 100644 --- a/docs/en/framework/api-development/dynamic-csharp-clients.md +++ b/docs/en/framework/api-development/dynamic-csharp-clients.md @@ -209,6 +209,46 @@ Using `asDefaultServices: false` may only be needed if your application has alre > If you disable `asDefaultServices`, you can only use `IHttpClientProxy` interface to use the client proxies. See the *IHttpClientProxy Interface* section above. +### Before Sending a Proxy Request + +`AbpHttpClientOptions.AddPreSendAction` registers an action for a named remote service. It receives the proxy configuration, the current request context and the `HttpClient`, and runs immediately before each proxy request is sent. + +````csharp +Configure(options => +{ + options.AddPreSendAction( + "BookStore", + (_, requestContext, httpClient) => + { + if (requestContext.Action.Name == "GetReportAsync") + { + httpClient.Timeout = TimeSpan.FromMinutes(2); + } + } + ); +}); +```` + +### Custom Parameter Converters + +Dynamic proxies normally use the built-in conversion rules for query-string, form-data and path values. Implement `IObjectToQueryString`, `IObjectToFormData` or `IObjectToPath` when a type requires custom serialization, register the implementation in dependency injection, and map the value type to the converter: + +````csharp +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); + +Configure(options => +{ + options.QueryStringConverts[typeof(MyFilter)] = + typeof(MyFilterToQueryString); + options.FormDataConverts[typeof(MyUploadMetadata)] = + typeof(MyUploadMetadataToFormData); + options.PathConverts[typeof(MyStrongId)] = + typeof(MyStrongIdToPath); +}); +```` + ### Retry/Failure Logic & Polly Integration If you want to add retry logic for the failing remote HTTP calls for the client proxies, you can configure the `AbpHttpClientBuilderOptions` in the `PreConfigureServices` method of your module class. diff --git a/docs/en/framework/api-development/identitymodel-clients.md b/docs/en/framework/api-development/identitymodel-clients.md new file mode 100644 index 0000000000..754c4816bc --- /dev/null +++ b/docs/en/framework/api-development/identitymodel-clients.md @@ -0,0 +1,101 @@ +```json +//[doc-seo] +{ + "Description": "Configure ABP IdentityModel clients for server-to-server access tokens, tenant-aware client selection, and request customization." +} +``` + +# IdentityModel Clients + +The `Volo.Abp.IdentityModel` package obtains access tokens for server-to-server HTTP calls. `AbpIdentityModelModule` binds the `IdentityClients` configuration section to `AbpIdentityClientOptions`. + +## Installation + +Install the `Volo.Abp.IdentityModel` NuGet package in the project that obtains the tokens: + +````shell +abp add-package Volo.Abp.IdentityModel +```` + +The command adds the package and the `AbpIdentityModelModule` dependency to the module class. + +## Configure Identity Clients + +Define a `Default` client and any named clients in the application configuration: + +````json +{ + "IdentityClients": { + "Default": { + "GrantType": "client_credentials", + "ClientId": "MyProject_Backend", + "ClientSecret": "your-client-secret", + "Authority": "https://localhost:44301/", + "Scope": "MyProject" + }, + "Reporting": { + "GrantType": "client_credentials", + "ClientId": "MyProject_Reporting", + "ClientSecret": "your-reporting-client-secret", + "Authority": "https://localhost:44301/", + "Scope": "Reporting" + } + } +} +```` + +When a client name is requested for the current tenant, ABP selects the first available configuration in this order: + +1. `.` +2. `.` +3. `` +4. `Default` + +If no client name is supplied, ABP uses `Default` as the client name. Tenant-specific entries let a tenant use different credentials without changing the consuming service. For example, `Reporting.8e6fcd0a-75ab-4d94-90f4-9a2503d0e70c` overrides the `Reporting` client for that tenant ID. + +Pass the client name to `TryAuthenticateAsync` when you authenticate an `HttpClient` directly: + +````csharp +var client = _httpClientFactory.CreateClient(); + +if (!await _authenticationService.TryAuthenticateAsync(client, "Reporting")) +{ + throw new InvalidOperationException( + "The Reporting identity client is not configured." + ); +} + +var response = await client.GetAsync("https://reporting.example.com/api/reports"); +```` + +Install `Volo.Abp.Http.Client.IdentityModel` when dynamic HTTP client proxies should obtain tokens automatically. Its `AbpHttpClientIdentityModelModule` integration uses the remote service's `IdentityClient` value when configured, then the remote service name, and finally the `Default` identity client fallback described above: + +````json +{ + "RemoteServices": { + "Reporting": { + "BaseUrl": "https://reporting.example.com/", + "IdentityClient": "Reporting" + } + } +} +```` + +## Customize Discovery and Token Requests + +Use `IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage` to add headers or otherwise customize the request messages: + +````csharp +Configure(options => +{ + options.ConfigureHttpRequestMessage = request => + { + request.Headers.TryAddWithoutValidation( + "X-Internal-Client", + "MyProject" + ); + }; +}); +```` + +The callback runs for discovery, client-credentials, password and device-authorization request messages created by the default authentication service. diff --git a/docs/en/framework/api-development/swagger.md b/docs/en/framework/api-development/swagger.md index 03808d7869..f07f049555 100644 --- a/docs/en/framework/api-development/swagger.md +++ b/docs/en/framework/api-development/swagger.md @@ -110,6 +110,21 @@ services.AddAbpSwaggerGen( ) ``` +### Enum and Schema ID Helpers + +ABP provides two additional `SwaggerGenOptions` helpers: + +* `UserFriendlyEnums()` changes enum schemas from numeric values to string enum names, making generated contracts easier for clients to consume. +* `CustomAbpSchemaIds()` uses full type names and includes generic argument names to avoid schema ID collisions. + +```csharp +services.AddAbpSwaggerGen(options => +{ + options.UserFriendlyEnums(); + options.CustomAbpSchemaIds(); +}); +``` + ## Using Swagger with OAUTH For non MVC/Tiered applications, we need to configure Swagger with OAUTH to handle authorization. diff --git a/docs/en/framework/architecture/domain-driven-design/repositories.md b/docs/en/framework/architecture/domain-driven-design/repositories.md index eed0529112..ea74f0976a 100644 --- a/docs/en/framework/architecture/domain-driven-design/repositories.md +++ b/docs/en/framework/architecture/domain-driven-design/repositories.md @@ -249,7 +249,7 @@ public virtual async Task> GetListAsync() ABP uses dynamic proxying to make these attributes work. There are some rules here: -* If you are **not injecting** the service over an interface (like `IPersonAppService`), then the methods of the service must be `virtual`. Otherwise, [dynamic proxy / interception](../../../dynamic-proxying-interceptors.md) system can not work. +* If you are **not injecting** the service over an interface (like `IPersonAppService`), then the methods of the service must be `virtual`. Otherwise, [dynamic proxy / interception](../../infrastructure/interceptors.md) system can not work. * Only `async` methods (methods returning a `Task` or `Task`) are intercepted. > Change tracking behavior doesn't affect tracking entity objects returned from `InsertAsync` and `UpdateAsync` methods. The objects returned from these methods are always tracked (if the underlying provider has the change tracking feature) and any change you make to these objects are saved into the database. @@ -309,9 +309,10 @@ Methods: - `GetListAsync()` - `GetQueryableAsync()` -- `WithDetails()` 1 overload - `WithDetailsAsync()` 1 overload +The synchronous `WithDetails()` overloads are obsolete. Use `WithDetailsAsync()` for new code. + Whereas the `IReadOnlyBasicRepository` provides the following methods: - `GetCountAsync()` diff --git a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md index 697d7dba80..63ee51149a 100644 --- a/docs/en/framework/architecture/domain-driven-design/unit-of-work.md +++ b/docs/en/framework/architecture/domain-driven-design/unit-of-work.md @@ -59,7 +59,7 @@ Configure(options => ### Option Properties * `TransactionBehavior` (`enum`: `UnitOfWorkTransactionBehavior`). A global point to configure the transaction behavior. Default value is `Auto` and work as explained in the "*Database Transaction Behavior*" section above. You can enable (even for HTTP GET requests) or disable transactions with this option. -* `TimeOut` (`int?`): Used to set the timeout value for UOWs. **Default value is `null`** and uses to the default of the underlying database provider. +* `Timeout` (`int?`): Used to set the timeout value for UOWs. **Default value is `null`** and uses to the default of the underlying database provider. * `IsolationLevel` (`IsolationLevel?`): Used to set the [isolation level](https://docs.microsoft.com/en-us/dotnet/api/system.data.isolationlevel) of the database transaction, if the UOW is transactional. ## Controlling the Unit Of Work @@ -93,7 +93,7 @@ Then `MyService` (and any class derived from it) methods will be UOW. However, there are **some rules should be followed** in order to make it working; -* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual` (otherwise, [dynamic proxy / interception](../../../dynamic-proxying-interceptors.md) system can not work). +* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual` (otherwise, [dynamic proxy / interception](../../infrastructure/interceptors.md) system can not work). * Only `async` methods (methods returning a `Task` or `Task`) are intercepted. So, sync methods can not start a UOW. > Notice that if `FooAsync` is called inside a UOW scope, then it already participates to the UOW without needing to the `IUnitOfWorkEnabled` or any other configuration. @@ -156,13 +156,13 @@ namespace AbpDemo Again, the **same rules** are valid here: -* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual` (otherwise, [dynamic proxy / interception](../../../dynamic-proxying-interceptors.md) system can not work). +* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual` (otherwise, [dynamic proxy / interception](../../infrastructure/interceptors.md) system can not work). * Only `async` methods (methods returning a `Task` or `Task`) are intercepted. So, sync methods can not start a UOW. #### UnitOfWorkAttribute Properties * `IsTransactional` (`bool?`): Used to set whether the UOW should be transactional or not. **Default value is `null`**. if you leave it `null`, it is determined automatically based on the conventions and the configuration. -* `TimeOut` (`int?`): Used to set the timeout value for this UOW. **Default value is `null`** and fallbacks to the default configured value. +* `Timeout` (`int?`): Used to set the timeout value for this UOW. **Default value is `null`** and fallbacks to the default configured value. * `IsolationLevel` (`IsolationLevel?`): Used to set the [isolation level](https://docs.microsoft.com/en-us/dotnet/api/system.data.isolationlevel) of the database transaction, if the UOW is transactional. If not set, uses the default configured value. * `IsDisabled` (`bool`): Used to disable the UOW for the current method/class. @@ -234,7 +234,7 @@ namespace AbpDemo * `requiresNew` (`bool`): Set `true` to ignore the surrounding unit of work and start a new UOW with the provided options. **Default value is `false`. If it is `false` and there is a surrounding UOW, `Begin` method doesn't actually begin a new UOW, but silently participates to the existing UOW.** * `isTransactional` (`bool`). Default value is `false`. * `isolationLevel` (`IsolationLevel?`): Used to set the [isolation level](https://docs.microsoft.com/en-us/dotnet/api/system.data.isolationlevel) of the database transaction, if the UOW is transactional. If not set, uses the default configured value. -* `TimeOut` (`int?`): Used to set the timeout value for this UOW. **Default value is `null`** and fallbacks to the default configured value. +* `timeout` (`int?`): Used to set the timeout value for this UOW. **Default value is `null`** and fallbacks to the default configured value. ### The Current Unit Of Work diff --git a/docs/en/framework/architecture/modularity/basics.md b/docs/en/framework/architecture/modularity/basics.md index 727c40f517..c6ee6f567c 100644 --- a/docs/en/framework/architecture/modularity/basics.md +++ b/docs/en/framework/architecture/modularity/basics.md @@ -145,6 +145,43 @@ You can also perform startup logic if your module requires it > These methods have asynchronous versions too, and if you want to make asynchronous calls inside these methods, override the asynchronous versions instead of the synchronous ones. +#### Custom Module Lifecycle Contributors + +`IModuleLifecycleContributor` is an advanced extension point for adding an application-wide initialization or shutdown phase. A contributor is invoked for every loaded module. Initialization follows module dependency order, while shutdown processes modules in reverse order. + +Derive from `ModuleLifecycleContributorBase` and override only the phases you need. Each phase has a synchronous and an asynchronous method; the application calls one of them depending on whether it is initialized synchronously or asynchronously, so override both to cover the two startup paths: + +````csharp +public class MyModuleLifecycleContributor : ModuleLifecycleContributorBase +{ + public override Task InitializeAsync( + ApplicationInitializationContext context, + IAbpModule module) + { + // Run initialization logic for the current module. + return Task.CompletedTask; + } + + public override void Initialize( + ApplicationInitializationContext context, + IAbpModule module) + { + AsyncHelper.RunSync(() => InitializeAsync(context, module)); + } +} +```` + +Add the contributor type to `AbpModuleLifecycleOptions.Contributors`: + +````csharp +Configure(options => +{ + options.Contributors.Add(); +}); +```` + +Contributor order is the order of the `Contributors` list. The four built-in contributors run the pre-initialization, initialization, post-initialization and shutdown callbacks. + ### Application Shutdown Lastly, you can override ``OnApplicationShutdown`` method if you want to execute some code while application is being shutdown. diff --git a/docs/en/framework/architecture/modularity/extending/module-entity-extensions.md b/docs/en/framework/architecture/modularity/extending/module-entity-extensions.md index 076238b444..d99ea4de8e 100644 --- a/docs/en/framework/architecture/modularity/extending/module-entity-extensions.md +++ b/docs/en/framework/architecture/modularity/extending/module-entity-extensions.md @@ -273,6 +273,33 @@ property => Use `property.UI.OnCreateForm` and `property.UI.OnEditForm` to control forms too. If a property is required, but not added to the create form, you definitely get a validation exception, so use this option carefully. But a required property may not be in the edit form if that's your requirement. +### Conditional Availability + +An extension property can carry global-feature, tenant-feature and permission policies. Policy-aware object-extension consumers use this metadata to decide whether the property is available for the current application and user. + +The following example requires either of two permissions: + +````csharp +property => +{ + property.Policy.Permissions.PermissionNames = + [ + "MyProject.Users.Manage", + "MyProject.Users.ManageExtendedProfile" + ]; +} +```` + +The available policy groups are: + +* `Policy.GlobalFeatures.Features` for application-wide global features. +* `Policy.Features.Features` for the current tenant's features. +* `Policy.Permissions.PermissionNames` for the current principal's permissions. + +`RequiresAll` is `false` by default for each group, so any configured name in that group is sufficient. Set the corresponding `RequiresAll` property to `true` to require every name. When more than one group is configured, every configured group must pass. An empty group imposes no restriction. + +These policies do not replace the `UI` and `Api` availability options. They add current feature and permission checks to consumers that evaluate extension-property policies. + ### UI Order When you define a property, it appears on the data table, create and edit forms on the related UI page. However, you can control its order. Example: diff --git a/docs/en/framework/data/entity-framework-core/index.md b/docs/en/framework/data/entity-framework-core/index.md index 418c040565..25b3e00b6b 100644 --- a/docs/en/framework/data/entity-framework-core/index.md +++ b/docs/en/framework/data/entity-framework-core/index.md @@ -651,7 +651,7 @@ In addition to the read-only repositories, ABP allows to manually control the ch ## Access to the EF Core API -In most cases, you want to hide EF Core APIs behind a repository (this is the main purpose of the repository pattern). However, if you want to access the `DbContext` instance over the repository, you can use `GetDbContext()` or `GetDbSet()` extension methods. Example: +In most cases, you want to hide EF Core APIs behind a repository (this is the main purpose of the repository pattern). However, if you want to access the `DbContext` instance over the repository, you can use `GetDbContextAsync()` or `GetDbSetAsync()` extension methods. Example: ````csharp public async Task TestAsync() diff --git a/docs/en/framework/data/memorydb/index.md b/docs/en/framework/data/memorydb/index.md new file mode 100644 index 0000000000..247d2b1e8f --- /dev/null +++ b/docs/en/framework/data/memorydb/index.md @@ -0,0 +1,77 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to use ABP's in-memory database provider, register a MemoryDb context and repositories, and customize entity serialization." +} +``` + +# In-Memory Database Provider + +The `Volo.Abp.MemoryDb` package implements ABP repositories with an in-process database. It is useful for tests and other non-durable scenarios. Data is kept in the application process and is lost when the process stops. + +## Installation + +Install the `Volo.Abp.MemoryDb` NuGet package in the data-access project: + +````shell +abp add-package Volo.Abp.MemoryDb +```` + +The command adds the package and the `AbpMemoryDbModule` dependency to the module class. You can also configure the dependency manually as shown in the next section. + +## Configure the Module + +Add `AbpMemoryDbModule` as a dependency of your module: + +````csharp +[DependsOn(typeof(AbpMemoryDbModule))] +public class MyDataModule : AbpModule +{ +} +```` + +## Create a MemoryDb Context + +Derive a class from `MemoryDbContext` and return the entity types managed by the context: + +````csharp +public class MyMemoryDbContext : MemoryDbContext +{ + private static readonly Type[] EntityTypes = + [ + typeof(Book), + typeof(Author) + ]; + + public override IReadOnlyList GetEntityTypes() + { + return EntityTypes; + } +} +```` + +Register the context in the `ConfigureServices` method of your module: + +````csharp +context.Services.AddMemoryDbContext(options => +{ + options.AddDefaultRepositories(); +}); +```` + +`AddDefaultRepositories()` registers default repositories for the aggregate roots returned by the context. Pass `includeAllEntities: true` when default repositories are also needed for other entity types. + +MemoryDb repositories use ABP's unit-of-work-aware database provider. Repository operations require an active [unit of work](../../architecture/domain-driven-design/unit-of-work.md). + +## JSON Serialization + +MemoryDb stores serialized entity values. Configure `Utf8JsonMemoryDbSerializerOptions` to customize the underlying `System.Text.Json` options: + +````csharp +Configure(options => +{ + options.JsonSerializerOptions.Converters.Add( + new MyEntityJsonConverter() + ); +}); +```` diff --git a/docs/en/framework/data/mongodb/index.md b/docs/en/framework/data/mongodb/index.md index b609e22963..5349decde4 100644 --- a/docs/en/framework/data/mongodb/index.md +++ b/docs/en/framework/data/mongodb/index.md @@ -352,6 +352,31 @@ services: ### Advanced Topics +#### MongoDB DateTime Serialization + +ABP applies a clock-aware MongoDB serializer to writable `DateTime` and nullable `DateTime` properties in ABP entity mappings by default. It uses the configured [clock](../../infrastructure/timing.md) kind when serializing these properties. Disable this handling when the application configures its own serialization for the mapped properties: + +```csharp +Configure(options => +{ + options.UseAbpClockHandleDateTime = false; +}); +``` + +#### Configuring MongoClientSettings + +`AbpMongoDbContextOptions.MongoClientSettingsConfigurer` runs before ABP creates a `MongoClient`. Use it for driver settings that are not part of the connection string, such as timeouts or TLS configuration: + +```csharp +Configure(options => +{ + options.MongoClientSettingsConfigurer = settings => + { + settings.ConnectTimeout = TimeSpan.FromSeconds(10); + }; +}); +``` + ### Controlling the Multi-Tenancy If your solution is [multi-tenant](../../architecture/multi-tenancy), tenants may have **separate databases**, you have **multiple** `DbContext` classes in your solution and some of your `DbContext` classes should be usable **only from the host side**, it is suggested to add `[IgnoreMultiTenancy]` attribute on your `DbContext` class. In this case, ABP guarantees that the related `DbContext` always uses the host [connection string](../../fundamentals/connection-strings.md), even if you are in a tenant context. diff --git a/docs/en/framework/fundamentals/application-startup.md b/docs/en/framework/fundamentals/application-startup.md index 4c323c1c52..39a849b7bc 100644 --- a/docs/en/framework/fundamentals/application-startup.md +++ b/docs/en/framework/fundamentals/application-startup.md @@ -213,6 +213,11 @@ We've passed a lambda method to configure the `ApplicationName` option. Here's a * `ApplicationName`: A human-readable name for the application. It is a unique value for an application. * `Configuration`: Can be used to setup the [application configuration](./configuration.md) when it is not provided by the hosting system. It is not needed for ASP.NET Core and other .NET hosted applications. However, if you've used `AbpApplicationFactory` with an internal service provider, you can use this option to configure how the application configuration is built. + * `FileName` (default: `appsettings`), `Optional` (default: `true`) and `ReloadOnChange` (default: `true`) configure the JSON files. + * The builder loads `.json` first and then the optional `.secrets.json` file. When `EnvironmentName` is set, it loads `..json` after both files. + * `EnvironmentName` adds the corresponding environment-specific JSON file. In the `Development` environment, user secrets are added from `UserSecretsId` when it is set; otherwise from `UserSecretsAssembly`. + * `BasePath` changes the configuration file base path. The current directory is used by default. + * `EnvironmentVariablesPrefix` filters environment variables, and `CommandLineArgs` adds command-line configuration after environment variables. * `Environment`: Environment name for the application. * `PlugInSources`: A list of plugin sources. See the [Plug-In Modules documentation](../architecture/modularity/plugin-modules.md) to learn how to work with plugins. * `Services`: The `IServiceCollection` object that can be used to register service dependencies. You generally don't need that, because you configure your services in your [module class](../architecture/modularity/basics.md). However, it can be used while writing extension methods for the `AbpApplicationCreationOptions` class. diff --git a/docs/en/framework/fundamentals/caching.md b/docs/en/framework/fundamentals/caching.md index f52cd9e07d..8f7b85e37e 100644 --- a/docs/en/framework/fundamentals/caching.md +++ b/docs/en/framework/fundamentals/caching.md @@ -214,6 +214,58 @@ public class BookService : ITransientDependency } ```` +## Hybrid Cache + +ABP registers Microsoft's `HybridCache` together with typed ABP wrappers when the `Volo.Abp.Caching` module is used. Hybrid caching keeps a local in-process cache and can use the configured `IDistributedCache` as a secondary cache. + +Use `IHybridCache` for string keys or `IHybridCache` for another key type: + +````csharp +using Volo.Abp.Caching.Hybrid; +using Volo.Abp.DependencyInjection; + +public class BookCacheItem +{ + public string Name { get; set; } = string.Empty; +} + +public class BookService : ITransientDependency +{ + private readonly IHybridCache _cache; + + public BookService(IHybridCache cache) + { + _cache = cache; + } + + public Task GetAsync(Guid bookId) + { + return _cache.GetOrCreateAsync( + bookId, + () => LoadBookAsync(bookId) + ); + } + + private Task LoadBookAsync(Guid bookId) + { + // Load the item from its source. + throw new NotImplementedException(); + } +} +```` + +The typed wrapper uses the same cache-name and tenant-aware key normalization conventions as ABP's distributed cache. Use `CacheName` on the cache item type to set its cache name and `IgnoreMultiTenancy` to share entries between tenants. A custom key type is converted with its `ToString()` method. + +The main operations are `GetOrCreateAsync`, `SetAsync`, `RemoveAsync` and `RemoveManyAsync`. Each operation has a nullable `hideErrors` argument. When it is `null`, `AbpHybridCacheOptions.HideErrors` is used; its default is `true`. Hidden errors are logged and sent to the exception notification system. `GetOrCreateAsync` can return `null` when a cache error is hidden. + +### Hybrid Cache and Unit of Work + +The hybrid-cache methods have a `considerUow` argument that defaults to `false`. When it is `true` and a unit of work is active, cache changes are visible inside that unit of work and are applied to the real cache only after the unit of work completes successfully. A rolled-back unit of work does not apply those changes. + +### Hybrid Cache Entry Options + +Pass `HybridCacheEntryOptions` to an individual `SetAsync` call when it needs a custom expiration. `AbpHybridCacheOptions.GlobalHybridCacheEntryOptions` is used by `SetAsync` when no per-call options are supplied, and `ConfigureCache()` can set the corresponding default for a cache item type. + ## Configuration ### AbpDistributedCacheOptions @@ -233,7 +285,7 @@ Configure(options => * `HideErrors` (`bool`, default: `true`): Enables or disables hiding errors when reading from or writing to the cache server. In the **development** environment, this option is **disabled** to help developers detect and fix any cache server issues. -* `KeyPrefix` (`string`, default: `null`): If your cache server is shared by multiple applications, you can set a prefix for the cache keys for your application. In this case, different applications can not overwrite each other's cache items. +* `KeyPrefix` (`string`, default: an empty string): If your cache server is shared by multiple applications, you can set a prefix for the cache keys for your application. In this case, different applications can not overwrite each other's cache items. * `GlobalCacheEntryOptions` (`DistributedCacheEntryOptions`): Used to set default distributed cache options (like `AbsoluteExpiration` and `SlidingExpiration`) used when you don't specify the options while saving cache items. The default value uses the `SlidingExpiration` as 20 minutes. ## Error Handling diff --git a/docs/en/framework/fundamentals/dependency-injection.md b/docs/en/framework/fundamentals/dependency-injection.md index 2e2e3e377a..9ceedf0ebf 100644 --- a/docs/en/framework/fundamentals/dependency-injection.md +++ b/docs/en/framework/fundamentals/dependency-injection.md @@ -547,7 +547,7 @@ public class AppModule : AbpModule This example simply checks if the service class has `MyLogAttribute` attribute and adds `MyLogInterceptor` to the interceptor list if so. -> Notice that `OnRegistered` callback might be called multiple times for the same service class if it exposes more than one service/interface. So, it's safe to use `Interceptors.TryAdd` method instead of `Interceptors.Add` method. See [the documentation](../../dynamic-proxying-interceptors.md) of dynamic proxying / interceptors. +> Notice that `OnRegistered` callback might be called multiple times for the same service class if it exposes more than one service/interface. So, it's safe to use `Interceptors.TryAdd` method instead of `Interceptors.Add` method. See [the documentation](../infrastructure/interceptors.md) of dynamic proxying / interceptors. ### IServiceCollection.OnActivated Event diff --git a/docs/en/framework/fundamentals/exception-handling.md b/docs/en/framework/fundamentals/exception-handling.md index 5ccfcf124a..f9e659f29b 100644 --- a/docs/en/framework/fundamentals/exception-handling.md +++ b/docs/en/framework/fundamentals/exception-handling.md @@ -344,6 +344,20 @@ Here, a list of the options you can configure: * `SendExceptionsDetailsToClients` (default: `false`): You can enable or disable sending exception details to the client. * `SendStackTraceToClients` (default: `true`): You can enable or disable sending the stack trace of exception to the client. If you want to send the stack trace to the client, you must set both `SendStackTraceToClients` and `SendExceptionsDetailsToClients` options to `true` otherwise, the stack trace will not be sent to the client. +* `SendExceptionDataToClientTypes`: Exception types whose `Data` dictionary is copied to the remote error response. The default list contains `IBusinessException`, so business exception data is sent to clients. Derived and implementing types are matched. +* `ExcludeExceptionFromLoggerSelectors`: Predicates that suppress matching exceptions from the ABP exception log. Add a selector when an expected exception should still produce an error response but should not be logged by the exception pipeline. + +Example: + +````csharp +Configure(options => +{ + options.SendExceptionDataToClientTypes.Add(typeof(MyClientVisibleException)); + options.ExcludeExceptionFromLoggerSelectors.Add( + exception => exception is MyExpectedException + ); +}); +```` ## See Also diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 5fd243eeef..4caed371d3 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -108,9 +108,9 @@ You can also use nesting or array in localization files, like this: "Hello": { "World": "Hello World!" }, - "Hi":[ - "Bye": "Bye World!" - "Hello": "Hello World!" + "Hi": [ + "Bye World!", + "Hello World!" ] } } @@ -189,6 +189,21 @@ public class TestResource See the Getting Localized Test / Client Side section below. +### Non-Typed Resources + +Most localization resources are represented by a class, which allows you to inject `IStringLocalizer`. You can also register a resource by name without creating a resource class. This is useful when a resource is identified only by its name. An external localization store can also return a non-typed resource for a resource name discovered at runtime. + +````csharp +Configure(options => +{ + options.Resources + .Add("CountryNames", "en") + .AddVirtualJson("/Localization/Resources/CountryNames"); +}); +```` + +Use `IStringLocalizerFactory` to access a non-typed resource, as described in the *Creating A Localizer By Resource Name* section below. + ### Inherit From Other Resources A resource can inherit from other resources which makes possible to re-use existing localization strings without referring the existing resource. Example: @@ -215,6 +230,18 @@ services.Configure(options => * A resource may inherit from multiple resources. * If the new resource defines the same localized string, it overrides the string. +A resource can also inherit from a typed or non-typed resource by its resource name: + +````csharp +Configure(options => +{ + options.Resources + .Add("en") + .AddVirtualJson("/Localization/Resources/Test") + .AddBaseResources("CountryNames"); +}); +```` + ### Extending Existing Resource Inheriting from a resource creates a new resource without modifying the existing one. In some cases, you may want to not create a new resource but directly extend an existing resource. Example: @@ -230,6 +257,51 @@ services.Configure(options => * If an extension file defines the same localized string, it overrides the string. +### Culture Fallback + +ABP searches for a localized string in the following order: + +1. The requested culture of the current resource, such as `en-US`. +2. The base culture, such as `en`, when `TryToGetFromBaseCulture` is enabled. +3. The default culture configured for the resource when `TryToGetFromDefaultCulture` is enabled. +4. The inherited resources, in their configured order. Each inherited resource applies the same culture fallback rules. +5. The localization key itself, returned with `ResourceNotFound` set to `true`. + +Both fallback options are enabled by default. You can disable them independently: + +````csharp +Configure(options => +{ + options.TryToGetFromBaseCulture = false; + options.TryToGetFromDefaultCulture = false; +}); +```` + +### Global Resource Contributors + +`AbpLocalizationOptions.GlobalContributors` adds an `ILocalizationResourceContributor` implementation to every localization resource: + +````csharp +Configure(options => +{ + options.GlobalContributors.Add(); +}); +```` + +The contributor type must have a parameterless constructor. Its `Initialize` method receives a `LocalizationResourceInitializationContext`, which provides the resource and the application service provider. + +Contributors are order-sensitive. A lookup starts with the last registered contributor, so a later contributor overrides an earlier contributor when both provide the same key. Global contributors are appended after contributors configured directly on a resource. + +### External Localization Stores + +Replace `IExternalLocalizationStore` when localization resources need to be discovered at runtime or loaded from an external system. The default `NullExternalLocalizationStore` does not provide any resources. + +The string localizer factory first searches the resources registered in `AbpLocalizationOptions.Resources`. If it cannot find the requested resource name, it queries `IExternalLocalizationStore`. The store exposes synchronous and asynchronous methods for retrieving a resource by name, and asynchronous methods for enumerating resource names and resources. + +The factory caches the localizer after it resolves a resource name. Changing the resource object returned by the store does not make the factory resolve that name again. Use dynamic contributors when the localization values themselves need to change while the application is running. + +Use the standard [dependency injection service replacement](dependency-injection.md#replace-a-service) mechanism to replace the default implementation. + ## Getting the Localized Texts Getting the localized text is pretty standard. @@ -255,12 +327,70 @@ public class MyService : ITransientDependency } ```` +### Creating A Localizer By Resource Name + +Use `IStringLocalizerFactory` when the resource type is not available or the resource is registered by name: + +````csharp +public class MyService : ITransientDependency +{ + private readonly IStringLocalizerFactory _localizerFactory; + + public MyService(IStringLocalizerFactory localizerFactory) + { + _localizerFactory = localizerFactory; + } + + public string GetCountryName() + { + var localizer = _localizerFactory.CreateByResourceName("CountryNames"); + return localizer["USA"]; + } +} +```` + +`CreateByResourceName` throws an `AbpException` when the resource cannot be found. Use `CreateByResourceNameOrNull` when a missing resource is expected. `CreateByResourceNameAsync` and `CreateByResourceNameOrNullAsync` are available for external stores that load resources asynchronously. + +### Serializing Localizable Strings + +Use `ILocalizableStringSerializer` when an `ILocalizableString` needs to be stored as a string and reconstructed later: + +````csharp +var serialized = localizableStringSerializer.Serialize( + LocalizableString.Create("HelloWorld") +); + +var localizableString = localizableStringSerializer.Deserialize(serialized!); +```` + +The default serializer uses `L:,` for `LocalizableString` and `F:` for `FixedLocalizableString`. A value without a recognized prefix is deserialized as a `FixedLocalizableString`; values too short to carry both a prefix and content (like the literal `L:`) are treated the same way. An `L:` value without a comma or with an empty or whitespace-only key throws an `AbpException`. Serializing `null` returns `null`; serializing another `ILocalizableString` implementation throws an `AbpException`. + ### Format Arguments Format arguments can be passed after the localization key. If your message is `Hello {0}, welcome!`, then you can pass the `{0}` argument to the localizer like `_localizer["HelloMessage", "John"]`. > Refer to the [Microsoft's localization documentation](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization) for details about using the localization. +### Getting All Localization Strings + +The standard `GetAllStrings(includeParentCultures)` method can include values from the resource's default and base cultures. ABP also provides an overload to control inherited resources and dynamic contributors independently: + +````csharp +var strings = localizer.GetAllStrings( + includeParentCultures: true, + includeBaseLocalizers: true, + includeDynamicContributors: false +); +```` + +* `includeParentCultures` includes values from the resource's default culture and the base culture of the current UI culture. Values from the current UI culture override them. +* `includeBaseLocalizers` includes strings from inherited resources. Values from the current resource override inherited values. +* `includeDynamicContributors` includes contributors whose `IsDynamic` property is `true`. + +`includeParentCultures` controls this bulk enumeration independently of `TryToGetFromBaseCulture` and `TryToGetFromDefaultCulture`, which control single-string lookups. + +Use `GetAllStringsAsync` with the same flags when a contributor retrieves strings asynchronously. + ### Using In A Razor View/Page Use `IHtmlLocalizer` in razor views/pages; @@ -331,6 +461,29 @@ Configure(options => }); ``` +### Mapping Culture Names For Client Packages + +Client libraries sometimes use a culture name or localization file name that differs from the application's culture name. Use `AddLanguagesMapOrUpdate` to map the culture passed to a package, and `AddLanguageFilesMapOrUpdate` to map the package's localization file name: + +```csharp +Configure(options => +{ + options.AddLanguagesMapOrUpdate( + "MyClientPackage", + new NameValue("zh-Hans", "zh-CN") + ); + + options.AddLanguageFilesMapOrUpdate( + "MyClientPackage", + new NameValue("zh-Hans", "zh-CN") + ); +}); +``` + +Mappings are scoped by package name. When a mapping is not defined, ABP uses the original culture name. + +The `NameValue` name is the application culture and its value is the culture or file name expected by the client package. Use the package's own package-name constant when it provides one. + ## URL-Based Localization ABP supports embedding the culture code directly in the URL path (e.g. `/en/products`, `/zh-Hans/about`), which is useful for SEO-friendly and shareable localized URLs. See the [URL-Based Localization](./url-based-localization.md) document for details. diff --git a/docs/en/framework/fundamentals/logging.md b/docs/en/framework/fundamentals/logging.md index 94056400c7..7a1928b4c6 100644 --- a/docs/en/framework/fundamentals/logging.md +++ b/docs/en/framework/fundamentals/logging.md @@ -11,3 +11,20 @@ ABP doesn't implement any logging infrastructure. It uses the [ASP.NET Core's lo > .NET Core's logging system is actually independent from the ASP.NET Core. It is usable in any type of application. +## Serilog Request Enrichers + +When the ABP ASP.NET Core Serilog integration is installed, its middleware enriches request log events with the current `TenantId`, `UserId`, `ClientId` and `CorrelationId` values when they are available. + +`AbpAspNetCoreSerilogOptions.EnricherPropertyNames` can align these property names with an existing observability schema: + +````csharp +Configure(options => +{ + options.EnricherPropertyNames.TenantId = "tenant_id"; + options.EnricherPropertyNames.UserId = "user_id"; + options.EnricherPropertyNames.ClientId = "client_id"; + options.EnricherPropertyNames.CorrelationId = "correlation_id"; +}); +```` + +The default names are `TenantId`, `UserId`, `ClientId` and `CorrelationId`. diff --git a/docs/en/framework/fundamentals/options.md b/docs/en/framework/fundamentals/options.md index d18c89c675..2bde471f90 100644 --- a/docs/en/framework/fundamentals/options.md +++ b/docs/en/framework/fundamentals/options.md @@ -123,3 +123,56 @@ public override void ConfigureServices(ServiceConfigurationContext context) } ```` +## Dynamic Options + +Standard options are created synchronously. `AbpDynamicOptionsManager` can override named option values asynchronously from a runtime source, such as the setting system. + +Derive a manager and implement `OverrideOptionsAsync`: + +````csharp +public class MyDynamicOptionsManager : AbpDynamicOptionsManager +{ + private readonly ISettingProvider _settingProvider; + + public MyDynamicOptionsManager( + IOptionsFactory factory, + ISettingProvider settingProvider) + : base(factory) + { + _settingProvider = settingProvider; + } + + protected override async Task OverrideOptionsAsync( + string name, + MyOptions options) + { + options.Value1 = await _settingProvider.GetAsync("MyOptions.Value1"); + } +} +```` + +Register the manager for the option type: + +````csharp +context.Services.AddAbpDynamicOptions(); +```` + +This replaces `IOptions` and `IOptionsSnapshot` with the scoped dynamic manager. Call the `IOptions.SetAsync` extension before reading the value when you need to apply the asynchronous override: + +````csharp +public class MyService : ITransientDependency +{ + private readonly IOptions _options; + + public MyService(IOptions options) + { + _options = options; + } + + public async Task GetValueAsync() + { + await _options.SetAsync(); + return _options.Value.Value1; + } +} +```` diff --git a/docs/en/framework/fundamentals/validation.md b/docs/en/framework/fundamentals/validation.md index 78e93e59f5..3bda1417da 100644 --- a/docs/en/framework/fundamentals/validation.md +++ b/docs/en/framework/fundamentals/validation.md @@ -117,7 +117,7 @@ namespace Acme.BookStore } ```` -> ABP uses the [dynamic proxying / interception](../../dynamic-proxying-interceptors.md) system to perform the validation. In order to make it working, your method should be **virtual** or your service should be injected and used over an **interface** (like `IMyService`). +> ABP uses the [dynamic proxying / interception](../infrastructure/interceptors.md) system to perform the validation. In order to make it working, your method should be **virtual** or your service should be injected and used over an **interface** (like `IMyService`). #### Enabling/Disabling Validation @@ -142,6 +142,25 @@ public class InputClass } ```` +If a class that is subject to automatic validation (it implements `IValidationEnabled`, like application services do) has `[DisableValidation]`, add `[EnableValidation]` to a method to re-enable automatic validation for that method (`[EnableValidation]` does not activate validation for a class that isn't intercepted at all): + +````csharp +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Validation; + +[DisableValidation] +public class MyService : IValidationEnabled, ITransientDependency +{ + [EnableValidation] + public virtual Task UpdateAsync(MyInput input) + { + //... + return Task.CompletedTask; + } +} +```` + ### AbpValidationException Once ABP determines a validation error, it throws an exception of type `AbpValidationException`. Your application code can throw `AbpValidationException`, but most of the times it is not needed. @@ -180,6 +199,17 @@ public class MyObjectValidationContributor * Remember to register your class to the [DI](./dependency-injection.md) (implementing `ITransientDependency` does it just like in this example) * ABP will automatically discover your class and use on any type of object validation (including automatic method call validation). +### Ignoring Types During Recursive Validation + +`AbpValidationOptions.IgnoredTypes` prevents the default data annotation contributor from descending into the properties of matching values during recursive validation. The data annotations on the matching value itself are still validated. Derived and implementing types are also matched. + +````csharp +Configure(options => +{ + options.IgnoredTypes.Add(typeof(MyInfrastructureValue)); +}); +```` + ### IMethodInvocationValidator `IMethodInvocationValidator` is used to validate a method call. It internally uses the `IObjectValidator` to validate objects passes to the method call. You normally don't need to this service since it is automatically used by the framework, but you may want to reuse or replace it on your application in rare cases. diff --git a/docs/en/framework/infrastructure/artificial-intelligence/microsoft-agent-framework.md b/docs/en/framework/infrastructure/artificial-intelligence/microsoft-agent-framework.md index d3e7d128fc..8d83bfc714 100644 --- a/docs/en/framework/infrastructure/artificial-intelligence/microsoft-agent-framework.md +++ b/docs/en/framework/infrastructure/artificial-intelligence/microsoft-agent-framework.md @@ -86,7 +86,7 @@ public class CommentSummarization > [!NOTE] > If you don't specify the workspace name, the full name of the class will be used as the workspace name. -You can resolve generic versions of `IChatClient` and `IChatClientAccessor` services for a specific workspace as generic arguments. If Chat Client is not configured for a workspace, you will get `null` from the accessor services. You should check the accessor before using it. This applies only for specified workspaces. Another workspace may have a configured Chat Client. +You can resolve generic versions of `IChatClient` and `IChatClientAccessor` services for a specific workspace as generic arguments. If a Chat Client is not configured for the specified workspace, both services fall back to the default workspace. `IChatClientAccessor.ChatClient` is `null` only when neither the specified workspace nor the default workspace has a configured Chat Client. Resolving `IChatClient` requires one of them to be configured. `IChatClient` or `IChatClientAccessor` can be resolved to access a specific workspace's chat client. This is a typed chat client and can be configured separately from the default chat client. @@ -215,4 +215,4 @@ public class MyProjectModule : AbpModule - [Usage of Microsoft.Extensions.AI](./microsoft-extensions-ai.md) - [Usage of Semantic Kernel](./microsoft-semantic-kernel.md) - [Microsoft Agent Framework Overview](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) -- [AI Samples for .NET](https://learn.microsoft.com/en-us/samples/dotnet/ai-samples/ai-samples/) \ No newline at end of file +- [AI Samples for .NET](https://learn.microsoft.com/en-us/samples/dotnet/ai-samples/ai-samples/) diff --git a/docs/en/framework/infrastructure/audit-logging.md b/docs/en/framework/infrastructure/audit-logging.md index ee2cdf7e41..c5885f0d01 100644 --- a/docs/en/framework/infrastructure/audit-logging.md +++ b/docs/en/framework/infrastructure/audit-logging.md @@ -44,10 +44,10 @@ Configure(options => Here, a list of the options you can configure: * `IsEnabled` (default: `true`): A root switch to enable or disable the auditing system. Other options is not used if this value is `false`. -* `HideErrors` (default: `true`): Audit log system hides and write regular [logs](../fundamentals/localization.md) if any error occurs while saving the audit log objects. If saving the audit logs is critical for your system, set this to `false` to throw exception in case of hiding the errors. +* `HideErrors` (default: `true`): Audit log system hides and write regular [logs](../fundamentals/logging.md) if any error occurs while saving the audit log objects. If saving the audit logs is critical for your system, set this to `false` to throw exception in case of hiding the errors. * `IsEnabledForAnonymousUsers` (default: `true`): If you want to write audit logs only for the authenticated users, set this to `false`. If you save audit logs for anonymous users, you will see `null` for `UserId` values for these users. * `AlwaysLogOnException` (default: `true`): If you set to true, it always saves the audit log on an exception/error case without checking other options (except `IsEnabled`, which completely disables the audit logging). -* `IsEnabledForIntegrationService` (default: `false`): Audit Logging is disabled for [integration services](../api-development/integration-services.md) by default. Set this property as `true` to enable it. +* `IsEnabledForIntegrationServices` (default: `false`): Audit Logging is disabled for [integration services](../api-development/integration-services.md) by default. Set this property as `true` to enable it. * `IsEnabledForGetRequests` (default: `false`): HTTP GET requests should not make any change in the database normally and audit log system doesn't save audit log objects for GET request. Set this to `true` to enable it also for the GET requests. * `DisableLogActionInfo` (default: `false`):If you set to true, Will no longer log `AuditLogActionInfo`. * `ApplicationName`: If multiple applications are saving audit logs into a single database, set this property to your application name, so you can distinguish the logs of different applications. If you don't set, it will set from the `IApplicationInfoAccessor.ApplicationName` value, which is the entry assembly name by default. @@ -311,6 +311,8 @@ An **audit log object** is created for each **web request** by default. An audit * **Exception**: An audit log object may contain zero or more exception. In this way, you can get a report of the failed requests. * **Comment**: An arbitrary string value to add custom messages to the audit log entry. An audit log object may contain zero or more comments. +> When the [Audit Logging Module](../../modules/audit-logging.md) persists exceptions, it uses `AbpExceptionHandlingOptions` to convert them. `SendExceptionsDetailsToClients`, `SendStackTraceToClients` and `SendExceptionDataToClientTypes` therefore also control the exception details stored in audit logs, not only the details sent to clients. Review these options when audit logs may contain sensitive information. See the [Exception Handling](../fundamentals/exception-handling.md#abpexceptionhandlingoptions) document for configuration details. + In addition to the standard properties explained above, `AuditLogInfo`, `AuditLogActionInfo` and `EntityChangeInfo` objects implement the `IHasExtraProperties` interface, so you can add custom properties to these objects. ## Audit Log Contributors diff --git a/docs/en/framework/infrastructure/background-jobs/index.md b/docs/en/framework/infrastructure/background-jobs/index.md index cdc4a1b5fb..2d70a9ea3c 100644 --- a/docs/en/framework/infrastructure/background-jobs/index.md +++ b/docs/en/framework/infrastructure/background-jobs/index.md @@ -150,7 +150,7 @@ Configure(options => { options.GetBackgroundJobName = (jobType) => { - if (jobTyep == typeof(EmailSendingArgs)) + if (jobType == typeof(EmailSendingArgs)) { return "emails"; } @@ -225,7 +225,7 @@ ABP includes a simple `IBackgroundJobManager` implementation that; - **Retries** job execution until the job **successfully runs** or **timeouts**. Default timeout is 2 days for a job. Logs all exceptions. - **Deletes** a job from the store (database) when it's successfully executed. If it's timed out, it sets it as **abandoned** and leaves it in the database. - **Increasingly waits between retries** for a job. It waits 1 minute for the first retry, 2 minutes for the second retry, 4 minutes for the third retry and so on. -- **Polls** the store for jobs in fixed intervals. It queries jobs, ordering by priority (asc) and then by try count (asc). +- **Polls** the store for jobs in fixed intervals. It queries jobs, ordering by priority (desc) and then by try count (asc). > `Volo.Abp.BackgroundJobs` nuget package contains the default background job manager and it is installed to the startup templates by default. @@ -248,11 +248,76 @@ public class MyModule : AbpModule ```` * `JobPollPeriod` is used to determine the interval between two job polling operations. Default is 5000 ms (5 seconds). -* `MaxJobFetchCount` is used to determine the maximum job count to fetch in a single polling operation. Default is 1000. +* `MaxJobFetchCount` is used to determine the maximum job count to fetch in a single polling operation. It is also used as the batch size for the retention cleanup deletions. Default is 1000. * `DefaultFirstWaitDuration` is used to determine the duration to wait before the first retry. Default is 60 seconds. * `DefaultTimeout` is used to determine the timeout duration for a job. Default is 172800 seconds (2 days). * `DefaultWaitFactor` is used to determine the factor to increase the wait duration between retries. Default is 2.0. * `DistributedLockName` is used to determine the distributed lock name to use. Default is `AbpBackgroundJobWorker`. +* `StoreSuccessfulJobs` is used to determine whether to keep successfully completed jobs in the store instead of deleting them. Default is `false`. See the *Storing Successful Jobs* section. +* `SuccessfulJobRetentionTime` is used to determine how long a kept job is retained before the cleanup deletes it. Default is 7 days. Set to `null` to keep completed jobs forever. Only relevant when `StoreSuccessfulJobs` is enabled. +* `CleanSuccessfulJobsPeriod` is used to determine the interval between cleanup runs that delete expired completed jobs. Default is 3600000 ms (1 hour). +* `CleanupDistributedLockName` is used to determine the distributed lock name for the cleanup worker. Default is `AbpBackgroundJobCleanup`. +* `MaxParallelJobExecutionCount` is used to determine the maximum number of jobs a worker executes in parallel within one poll cycle. Default is 1. See the *Parallel Job Execution* section. +* `PerJobDistributedLockPrefix` is used to determine the prefix of the per-job distributed lock name used when `MaxParallelJobExecutionCount` is greater than 1. Default is `AbpBackgroundJob:`. + +### Storing Successful Jobs + +By default, the background job manager deletes a job from the store as soon as it runs successfully. If you want to keep completed jobs (for auditing or history), enable `StoreSuccessfulJobs`: + +````csharp +Configure(options => +{ + options.StoreSuccessfulJobs = true; + options.SuccessfulJobRetentionTime = TimeSpan.FromDays(30); //null to keep forever +}); +```` + +When enabled, a successful job is not deleted; instead its `CompletionTime` is set and it stays in the store. Completed jobs are excluded from the waiting jobs query, so they are not executed again. A cleanup worker periodically deletes completed jobs older than `SuccessfulJobRetentionTime`. + +> **Note:** The `IBackgroundJobStore` interface has new overloads (a `GetWaitingJobsAsync` overload that takes a job name filter and a `DeleteAsync` overload for cleanup). If you have a custom `IBackgroundJobStore` implementation, you must implement them for your code to compile. The built-in stores already implement them. + +### Dedicated Workers per Job Type + +By default, a single worker processes all job types. If you want to process certain job types separately (for example, slow or high-volume jobs), you can register dedicated workers, each handling only the specified job argument types with its own distributed lock: + +````csharp +Configure(options => +{ + options.AddDedicatedWorker("NotificationWorkerLock"); + options.AddDedicatedWorker("ReportWorkerLock"); +}); +```` + +Each dedicated worker processes only its configured job types. An additional default worker is automatically started to process all the remaining job types. In sequential mode, each worker (including the default one) runs independently under its own distributed lock (see *Parallel Job Execution* for how this changes when running jobs in parallel). + +If you don't want to specify a lock name, use the overloads without the `lockName` parameter; a stable, length-bounded lock name is then derived from the job argument types: + +````csharp +Configure(options => +{ + options.AddDedicatedWorker(); + options.AddDedicatedWorker(); +}); +```` + +> **Note:** Each job type can be handled by only one dedicated worker, and each worker must have a unique lock name; `AddDedicatedWorker` throws if this is violated. Dedicated workers require an `IBackgroundJobStore` that can filter jobs by name (the built-in stores can). + +### Parallel Job Execution + +By default, a worker executes waiting jobs one by one under a single worker-level distributed lock, so only one job runs at a time across all application instances. If you want to execute multiple jobs concurrently, set `MaxParallelJobExecutionCount` to a value greater than 1: + +````csharp +Configure(options => +{ + options.MaxParallelJobExecutionCount = 4; +}); +```` + +When it is greater than 1, the worker-level lock is not used. Instead, each job is claimed with its own distributed lock, so multiple application instances can execute different jobs at the same time. With a properly configured distributed lock provider, a job is not executed by more than one instance at a time. + +`MaxParallelJobExecutionCount` is a per-worker, per-poll-cycle limit — it is not a cluster-wide limit. A worker first fetches up to `MaxJobFetchCount` waiting jobs, then executes up to `MaxParallelJobExecutionCount` of them in parallel, so a single worker runs up to `min(MaxJobFetchCount, MaxParallelJobExecutionCount)` jobs per cycle; keep `MaxJobFetchCount` at least as large as `MaxParallelJobExecutionCount` to avoid capping the parallelism. When you also configure dedicated workers, each worker runs its own timer and claims up to `MaxParallelJobExecutionCount` jobs, so the effective concurrency is up to (number of workers) × `MaxParallelJobExecutionCount` per application instance, and up to (number of application instances) × (number of workers) × `MaxParallelJobExecutionCount` across the whole cluster. + +> **Important:** Configure `MaxParallelJobExecutionCount` and `PerJobDistributedLockPrefix` consistently across all application instances. Mixing sequential (worker lock) and parallel (per-job lock) instances removes the common mutual exclusion, and a different prefix produces a different per-job lock name for the same job — either case may let the same job run on more than one instance. As with the sequential mode, configure a real [distributed lock](../distributed-locking.md) provider for clustered deployments. ### Data Store @@ -439,4 +504,4 @@ See pre-built job manager alternatives: * [TickerQ Background Job Manager](./tickerq.md) ## See Also -* [Background Workers](../background-workers) \ No newline at end of file +* [Background Workers](../background-workers) diff --git a/docs/en/framework/infrastructure/background-workers/hangfire.md b/docs/en/framework/infrastructure/background-workers/hangfire.md index 9b65a488a9..43ddaceb19 100644 --- a/docs/en/framework/infrastructure/background-workers/hangfire.md +++ b/docs/en/framework/infrastructure/background-workers/hangfire.md @@ -47,6 +47,16 @@ public class YourModule : AbpModule > Hangfire background worker integration provides an adapter `HangfirePeriodicBackgroundWorkerAdapter` to automatically load any `PeriodicBackgroundWorkerBase` and `AsyncPeriodicBackgroundWorkerBase` derived classes as `IHangfireBackgroundWorker` instances. This allows you to still to easily switch over to use Hangfire as the background manager even you have existing background workers that are based on the [default background workers implementation](../background-workers). +The adapter uses UTC for recurring schedules by default and uses the default Hangfire queue when no queue is specified (a specified queue name is prefixed with `AbpHangfireOptions.DefaultQueuePrefix`, which is empty by default). You can configure both values globally for adapted periodic workers: + +````csharp +Configure(options => +{ + options.TimeZone = TimeZoneInfo.Local; + options.Queue = "periodic"; +}); +```` + ## Configuration You can install any storage for Hangfire. The most common one is SQL Server (see the [Hangfire.SqlServer](https://www.nuget.org/packages/Hangfire.SqlServer) NuGet package). diff --git a/docs/en/framework/infrastructure/blob-storing/database.md b/docs/en/framework/infrastructure/blob-storing/database.md index 1b568c247c..ff226ae3c6 100644 --- a/docs/en/framework/infrastructure/blob-storing/database.md +++ b/docs/en/framework/infrastructure/blob-storing/database.md @@ -9,6 +9,8 @@ BLOB Storing Database Storage Provider can store BLOBs in a relational or non-relational database. +The database provider reads the complete input stream into memory before saving a BLOB and returns BLOB content from an in-memory buffer. Database and driver value-size limits still apply. Consider an external object-storage provider for very large BLOBs or workloads that require end-to-end streaming. + There are two database providers implemented; * [Volo.Abp.BlobStoring.Database.EntityFrameworkCore](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Database.EntityFrameworkCore) package implements for [EF Core](../../data/entity-framework-core), so it can store BLOBs in [any DBMS supported](https://docs.microsoft.com/en-us/ef/core/providers/) by the EF Core. @@ -32,16 +34,16 @@ This command adds all the NuGet packages to corresponding layers of your solutio ### Manual Installation -Here, all the NuGet packages defined by this provider; +The following NuGet packages are defined by this provider: * [Volo.Abp.BlobStoring.Database.Domain.Shared](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Database.Domain.Shared) * [Volo.Abp.BlobStoring.Database.Domain](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Database.Domain) * [Volo.Abp.BlobStoring.Database.EntityFrameworkCore](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Database.EntityFrameworkCore) * [Volo.Abp.BlobStoring.Database.MongoDB](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Database.MongoDB) -You can only install Volo.Abp.BlobStoring.Database.EntityFrameworkCore or Volo.Abp.BlobStoring.Database.MongoDB (based on your preference) since they depends on the other packages. +You only need to install Volo.Abp.BlobStoring.Database.EntityFrameworkCore or Volo.Abp.BlobStoring.Database.MongoDB (based on your preference), since they depend on the other packages. -After installation, add `DepenedsOn` attribute to your related [module](../../architecture/modularity/basics.md). Here, the list of module classes defined by the related NuGet packages listed above: +After installation, add the `[DependsOn]` attribute to your related [module](../../architecture/modularity/basics.md). Here is the list of module classes defined by the related NuGet packages listed above: * `BlobStoringDatabaseDomainModule` * `BlobStoringDatabaseDomainSharedModule` @@ -52,6 +54,17 @@ Whenever you add a NuGet package to a project, also add the module class depende If you are using EF Core, you also need to configure your **Migration DbContext** to add BLOB storage tables to your database schema. Call `builder.ConfigureBlobStoring()` extension method inside the `OnModelCreating` method to include mappings to your DbContext. Then you can use the standard `Add-Migration` and `Update-Database` [commands](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/) to create necessary tables in your database. +If you are using MongoDB and combine module collections in a custom `AbpMongoDbContext`, call `modelBuilder.ConfigureBlobStoring()` inside the `CreateModel` method: + +````csharp +protected override void CreateModel(IMongoModelBuilder modelBuilder) +{ + base.CreateModel(modelBuilder); + + modelBuilder.ConfigureBlobStoring(); +} +```` + ## Configuration ### Connection String @@ -60,9 +73,17 @@ If you will use your `Default` connection string, you don't need to any addition If you want to use a separate database for BLOB storage, use the `AbpBlobStoring` as the [connection string](../../fundamentals/connection-strings.md) name in your configuration file (`appsettings.json`). In this case, also read the [EF Core Migrations](../../data/entity-framework-core/migrations.md) document to learn how to create and use a different database for a desired module. +### Common Database Properties + +The `AbpBlobStoringDatabaseDbProperties` class defines the following database settings. Set `DbTablePrefix` and `DbSchema` at application startup, before the database model is created: + +* `DbTablePrefix` (`Abp` by default) is the prefix for table and collection names. +* `DbSchema` (`null` by default) is the database schema used by EF Core. MongoDB does not use this property. +* `ConnectionStringName` (`AbpBlobStoring`) is the connection-string name used by both database providers. + ### Configuring the Containers -If you are using only the database storage provider, you don't need to manually configure it, since it is automatically done. If you are using multiple storage providers, you may want to configure it. +The database module selects `DatabaseBlobProvider` for the default container when no provider has already been selected. It does not replace an explicitly configured provider. If you use multiple storage providers, configure the database provider for the required default, typed or named containers. Configuration is done in the `ConfigureServices` method of your [module](../../architecture/modularity/basics.md) class, as explained in the [BLOB Storing document](../blob-storing). @@ -84,12 +105,18 @@ Configure(options => It is expected to use the [BLOB Storing services](../blob-storing) to use the BLOB storing system. However, if you want to work on the database tables/entities, you can use the following information. +### Database Tables and Collections + +With the default `Abp` prefix, EF Core maps the entities to the `AbpBlobContainers` and `AbpBlobs` tables. MongoDB uses collections with the same names. Changing `DbTablePrefix` changes both table and collection names, while `DbSchema` only changes the EF Core schema. + ### Entities Entities defined for this module: -* `DatabaseBlobContainer` (aggregate root) represents a container stored in the database. -* `DatabaseBlob` (aggregate root) represents a BLOB in the database. +* `DatabaseBlobContainer` (aggregate root) represents a container stored in the database. It stores the tenant identifier and the container name. Persisted container names have a maximum length of 128 characters. +* `DatabaseBlob` (aggregate root) represents a BLOB in the database. It stores the container identifier, tenant identifier, BLOB name and content. Persisted BLOB names have a maximum length of 256 characters. + +The provider creates a container record lazily when the first BLOB is saved to that container. Read, existence-check and delete operations do not create container records, and deleting the last BLOB does not delete its container record. See the [entities document](../../architecture/domain-driven-design/entities.md) to learn what is an entity and aggregate root. @@ -102,4 +129,4 @@ You can also use `IRepository` and `IRepository(options => * `CleanOldEventTimeIntervalSpan`: The event inbox system periodically checks and deletes the old processed events from the inbox in the database. You can set this value to determine the check period. Default value is 6 hours (`TimeSpan.FromHours(6)`). * `WaitTimeToDeleteProcessedInboxEvents`: Inbox events are not deleted from the database for a while even if they are successfully processed. This is for a system to prevent multiple process of the same event (if the event broker sends it twice). This configuration value determines the time to keep the processed events. Default value is 2 hours (`TimeSpan.FromHours(2)`). * `InboxWaitingEventMaxCount`: The maximum number of events to query at once from the inbox in the database. Default value is 1000. +* `InboxProcessorFilter`: An expression used to filter incoming event records fetched by the inbox processor. The default value is `null`, which includes all records. * `OutboxWaitingEventMaxCount`: The maximum number of events to query at once from the outbox in the database. Default value is 1000. +* `OutboxProcessorFilter`: An expression used to filter outgoing event records fetched by the outbox processor. The default value is `null`, which includes all records. * `DistributedLockWaitDuration`: ABP uses [distributed locking](../../distributed-locking.md) to prevent concurrent access to the inbox and outbox messages in the database, when running multiple instance of the same application. If an instance of the application can not obtain the lock, it tries after a duration. This is the configuration of that duration. Default value is 15 seconds (`TimeSpan.FromSeconds(15)`). * `InboxProcessorFailurePolicy`: The policy to handle the failure of the inbox processor. Default value is `Retry`. Possible values are: * `Retry`: The current exception and subsequent events will continue to be processed in order in the next cycle. * `RetryLater`: Skip the event that caused the exception and continue with the following events. The failed event will be retried after a delay that doubles with each retry, starting from the configured `InboxProcessorRetryBackoffFactor` (e.g., 10, 20, 40, 80 seconds). The default maximum retry count is 10 (configurable). Discard the event if it still fails after reaching the maximum retry count. * `Discard`: The event that caused the exception will be discarded and will not be retried. +* `InboxProcessorMaxRetryCount`: The maximum retry count used by the `RetryLater` failure policy before an event is discarded. Default value is `10`. * `InboxProcessorRetryBackoffFactor`: The initial retry delay factor (double) used when `InboxProcessorFailurePolicy` is `RetryLater`. The retry delay is calculated as: `delay = InboxProcessorRetryBackoffFactor × 2^retryCount`. Default value is `10`. ### Skipping Outbox diff --git a/docs/en/framework/infrastructure/event-bus/distributed/rabbitmq.md b/docs/en/framework/infrastructure/event-bus/distributed/rabbitmq.md index 7b7d64c4c6..5c8fc740f1 100644 --- a/docs/en/framework/infrastructure/event-bus/distributed/rabbitmq.md +++ b/docs/en/framework/infrastructure/event-bus/distributed/rabbitmq.md @@ -148,17 +148,20 @@ Configure(options => }); ```` -**Example: Configure the client, exchange names and prefetchCount** +**Example: Configure the client, exchange name, exchange type and prefetch count** ````csharp Configure(options => { options.ClientName = "TestApp1"; options.ExchangeName = "TestMessages"; + options.ExchangeType = "topic"; options.PrefetchCount = 1; }); ```` +`ExchangeType` uses RabbitMQ's `direct` exchange type when it is `null` or empty. + **Example: Configure the queue and exchange optional arguments** ```csharp diff --git a/docs/en/framework/infrastructure/features.md b/docs/en/framework/infrastructure/features.md index e450dc2068..744d5cec13 100644 --- a/docs/en/framework/infrastructure/features.md +++ b/docs/en/framework/infrastructure/features.md @@ -49,7 +49,7 @@ ABP uses the interception system to make the `[RequiresFeature]` attribute worki However, there are **some rules should be followed** in order to make it working; -* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual`. Otherwise, [dynamic proxy / interception](../../dynamic-proxying-interceptors.md) system can not work. +* If you are **not injecting** the service over an interface (like `IMyService`), then the methods of the service must be `virtual`. Otherwise, [dynamic proxy / interception](./interceptors.md) system can not work. * Only `async` methods (methods returning a `Task` or `Task`) are intercepted. > There is an exception for the **controller and razor page methods**. They **don't require** the following the rules above, since ABP uses the action/page filters to implement the feature checking in this case. diff --git a/docs/en/framework/infrastructure/interceptors.md b/docs/en/framework/infrastructure/interceptors.md index 9005c3d612..c6cedf7859 100644 --- a/docs/en/framework/infrastructure/interceptors.md +++ b/docs/en/framework/infrastructure/interceptors.md @@ -203,6 +203,26 @@ ABP uses interceptors for features like UOW, auditing, and authorization, which To avoid generating dynamic proxies for specific types, use the static class `DynamicProxyIgnoreTypes` and add the base classes of the types to the list. Subclasses of any listed base class are also ignored. ABP framework already adds some base classes to the list (`ComponentBase, ControllerBase, PageModel, ViewComponent`); you can add more base classes if needed. +You can also disable ABP class interceptors for all registrations or for types selected by a predicate: + +````csharp +// Disable all class interceptors. +context.Services.DisableAbpClassInterceptors(); + +// Or disable them only for selected types. The predicate runs for class +// service registrations and receives the exposed class service type, +// which is the exposed base class rather than the implementation type +// when a class is exposed through a base class. +context.Services.DisableAbpClassInterceptors( + new NamedTypeSelector( + "MyHotPathServices", + type => type.Namespace == "MyProject.HotPath" + ) +); +```` + +These methods control class interception. Interface-based interception is configured separately. + > Always use interface-based proxies instead of class-based proxies for better performance. ## See Also diff --git a/docs/en/framework/infrastructure/json.md b/docs/en/framework/infrastructure/json.md index a8d17528cb..54281e40b8 100644 --- a/docs/en/framework/infrastructure/json.md +++ b/docs/en/framework/infrastructure/json.md @@ -45,6 +45,35 @@ public class ProductManager } ``` +## IObjectSerializer + +`IObjectSerializer` (defined in the `Volo.Abp.Serialization` package, independently of the JSON system) serializes objects to and from `byte[]`. The default implementation uses UTF-8 JSON bytes from `System.Text.Json`: + +```csharp +public interface IObjectSerializer +{ + byte[]? Serialize(T? obj); + T? Deserialize(byte[] bytes); +} +``` + +Inject `IObjectSerializer` when a storage or transport API works with bytes instead of strings. To customize serialization for a specific type, implement `IObjectSerializer`. ABP automatically exposes conventionally registered implementations through the corresponding closed generic interface, and the default serializer uses that implementation for `T`: + +```csharp +public class ProductSerializer : IObjectSerializer, ITransientDependency +{ + public byte[]? Serialize(Product? obj) + { + return obj is null ? null : JsonSerializer.SerializeToUtf8Bytes(obj); + } + + public Product? Deserialize(byte[]? bytes) + { + return bytes is null ? null : JsonSerializer.Deserialize(bytes); + } +} +``` + ## Configuration ### AbpJsonOptions diff --git a/docs/en/framework/infrastructure/mail-kit.md b/docs/en/framework/infrastructure/mail-kit.md index f483aaa512..307ba61557 100644 --- a/docs/en/framework/infrastructure/mail-kit.md +++ b/docs/en/framework/infrastructure/mail-kit.md @@ -37,7 +37,7 @@ MailKit integration package uses the same settings defined by the email sending In addition to the standard settings, this package defines `AbpMailKitOptions` as a simple [options](../fundamentals/options.md) class. This class defines only one options: -* **SecureSocketOption**: Used to set one of the `SecureSocketOptions`. Default: `null` (uses the defaults). +* **SecureSocketOption**: Used to set one of the `SecureSocketOptions`. The default is `null`. In that case, ABP uses `SslOnConnect` when the SMTP `EnableSsl` setting is `true`; otherwise, it uses `StartTlsWhenAvailable`. **Example: Use *SecureSocketOptions.SslOnConnect*** @@ -52,4 +52,4 @@ Refer to the [MailKit documentation](http://www.mimekit.net/) to learn more abou ## See Also -* [Email sending](./emailing.md) \ No newline at end of file +* [Email sending](./emailing.md) diff --git a/docs/en/framework/infrastructure/object-to-object-mapping.md b/docs/en/framework/infrastructure/object-to-object-mapping.md index 5dcf526266..888655d446 100644 --- a/docs/en/framework/infrastructure/object-to-object-mapping.md +++ b/docs/en/framework/infrastructure/object-to-object-mapping.md @@ -224,7 +224,18 @@ public class MyProfile : Profile } ```` -> AutoMapper 14.x contains a [known vulnerability (GHSA-rvv3-g6hj-g44x)](https://github.com/advisories/GHSA-rvv3-g6hj-g44x). ABP Framework has applied a code-level mitigation (`MaxDepth = 64`) to address this. If you hold a commercial AutoMapper license, you can use [Volo.Abp.LuckyPenny.AutoMapper](luckypenny-automapper.md) to upgrade to the officially patched version. Alternatively, you can migrate to [Mapperly](../../../release-info/migration-guides/AutoMapper-To-Mapperly.md). +> AutoMapper 14.x contains a [known vulnerability (GHSA-rvv3-g6hj-g44x)](https://github.com/advisories/GHSA-rvv3-g6hj-g44x). ABP Framework has applied a code-level mitigation (`MaxDepth = 64`) to address this. If you hold a commercial AutoMapper license, you can use [Volo.Abp.LuckyPenny.AutoMapper](luckypenny-automapper.md) to upgrade to the officially patched version. Alternatively, you can migrate to [Mapperly](../../release-info/migration-guides/AutoMapper-To-Mapperly.md). + +The global maximum depth is configured by `AbpAutoMapperOptions.DefaultMaxDepth` and defaults to `64`. It is applied only when a map does not already configure `MaxDepth`. Set it to `null` to disable ABP's global default: + +````csharp +Configure(options => +{ + options.DefaultMaxDepth = null; +}); +```` + +> Disabling the global default also removes ABP's mitigation for unbounded mapping depth. Disable it only when every affected map has an explicit safe depth or the application uses an officially patched mapper. ## Mapperly Integration @@ -254,8 +265,8 @@ public partial class UserToUserDtoMapper : TwoWayMapperBase public override partial UserDto Map(User source); public override partial void Map(User source, UserDto destination); - public override partial User ReverseMap(UserDto destination); - public override partial void ReverseMap(UserDto destination, User source); + public override partial User ReverseMap(UserDto source); + public override partial void ReverseMap(UserDto source, User destination); } ```` @@ -280,15 +291,15 @@ public partial class UserToUserDtoMapper : TwoWayMapperBase //TODO: Perform actions after the mapping } - public override partial User ReverseMap(UserDto destination); - public override partial void ReverseMap(UserDto destination, User source); + public override partial User ReverseMap(UserDto source); + public override partial void ReverseMap(UserDto source, User destination); - public override partial void BeforeReverseMap(UserDto destination) + public override partial void BeforeReverseMap(UserDto source) { //TODO: Perform actions before the reverse mapping } - public override partial void AfterReverseMap(UserDto destination, User source) + public override partial void AfterReverseMap(UserDto source, User destination) { //TODO: Perform actions after the reverse mapping } diff --git a/docs/en/framework/infrastructure/settings.md b/docs/en/framework/infrastructure/settings.md index 38c9528d86..14b493e1fc 100644 --- a/docs/en/framework/infrastructure/settings.md +++ b/docs/en/framework/infrastructure/settings.md @@ -257,6 +257,15 @@ While a setting value provider is free to use any source to get the setting valu You can replace this service in the dependency injection system to customize the encryption/decryption process. Default implementation uses the `StringEncryptionService` which is implemented with the AES algorithm by default (see string [encryption document](./string-encryption.md) for more). +If an encrypted setting value cannot be decrypted, the default service logs a warning and returns the original value. This behavior helps when an existing setting is changed from unencrypted to encrypted. Set `AbpSettingOptions.ReturnOriginalValueIfDecryptFailed` to `false` to return an empty string instead: + +````csharp +Configure(options => +{ + options.ReturnOriginalValueIfDecryptFailed = false; +}); +```` + ## Setting Management Module The core setting system is pretty independent and doesn't make any assumption about how you manage (change) the setting values. Even the default `ISettingStore` implementation is the `NullSettingStore` which returns null for all setting values. diff --git a/docs/en/framework/infrastructure/sms-sending.md b/docs/en/framework/infrastructure/sms-sending.md index 64439be111..d2532709ec 100644 --- a/docs/en/framework/infrastructure/sms-sending.md +++ b/docs/en/framework/infrastructure/sms-sending.md @@ -85,20 +85,19 @@ The given `SendAsync` method in the example is an extension method to send an SM - `PhoneNumber` (`string`): Target phone number - `Text` (`string`): Message text -- `Properties` (`Dictionary`): Key-value pairs to pass custom arguments +- `Properties` (`IDictionary`): Key-value pairs to pass custom arguments ## NullSmsSender -`NullSmsSender` is a the default implementation of the `ISmsSender`. It writes SMS content to the [standard logger](../fundamentals/logging.md), rather than actually sending the SMS. +`NullSmsSender` is the default implementation of `ISmsSender`. It writes SMS content to the [standard logger](../fundamentals/logging.md), rather than actually sending the SMS. -This class can be useful especially in development time where you generally don't want to send real SMS. **However, if you want to actually send SMS, you should implement the `ISmsSender` in your application code.** +This class can be useful especially in development time where you generally don't want to send real SMS. To send real SMS, install one of the pre-built providers below or implement `ISmsSender` in your application code. ## Implementing the ISmsSender You can easily create your SMS sending implementation by creating a class that implements the `ISmsSender` interface, as shown below: ```csharp -using System.IO; using System.Threading.Tasks; using Volo.Abp.Sms; using Volo.Abp.DependencyInjection; @@ -107,14 +106,95 @@ namespace AbpDemo { public class MyCustomSmsSender : ISmsSender, ITransientDependency { - public async Task SendAsync(SmsMessage smsMessage) + public Task SendAsync(SmsMessage smsMessage) { // Send sms + return Task.CompletedTask; } } } ``` +## Pre-Built Providers + +Adding a provider module registers its sender as the `ISmsSender` implementation in place of the default `NullSmsSender`. + +### Aliyun + +Install the Aliyun provider package: + +```bash +abp add-package Volo.Abp.Sms.Aliyun +``` + +For manual installation, add the `Volo.Abp.Sms.Aliyun` package and declare a dependency on `AbpSmsAliyunModule`. + +Configure the provider in the `AbpAliyunSms` section: + +```json +{ + "AbpAliyunSms": { + "AccessKeyId": "your-access-key-id", + "AccessKeySecret": "your-access-key-secret", + "EndPoint": "your-endpoint" + } +} +``` + +Aliyun sends template-based messages. Set `SmsMessage.Text` to the template parameter JSON and use the `SignName` and `TemplateCode` properties: + +```csharp +var message = new SmsMessage( + "+012345678901", + "{\"code\":\"123456\"}" +); + +message.Properties["SignName"] = "MySign"; +message.Properties["TemplateCode"] = "SMS_123456789"; + +await _smsSender.SendAsync(message); +``` + +### Tencent Cloud + +Install the Tencent Cloud provider package: + +```bash +abp add-package Volo.Abp.Sms.TencentCloud +``` + +For manual installation, add the `Volo.Abp.Sms.TencentCloud` package and declare a dependency on `AbpSmsTencentCloudModule`. + +Configure the provider in the `AbpTencentCloudSms` section: + +```json +{ + "AbpTencentCloudSms": { + "SmsSdkAppId": "your-sdk-app-id", + "SecretId": "your-secret-id", + "SecretKey": "your-secret-key", + "Endpoint": "sms.tencentcloudapi.com", + "Region": "ap-guangzhou" + } +} +``` + +`Endpoint` defaults to `sms.tencentcloudapi.com` and `Region` defaults to `ap-guangzhou`. + +Set the sign and template identifiers through `TencentCloudSmsProperties`. The provider splits `SmsMessage.Text` by commas and sends the resulting values as template parameters: + +```csharp +var message = new SmsMessage( + "+012345678901", + "123456,5" +); + +message.Properties[TencentCloudSmsProperties.SignName] = "MySign"; +message.Properties[TencentCloudSmsProperties.TemplateId] = "123456"; + +await _smsSender.SendAsync(message); +``` + ## More [ABP](https://abp.io/) provides Twilio integration package to send SMS over [Twilio service](https://abp.io/docs/latest/modules/twilio-sms). diff --git a/docs/en/framework/infrastructure/string-encryption.md b/docs/en/framework/infrastructure/string-encryption.md index de8f6c75bf..ccdc9d8eac 100644 --- a/docs/en/framework/infrastructure/string-encryption.md +++ b/docs/en/framework/infrastructure/string-encryption.md @@ -112,8 +112,8 @@ Configure(opts => { opts.DefaultPassPhrase = "MyStrongPassPhrase"; opts.DefaultSalt = Encoding.UTF8.GetBytes("MyStrongSalt"); - opts.InitVectorBytes = Encoding.UTF8.GetBytes("YetAnotherStrongSalt"); - opts.Keysize = 512; + opts.InitVectorBytes = Encoding.UTF8.GetBytes("My16ByteInitVect"); + opts.Keysize = 256; }); ``` @@ -123,10 +123,10 @@ Configure(opts => Default value: `Encoding.ASCII.GetBytes("hgt!16kl")` -- **InitVectorBytes:** This constant string is used as a "salt" value for the PasswordDeriveBytes function calls. This size of the IV (in bytes) must = (keysize / 8). Default keysize is 256, so the IV must be 32 bytes long. Using a 16 character string here gives us 32 bytes when converted to a byte array. +- **InitVectorBytes:** The initialization vector used by AES. It must be exactly 16 bytes, regardless of the configured key size. Default value: `Encoding.ASCII.GetBytes("jkE49230Tf093b42")` -- **Keysize:** This constant is used to determine the keysize of the encryption algorithm. +- **Keysize:** The AES key size in bits. Use a key size supported by AES: `128`, `192`, or `256`. Default value: `256` diff --git a/docs/en/framework/infrastructure/text-templating/index.md b/docs/en/framework/infrastructure/text-templating/index.md index 853014c1cd..e8912889b6 100644 --- a/docs/en/framework/infrastructure/text-templating/index.md +++ b/docs/en/framework/infrastructure/text-templating/index.md @@ -33,6 +33,21 @@ ABP provides two templating engines; You can use different template engines in the same application, or even create a new custom template engine. +## Default Rendering Engine + +A template can select its rendering engine explicitly with `WithScribanEngine`, `WithRazorEngine` or `WithRenderEngine`. If it does not, the renderer uses `AbpTextTemplatingOptions.DefaultRenderingEngine`. + +The Scriban module selects Scriban as the default engine. The Razor module selects Razor only if no default has already been configured. You can explicitly select the application-wide default: + +````csharp +Configure(options => +{ + options.DefaultRenderingEngine = ScribanTemplateRenderingEngine.EngineName; +}); +```` + +An engine selected on a template definition takes precedence over this global default. + ## Source Code Get [the source code of the sample application](https://github.com/abpframework/abp-samples/tree/master/TextTemplateDemo) developed and referred through this document. @@ -41,4 +56,4 @@ Get [the source code of the sample application](https://github.com/abpframework/ * [The source code of the sample application](https://github.com/abpframework/abp-samples/tree/master/TextTemplateDemo) developed and referred through this document. * [Localization system](../../fundamentals/localization.md). -* [Virtual File System](../../infrastructure/virtual-file-system.md). \ No newline at end of file +* [Virtual File System](../../infrastructure/virtual-file-system.md). diff --git a/docs/en/framework/infrastructure/virtual-file-system.md b/docs/en/framework/infrastructure/virtual-file-system.md index 682fcd2f10..51fb3a44aa 100644 --- a/docs/en/framework/infrastructure/virtual-file-system.md +++ b/docs/en/framework/infrastructure/virtual-file-system.md @@ -116,6 +116,38 @@ public class MyService : ITransientDependency } ```` +### Dynamic Files + +`IDynamicFileProvider` can add, replace and delete virtual files at runtime. Inside `IVirtualFileProvider`, dynamic files take precedence over configured embedded and replacement physical file sets, so they can temporarily override a file with the same virtual path. ASP.NET Core's physical web-root provider is a separate, higher-precedence layer, as described in the *Physical Files* section below. Dynamic files also support exact file-path change notifications through the standard `Watch` method; directory and wildcard watches are not supported. + +````csharp +public class DynamicFileService : ITransientDependency +{ + private readonly IDynamicFileProvider _dynamicFileProvider; + + public DynamicFileService(IDynamicFileProvider dynamicFileProvider) + { + _dynamicFileProvider = dynamicFileProvider; + } + + public void SetFile(string content) + { + _dynamicFileProvider.AddOrUpdate( + new InMemoryFileInfo( + "/my-files/runtime.txt", + Encoding.UTF8.GetBytes(content), + "runtime.txt" + ) + ); + } + + public bool DeleteFile() + { + return _dynamicFileProvider.Delete("/my-files/runtime.txt"); + } +} +```` + ## ASP.NET Core Integration The Virtual File System is well integrated to ASP.NET Core: @@ -192,4 +224,4 @@ Physical files always override the virtual files. That means if you put a file u ## See Also -* [Video tutorial](https://abp.io/video-courses/essentials/virtual-file-system) \ No newline at end of file +* [Video tutorial](https://abp.io/video-courses/essentials/virtual-file-system) diff --git a/docs/en/framework/real-time/signalr.md b/docs/en/framework/real-time/signalr.md index be4160a819..4a3ffe69fc 100644 --- a/docs/en/framework/real-time/signalr.md +++ b/docs/en/framework/real-time/signalr.md @@ -223,6 +223,17 @@ app.UseConfiguredEndpoints(endpoints => }); ``` +### Dynamic Claims + +When [dynamic claims](../fundamentals/dynamic-claims.md) are enabled, ABP refreshes the principal when a client connects and periodically during hub method invocations. `AbpSignalROptions.CheckDynamicClaimsInterval` controls the minimum interval between invocation-time checks for a connection. The default is five seconds; set it to `null` to check on every invocation: + +```csharp +Configure(options => +{ + options.CheckDynamicClaimsInterval = TimeSpan.FromMinutes(1); +}); +``` + ### UserIdProvider ABP implements SignalR's `IUserIdProvider` interface to provide the current user id from the `ICurrentUser` service of the ABP (see [the current user service](../infrastructure/current-user.md)), so it will be integrated to the authentication system of your application. The implementing class is the `AbpSignalRUserIdProvider`, if you want to change/override it. diff --git a/docs/en/framework/ui/angular/commercial-ui.md b/docs/en/framework/ui/angular/commercial-ui.md new file mode 100644 index 0000000000..c2f24aed34 --- /dev/null +++ b/docs/en/framework/ui/angular/commercial-ui.md @@ -0,0 +1,88 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to use ABP Commercial Angular date range controls, standalone UI configuration and the public testing entrypoint." +} +``` + +# Commercial UI Components + +The `@volo/abp.commercial.ng.ui` package provides shared ABP Commercial Angular controls in addition to the separately documented [lookup components](./lookup-components.md) and [entity filters](./entity-filters.md). The package is included in ABP Commercial Angular application templates. + +## Date Range Controls + +`DateRangePickerComponent` and `DatetimeRangePickerComponent` are Angular form controls. `startDateProp` and `endDateProp` specify the two properties updated in the bound model: + +```ts +import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + DateRangePickerModule, + DatetimeRangePickerComponent, +} from '@volo/abp.commercial.ng.ui'; + +@Component({ + selector: 'app-report-range', + templateUrl: './report-range.component.html', + imports: [ + FormsModule, + DateRangePickerModule, + DatetimeRangePickerComponent, + ], +}) +export class ReportRangeComponent { + dateRange: { + startDate: string | Date | null; + endDate: string | Date | null; + } = { + startDate: null, + endDate: null, + }; +} +``` + +```html + +``` + +Use `abp-datetime-range-picker` with the same inputs when the model also needs start and end times. `labelText` is rendered as-is, so pass an already localized string (for example, a value resolved with the `LocalizationService`) instead of a localization key. + +## Standalone Configuration + +Register commercial UI configuration in the application providers. The following example enables flag icons: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { + provideCommercialUiConfig, + withEnableFlagIcon, +} from '@volo/abp.commercial.ng.ui/config'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideCommercialUiConfig( + withEnableFlagIcon(true), + ), + ], +}; +``` + +Calling `provideCommercialUiConfig()` also registers the shared profile-picture, impersonation and tenant-switching providers. Flag icons are disabled unless `withEnableFlagIcon(true)` is supplied. + +## Testing + +`CommercialUiTestingModule` imports and exports `BaseCommercialUiModule`, making its declarations available from the public testing entrypoint. Its `withConfig()` method returns the module registration without adding test doubles or providers: + +```ts +import { TestBed } from '@angular/core/testing'; +import { CommercialUiTestingModule } from '@volo/abp.commercial.ng.ui/testing'; + +await TestBed.configureTestingModule({ + imports: [CommercialUiTestingModule.withConfig()], +}).compileComponents(); +``` diff --git a/docs/en/framework/ui/angular/component-replacement.md b/docs/en/framework/ui/angular/component-replacement.md index ec1102f893..4750bbbb64 100644 --- a/docs/en/framework/ui/angular/component-replacement.md +++ b/docs/en/framework/ui/angular/component-replacement.md @@ -171,7 +171,7 @@ export const appConfig: ApplicationConfig = { withOptions({ dynamicLayouts: myDynamicLayouts, environment, - registerLocaleFn: registerLocale(), + registerLocaleFn: registerLocaleForEsBuild(), }), ), ], @@ -180,6 +180,8 @@ export const appConfig: ApplicationConfig = { In this code, `myDynamicLayouts` is the map of dynamic layouts you defined earlier. We pass this map to the `provideAbpCore` using the `withOptions` method. +This example uses the Angular application builder. Use `registerLocale()` instead when the application uses the Webpack builder. See [Registering a New Locale](./localization.md#registering-a-new-locale) for the builder-specific setup. + Now that you have defined the new layout, you can use it in the router definition. You do this by adding a new route that uses the new layout. Here's how you can do it: diff --git a/docs/en/framework/ui/angular/datetime-format-pipe.md b/docs/en/framework/ui/angular/datetime-format-pipe.md index e0e5493851..190b17aab6 100644 --- a/docs/en/framework/ui/angular/datetime-format-pipe.md +++ b/docs/en/framework/ui/angular/datetime-format-pipe.md @@ -1,41 +1,117 @@ ```json //[doc-seo] { - "Description": "Learn how to easily format dates in Angular using DateTime format pipes for shortDate, shortTime, and shortDateTime with culture settings." + "Description": "Format dates and handle clock-aware timezone conversion in ABP Angular applications with pipes, TimeService, and TimezoneService." } ``` {%{ -# DateTime Format Pipes +# Date and Time -You can format date by Date pipe of angular. +ABP Angular provides culture-aware format pipes, clock-aware UTC conversion, timezone selection, and date-time services. These APIs use the culture, clock, and timezone values from the application configuration. + +## Culture-Aware Format Pipes + +Angular's built-in `DatePipe` can format a date directly: -Example ```html -{{today | date 'dd/mm/yy'}} +{{ today | date:'dd/MM/yy' }} ``` -ShortDate, ShortTime and ShortDateTime format data like angular's data pipe but easier. Also the pipes get format from config service by culture. +The ABP pipes below use the short date and time patterns returned in the application localization configuration. -## ShortDate Pipe +### `shortDate` ```html - {{today | shortDate }} +{{ today | shortDate }} ``` +### `shortTime` -## ShortTime Pipe +```html +{{ today | shortTime }} +``` + +### `shortDateTime` ```html - {{today | shortTime }} +{{ today | shortDateTime }} ``` +These pipes extend Angular's `DatePipe`. They select the format pattern from `ConfigStateService`; they do not apply ABP's clock-aware timezone selection. Use `abpUtcToLocal` when the value also needs to follow the application's clock and timezone. + +## Clock-Aware UTC Conversion -## ShortDateTime Pipe +The `abpUtcToLocal` pipe accepts `date`, `time`, or `datetime` as its format type: ```html - {{today | shortDateTime }} +{{ order.creationTime | abpUtcToLocal:'datetime' }} +``` + +Its behavior depends on the clock configuration returned by the backend: + +- With the UTC clock enabled, it converts the input to `TimezoneService.timezone`, including the daylight-saving-time offset for that date. +- With a non-UTC clock, it formats the value without applying the configured timezone conversion. +- Empty or invalid input produces an empty string. + +The output pattern is still taken from the current application's short date and time formats. + +## `TimezoneService` + +Inject `TimezoneService` to read or persist the timezone used by the Angular application: + +```ts +import { TimezoneService } from '@abp/ng.core'; +import { Component, inject } from '@angular/core'; + +@Component({ + selector: 'app-timezone-selector', + template: ``, +}) +export class TimezoneSelectorComponent { + private readonly timezoneService = inject(TimezoneService); + + selectTimezone(): void { + this.timezoneService.setTimezone('Europe/Istanbul'); + } +} +``` + +`timezone` returns: + +- the browser timezone when the backend clock is not UTC; +- the `Abp.Timing.TimeZone` setting when the clock is UTC and the setting has a value; +- the browser timezone as a fallback when the UTC setting is empty. + +`setTimezone` writes the selected IANA timezone to the `__timezone` cookie only when the UTC clock is enabled. + +When you configure the application with `provideAbpCore`, its built-in `timezoneInterceptor` adds the effective timezone to outgoing `HttpClient` requests as the `__timezone` header. It does not add the header when the UTC clock is disabled. + +## `TimeService` + +`TimeService` returns [Luxon](https://moment.github.io/luxon/#/) `DateTime` values and formats dates with the current Angular locale: + +```ts +import { TimeService } from '@abp/ng.core'; +import { inject, Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class ScheduleFormatter { + private readonly timeService = inject(TimeService); + + formatForIstanbul(value: string): string { + return this.timeService.format(value, 'ff', 'Europe/Istanbul'); + } +} ``` +| Method | Behavior | +| --- | --- | +| `now(zone = 'local')` | Returns the current time in the requested IANA timezone. | +| `toZone(value, zone)` | Parses an ISO string or `Date` and returns a Luxon value in the requested timezone. | +| `format(value, format = 'ff', zone = 'local')` | Converts to the timezone, applies its DST rules, and formats with the current locale. | +| `formatDateWithStandardOffset(value, format = 'ff', zone?)` | Applies the zone's January 1 offset and formats without any further timezone or DST conversion. | +| `formatWithoutTimeZone(value, format = 'ff')` | Formats the parsed ISO clock fields without shifting them to another timezone. | + }%} diff --git a/docs/en/framework/ui/angular/http-error-handling.md b/docs/en/framework/ui/angular/http-error-handling.md index 33c4b0cec2..aa436a8293 100644 --- a/docs/en/framework/ui/angular/http-error-handling.md +++ b/docs/en/framework/ui/angular/http-error-handling.md @@ -13,7 +13,11 @@ ABP offers a configurations for errors handling like below ```ts //app.config.ts -import { provideAbpThemeShared } from '@abp/ng.theme.shared'; +import { ApplicationConfig } from '@angular/core'; +import { + provideAbpThemeShared, + withHttpErrorConfig, +} from '@abp/ng.theme.shared'; import { CustomErrorComponent } from './custom-error.component'; export const appConfig: ApplicationConfig = { @@ -75,15 +79,13 @@ export function handleHttpErrors(injector: Injector, httpError: HttpErrorRespons } // app.config.ts -import { Error404Component } from './error404/error404.component'; -import { handleHttpErrors } from './http-error-handling'; -import { HTTP_ERROR_HANDLER, ... } from '@abp/ng.theme.shared'; +import { ApplicationConfig } from '@angular/core'; +import { HTTP_ERROR_HANDLER } from '@abp/ng.theme.shared'; +import { handleHttpErrors } from './http-error-handler'; export const appConfig: ApplicationConfig = { providers: [ - ... { provide: HTTP_ERROR_HANDLER, useValue: handleHttpErrors }, - ... ], }; @@ -116,7 +118,9 @@ export function handleHttpErrors( - `httpError` is the second parameter of the error handler function which is registered to the `HTTP_ERROR_HANDLER` provider. Type of the `httpError` is `HttpErrorResponse`. ```ts -import { of } from "rxjs"; +import { HttpErrorResponse } from '@angular/common/http'; +import { Injector } from '@angular/core'; +import { of } from 'rxjs'; export function handleHttpErrors( injector: Injector, @@ -163,11 +167,13 @@ See an example: ```ts // custom-error-handler.service.ts -import { inject, Injectable } from "@angular/core"; -import { HttpErrorResponse } from "@angular/common/http"; -import { CustomHttpErrorHandlerService } from "@abp/ng.theme.shared"; -import { CUSTOM_HTTP_ERROR_HANDLER_PRIORITY } from "@abp/ng.theme.shared"; -import { ToasterService } from "@abp/ng.theme.shared"; +import { HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { + CUSTOM_HTTP_ERROR_HANDLER_PRIORITY, + CustomHttpErrorHandlerService, + ToasterService, +} from '@abp/ng.theme.shared'; @Injectable({ providedIn: "root" }) export class MyCustomErrorHandlerService @@ -190,8 +196,8 @@ export class MyCustomErrorHandlerService // If this service is picked from ErrorHandler, this execute method will be called. execute() { this.toaster.error( - this.error.error?.error?.message || "Bad request!", - "400" + this.error?.error?.error?.message || 'Bad request!', + '400', ); } } @@ -200,17 +206,17 @@ export class MyCustomErrorHandlerService ```ts // app.config.ts -import { CUSTOM_ERROR_HANDLERS, ... } from '@abp/ng.theme.shared'; +import { ApplicationConfig } from '@angular/core'; +import { CUSTOM_ERROR_HANDLERS } from '@abp/ng.theme.shared'; import { MyCustomErrorHandlerService } from './custom-error-handler.service'; export const appConfig: ApplicationConfig = { providers: [ - //... { provide: CUSTOM_ERROR_HANDLERS, useExisting: MyCustomErrorHandlerService, multi: true, - } + }, ], }; ``` @@ -227,5 +233,5 @@ In the example above: - If your service cannot handle the error. Then ABP will check the next Error Service. - If none of the service handle the error. Then basic confirmation message about the error will be shown to the user. -- You can provide more than one service, with CUSTOM_ERROR_HANDLER injection token. +- You can provide more than one service with the `CUSTOM_ERROR_HANDLERS` injection token. - If you want your custom service to be evaluated (checked) earlier, set the priority variable high. diff --git a/docs/en/framework/ui/angular/http-requests.md b/docs/en/framework/ui/angular/http-requests.md index a5ca0dec66..db7c65ab12 100644 --- a/docs/en/framework/ui/angular/http-requests.md +++ b/docs/en/framework/ui/angular/http-requests.md @@ -30,7 +30,7 @@ An `HttpInterceptor` is able to catch `HttpErrorResponse` and can be used for a ## RestService -ABP core module has a utility service for HTTP requests: `RestService`. Unless explicitly configured otherwise, it catches HTTP errors and dispatches a `RestOccurError` action. This action is then captured by the `ErrorHandler` introduced by the `ThemeSharedModule`. Since you should already import this module in your app, when the `RestService` is used, all HTTP errors get automatically handled by default. +ABP core module has a utility service for HTTP requests: `RestService`. Unless explicitly configured otherwise, it catches HTTP errors and reports them through `HttpErrorReporterService`. The error handler provided by the Theme Shared package subscribes to that service and displays the appropriate error UI. When the Theme Shared provider is configured in your application, HTTP errors from `RestService` are handled automatically by default. ### Getting Started with RestService @@ -110,7 +110,7 @@ deleteFoo(id: number) { } ``` -`skipHandleError` config option, when set to `true`, disables the error handler and the returned observable starts throwing an error that you can catch in your subscription. +The `skipHandleError` config option, when set to `true`, prevents `RestService` from reporting the error through `HttpErrorReporterService`. The returned observable still throws the error, so you can handle it in the caller. ```js removeFooFromList(id: number) { diff --git a/docs/en/framework/ui/angular/list-service.md b/docs/en/framework/ui/angular/list-service.md index dab58d0e33..6c65ac6efc 100644 --- a/docs/en/framework/ui/angular/list-service.md +++ b/docs/en/framework/ui/angular/list-service.md @@ -15,11 +15,11 @@ `ListService` is **not provided in root**. The reason is, this way, it will clear any subscriptions on component destroy. You may use the optional `LIST_QUERY_DEBOUNCE_TIME` token to adjust the debounce behavior. -```js -import { ListService } from '@abp/ng.core'; +```ts +import { LIST_QUERY_DEBOUNCE_TIME, ListService } from '@abp/ng.core'; import { BookDto } from '../models'; import { BookService } from '../services'; -import { inject } from '@angular/core'; +import { Component, inject } from '@angular/core'; @Component({ /* class metadata here */ @@ -29,7 +29,7 @@ import { inject } from '@angular/core'; // [Optional] // Provide this token if you want a different debounce time. - // Default is 300. Cannot be 0. Any value below 100 is not recommended. + // Default is 300. Use 0 to disable debouncing. { provide: LIST_QUERY_DEBOUNCE_TIME, useValue: 500 }, ], template: ` @@ -55,7 +55,7 @@ class BookComponent { this.list.hookToQuery(bookStreamCreator).subscribe( response => { this.items = response.items; - this.count = response.count; + this.count = response.totalCount; // If you use OnPush change detection strategy, // call detectChanges method of ChangeDetectorRef here. } diff --git a/docs/en/framework/ui/angular/localization.md b/docs/en/framework/ui/angular/localization.md index d50484c83a..b6a77ec0f7 100644 --- a/docs/en/framework/ui/angular/localization.md +++ b/docs/en/framework/ui/angular/localization.md @@ -72,6 +72,27 @@ Then, we can use this key like this: ``` +### Using the Async Localization Pipe + +Use `abpAsyncLocalization` when the template must wait for the application localization state before resolving a key. The pipe returns an observable, so combine it with Angular's `async` pipe: + +```ts +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { AsyncLocalizationPipe } from '@abp/ng.core'; + +@Component({ + selector: 'app-greeting', + imports: [AsyncPipe, AsyncLocalizationPipe], + template: ` +

{%{{{ 'MyProjectName::Greeting' | abpAsyncLocalization | async }}}%}

+ `, +}) +export class GreetingComponent {} +``` + +The observable initially emits an empty string. After the localization configuration is available, it emits the localized value. It also emits an empty string when the key cannot be resolved. Interpolation parameters can be passed in the same way as with `abpLocalization`. + ### Using the Localization Service First of all, you should import the `LocalizationService` from **@abp/ng.core** @@ -212,7 +233,7 @@ The localizations above can be used like this:
{%{{{ 'MyProjectName::HomePage' | abpLocalization }}}%}
``` -> **Note:** If you have specified the same localizations in the UI and backend, the backend localizations override the UI localizations. +> **Note:** If the same localization key is specified in the UI and backend, the UI localization overrides the backend localization. ## RTL Support @@ -279,9 +300,42 @@ export class AppComponent {} ## Registering a New Locale -Since ABP has more than one language, Angular locale files load lazily using [Webpack's import function](https://webpack.js.org/api/module-methods/#import-1) to avoid increasing the bundle size and to register the Angular core using the [`registerLocaleData`](https://angular.dev/api/common/registerLocaleData) function. The chunks to be included in the bundle are specified by the [Webpack's magic comments](https://webpack.js.org/api/module-methods/#magic-comments) as hard-coded. Therefore a `registerLocale` function that returns Webpack `import` function must be passed to `provideAbpCore(withOptions({...}))`. +ABP loads Angular locale data lazily and registers it with Angular's [`registerLocaleData`](https://angular.dev/api/common/registerLocaleData) function. The registration function depends on the Angular builder used by your application: + +| Builder | Registration function | +| --- | --- | +| Angular application builder (`@angular/build:application`) | `registerLocaleForEsBuild()` | +| Webpack builder | `registerLocale()` | + +Pass the selected function as `registerLocaleFn` to `provideAbpCore(withOptions({...}))`. + +### Application Builder (EsBuild) + +Current ABP Angular application templates use the Angular application builder. Configure them with `registerLocaleForEsBuild`: -### registerLocaleFn +```ts +import { provideAbpCore, withOptions } from '@abp/ng.core'; +import { registerLocaleForEsBuild } from '@abp/ng.core/locale'; +import { ApplicationConfig } from '@angular/core'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAbpCore( + withOptions({ + environment, + registerLocaleFn: registerLocaleForEsBuild({ + cultureNameLocaleFileMap: { 'pt-BR': 'pt' }, + }), + }), + ), + ], +}; +``` + +`registerLocaleForEsBuild` uses a fixed list of supported Angular locale imports so the application builder can include them in the bundle. + +### Webpack Builder The `registerLocale` function, exported from the `@abp/ng.core/locale` package, is a **higher-order function**. @@ -290,7 +344,7 @@ It accepts the following parameters: - **`cultureNameLocaleFileMap`** – an object that maps culture names to their corresponding locale files. - **`errorHandlerFn`** – a function that handles any errors that occur during locale loading. -It returns a **Webpack `import` function**. +It returns a **Webpack `import` function**. Use it only when the application is built with Webpack. You should use `registerLocale` within the `withOptions` function of `provideAbpCore`, as shown in the example below: @@ -326,14 +380,14 @@ Some of the culture names defined in .NET do not match Angular locales. In such ![locale-error](./images/locale-error.png) -If you see an error like this, you should pass the `cultureNameLocaleFileMap` property like below to the `registerLocale` function. +If you see an error like this, pass the `cultureNameLocaleFileMap` property to the registration function selected for your builder. The following example uses the Angular application builder: ```ts // app.config.ts -import { registerLocale } from "@abp/ng.core/locale"; -// if you have commercial license and the language management module, add the below import -// import { registerLocale } from '@volo/abp.ng.language-management/locale'; +import { registerLocaleForEsBuild } from "@abp/ng.core/locale"; +// If you use the Language Management module, replace the import above with: +// import { registerLocale as registerLocaleForEsBuild } from '@volo/abp.ng.language-management/locale'; export const appConfig: ApplicationConfig = { providers: [ @@ -341,7 +395,7 @@ export const appConfig: ApplicationConfig = { provideAbpCore( withOptions({ // ..., - registerLocaleFn: registerLocale({ + registerLocaleFn: registerLocaleForEsBuild({ cultureNameLocaleFileMap: { DotnetCultureName: "AngularLocaleFileName", "pt-BR": "pt", // example @@ -353,6 +407,8 @@ export const appConfig: ApplicationConfig = { }; ``` +For a Webpack project, pass the same option object to `registerLocale()` instead. + See [all locale files in Angular](https://github.com/angular/angular/tree/master/packages/common/locales). ### Adding a New Culture @@ -370,7 +426,7 @@ import( ).then((m) => storeLocaleData(m.default, "your-locale")); ``` -You can also configure a custom `registerLocale` function that can be passed to the abp core provider configuration options: +In a Webpack project, you can also configure a custom `registerLocale` function and pass it to the ABP Core provider options: ```ts // register-locale.ts diff --git a/docs/en/framework/ui/angular/lookup-search-component.md b/docs/en/framework/ui/angular/lookup-search-component.md new file mode 100644 index 0000000000..144f24c1e1 --- /dev/null +++ b/docs/en/framework/ui/angular/lookup-search-component.md @@ -0,0 +1,95 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to use the generic ABP Angular lookup search component with remote searches, two-way values and custom templates." +} +``` + +# Lookup Search Component + +`LookupSearchComponent` is a generic standalone search control exported by `@abp/ng.components/lookup`. A search function returns observable lookup items, and the component manages debouncing, loading, selection and clearing. + +This component is part of the open-source `@abp/ng.components` package. It is different from the [commercial lookup component family](./lookup-components.md), which provides form-oriented typeahead, select and table controls. + +The package is included in the Angular application templates. Install it if the application does not already reference it: + +```bash +npm install @abp/ng.components +``` + +```ts +import { Component, inject, signal } from '@angular/core'; +import { + LookupItem, + LookupSearchComponent, + LookupSearchFn, +} from '@abp/ng.components/lookup'; +import { map } from 'rxjs'; +import { BookService } from '../services/book.service'; + +interface BookLookupItem extends LookupItem { + authorName: string; +} + +@Component({ + selector: 'app-book-lookup', + templateUrl: './book-lookup.component.html', + imports: [LookupSearchComponent], +}) +export class BookLookupComponent { + private readonly bookService = inject(BookService); + + readonly selectedBookId = signal(''); + readonly selectedBookName = signal(''); + + readonly searchBooks: LookupSearchFn = filter => + this.bookService.search(filter).pipe( + map(books => + books.map(book => ({ + key: book.id, + displayName: book.name, + authorName: book.authorName, + })), + ), + ); + + onBookSelected(book: BookLookupItem) { + console.log(book.key); + } +} +``` + +{%{ +```html + + + {{ book.displayName }} + {{ book.authorName }} + + +``` +}%} + +Each lookup item uses `key` as its selected value and `displayName` as its displayed value by default. Set `valueKey` or `displayKey` to use another item property. + +Searches use a 300 millisecond debounce by default. Configure `debounceTime` and `minSearchLength` when the remote endpoint needs different behavior. `label` and `placeholder` values are passed through ABP localization. + +`selectedValue` and `displayValue` are model inputs and support two-way binding. The component also emits `searchChanged` for input changes and `itemSelected` after selection. + +Add an `#itemTemplate` template to customize each result, as in the previous example. Add a `#noResultsTemplate` template to replace the default no-results content: + +```html + +
No matching books
+
+``` diff --git a/docs/en/framework/ui/angular/modifying-the-menu.md b/docs/en/framework/ui/angular/modifying-the-menu.md index eec9e79ba0..7f381997de 100644 --- a/docs/en/framework/ui/angular/modifying-the-menu.md +++ b/docs/en/framework/ui/angular/modifying-the-menu.md @@ -16,7 +16,7 @@ The `logoUrl` property in the environment variables is the url of the logo. You can add your logo to `src/assets` folder and set the `logoUrl` as shown below: -```js +```ts export const environment = { // other configurations application: { @@ -74,7 +74,7 @@ Add the following to your `src/styles.scss`: You can add routes to the menu by calling the `add` method of `RoutesService`. It is a singleton service, i.e. provided in root, so you can inject and use it immediately. -```js +```ts import { RoutesService, eLayoutType } from '@abp/ng.core'; import { Component, inject } from '@angular/core'; @@ -106,10 +106,10 @@ export class AppComponent { An alternative and probably cleaner way is to use a route provider. First create a provider: -```js +```ts // route.provider.ts import { RoutesService, eLayoutType } from '@abp/ng.core'; -import { provideAppInitializer } from '@angular/core'; +import { inject, provideAppInitializer } from '@angular/core'; export const APP_ROUTE_PROVIDER = [ provideAppInitializer(() => { @@ -119,7 +119,7 @@ export const APP_ROUTE_PROVIDER = [ function configureRoutes() { const routesService = inject(RoutesService); - routes.add([ + routesService.add([ { path: '/your-path', name: 'Your navigation', @@ -142,13 +142,14 @@ function configureRoutes() { We can also define a group for navigation elements. It's an optional property - **Note:** It'll also include groups that were defined at the modules -```js +```ts // route.provider.ts import { RoutesService } from '@abp/ng.core'; +import { inject } from '@angular/core'; function configureRoutes() { const routesService = inject(RoutesService); - routes.add([ + routesService.add([ { //etc.. group: 'ModuleName::GroupName' @@ -167,7 +168,7 @@ function configureRoutes() { To get the route items as grouped we can use the `groupedVisible` (or Observable one `groupedVisible$`) getter methods - It returns `RouteGroup[]` if there is any group in the route tree, otherwise it returns `undefined` -```js +```ts import { ABP, RoutesService, RouteGroup } from "@abp/ng.core"; import { Component, inject } from "@angular/core"; import { Observable } from "rxjs"; @@ -185,7 +186,7 @@ export class AppComponent { ...and then in app.config.ts... - The `groupedVisible` method will return the `Others` group for ungrouped items, the default key is `AbpUi::OthersGroup`, we can change this `key` via the `OTHERS_GROUP` injection token -```js +```ts import { OTHERS_GROUP } from '@abp/ng.core'; import { APP_ROUTE_PROVIDER } from './route.provider'; @@ -239,7 +240,7 @@ You can define your routes by adding `routes` as a child property to `data` prop You can add the `routes` property like below: -```js +```ts { path: 'your-path', data: { @@ -263,7 +264,7 @@ You can add the `routes` property like below: Alternatively, you can do this: -```js +```ts { path: 'your-path', data: { @@ -297,7 +298,7 @@ After adding the `routes` property as described above, the navigation menu looks The `patch` method of `RoutesService` finds a route by its name and replaces its configuration with the new configuration passed as the second parameter. Similarly, `remove` method finds a route and removes it along with its children. Also you can use `removeByParam` method to delete the routes with given properties. -```js +```ts // this.routes is instance of RoutesService // eThemeSharedRouteNames enum can be imported from @abp/ng.theme.shared @@ -343,7 +344,7 @@ After the operations above, the new menu looks like below: You can add elements to the right part of the menu by calling the `addItems` method of `NavItemsService`. It is a singleton service, i.e. provided in root, so you can inject and use it immediately. -```js +```ts import { NavItemsService } from '@abp/ng.theme.shared'; import { Component, inject } from '@angular/core'; @@ -387,7 +388,7 @@ This inserts a search input and a sign out icon to the menu. The final UI looks The `patchItem` method of `NavItemsService` finds an element by its `id` property and replaces its configuration with the new configuration passed as the second parameter. Similarly, `removeItem` method finds an element and removes it. -```js +```ts export class AppComponent { private navItems = inject(NavItemsService); diff --git a/docs/en/framework/ui/angular/oauth-module.md b/docs/en/framework/ui/angular/oauth-module.md index 9e6d4a8e1e..b8730806e4 100644 --- a/docs/en/framework/ui/angular/oauth-module.md +++ b/docs/en/framework/ui/angular/oauth-module.md @@ -7,23 +7,56 @@ # ABP OAuth Package -The authentication functionality has been moved from @abp/ng.core to @abp/ng.oauth since v7.0. +The authentication implementation was moved from `@abp/ng.core` to `@abp/ng.oauth` in v7.0. The core package defines the authentication abstractions and tokens, while the OAuth package supplies their `angular-oauth2-oidc` implementations. -If your app is version 8.3 or higher, you should include "provideAbpOAuth()" after "provideAbpCore()" in the `appConfig` array of your `app.config.ts`. +The package is included in the Angular application templates. Install it if the application does not already reference it: -Those abstractions can be found in the @abp/ng-core packages. +```bash +npm install @abp/ng.oauth +``` + +## Standalone Setup + +Add `provideAbpOAuth()` after `provideAbpCore()` in the `providers` array of `app.config.ts`: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideAbpCore, withOptions } from '@abp/ng.core'; +import { registerLocaleForEsBuild } from '@abp/ng.core/locale'; +import { provideAbpOAuth } from '@abp/ng.oauth'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAbpCore( + withOptions({ + environment, + registerLocaleFn: registerLocaleForEsBuild(), + }), + ), + provideAbpOAuth(), + ], +}; +``` + +`AbpOAuthModule.forRoot()` is deprecated. Use the standalone provider for new applications. + +## Registered Authentication Services + +`provideAbpOAuth()` registers or replaces these public core abstractions: -- `AuthService` (the class that implements the IAuthService interface). -- `NAVIGATE_TO_MANAGE_PROFILE` Inject token. -- `ApiInterceptor` (the class that implements the IApiInterceptor interface). +- `AuthService`, `AuthGuard`, `authGuard` and `asyncAuthGuard` with their OAuth implementations. +- `ApiInterceptor` and its `HTTP_INTERCEPTORS` registration. +- `PIPE_TO_LOGIN_FN_KEY` with the function used when authentication is required. +- `CHECK_AUTHENTICATION_STATE_FN_KEY` with the function that checks and stores the current authentication state. +- `NAVIGATE_TO_MANAGE_PROFILE` with navigation to the authority's account-management page. +- `AuthErrorFilterService` with the OAuth error filter. +- `OAuthStorage`, using `BrowserTokenStorageService` or `ServerTokenStorageService` for an SSR-started application and `MemoryTokenStorageService` otherwise. -Those base classes are overridden by the "AbpOAuthModule" for oAuth. There are also three functions provided with AbpOAuthModule. +It also registers the OAuth configuration initializer and the providers from `angular-oauth2-oidc`. -- `PIPE_TO_LOGIN_FN_KEY` a provide that calls a function when the user is not authenticated. The function should be PipeToLoginFn type. -- `SET_TOKEN_RESPONSE_TO_STORAGE_FN_KEY` a provide that calls a function when the user is authenticated. The function should be SetTokenResponseToStorageFn type. -- `CHECK_AUTHENTICATION_STATE_FN_KEY` a provide that calls a function when the user is authenticated and stores the auth state. The function should be CheckAuthenticationStateFn type. - The tokens and interfaces are in the `@abp/ng.core` package but the implementation of these interfaces is in the `@abp/ng.oauth` package. +## API Interceptor -If you want to make your own authentication system, you must also change these 'abstract' classes. +For non-external requests, the OAuth API interceptor adds the `X-Requested-With` header and, when the corresponding values are available, an `Authorization` bearer token, `Accept-Language` and the configured tenant header. Existing authorization, language and tenant headers are preserved. Requests marked with the `IS_EXTERNAL_REQUEST` HTTP context token are sent without those ABP headers. The interceptor also integrates every request with the HTTP wait service. -ApiInterceptor is provided by `@abp/ng.core` but overridden with `@abp/ng.oauth`. The ApiInterceptor adds the token, accepted-language, and tenant id to the header of the HTTP request. It also calls the http-wait service. +To implement another authentication system, provide replacements for the core services and tokens used by the application instead of depending on the OAuth implementations. diff --git a/docs/en/framework/ui/angular/permission-management.md b/docs/en/framework/ui/angular/permission-management.md index 8d30d925bb..5ec8497af4 100644 --- a/docs/en/framework/ui/angular/permission-management.md +++ b/docs/en/framework/ui/angular/permission-management.md @@ -42,7 +42,8 @@ const hasIdentityOrAccountPermission = this.permissionService.getGrantedPolicy( Please consider the following **rules** when creating your permission selectors: -- Maximum 2 keys can be combined. +- Two or more keys can be combined with the same operator. +- Do not mix `&&` and `||` in the same selector. - `&&` operator looks for both keys. - `||` operator looks for either key. - Empty string `''` as key will return `true` @@ -89,18 +90,14 @@ In some cases, a custom permission management may be needed. All you need to do - First, create a service of your own. Let's call it `CustomPermissionService` and extend `PermissionService` from `@abp/ng.core` as follows: -```js -import { ConfigStateService, PermissionService } from '@abp/ng.core'; -import { Injectable, inject } from '@angular/core'; +```ts +import { PermissionService } from '@abp/ng.core'; +import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root', }) export class CustomPermissionService extends PermissionService { - constructor() { - super(inject(ConfigStateService)); - } - // This is an example to show how to override the methods getGrantedPolicy$(key: string) { return super.getGrantedPolicy$(key); diff --git a/docs/en/framework/ui/angular/quick-start.md b/docs/en/framework/ui/angular/quick-start.md index 8a9f6cb7b4..ef9a626803 100644 --- a/docs/en/framework/ui/angular/quick-start.md +++ b/docs/en/framework/ui/angular/quick-start.md @@ -1,19 +1,19 @@ ```json //[doc-seo] { - "Description": "Learn how to set up your development environment for ABP Angular 21.x with this quick start guide, ensuring a smooth development experience." + "Description": "Learn how to set up your development environment for ABP Angular 22.0.x with this quick start guide, ensuring a smooth development experience." } ``` # ABP Angular Quick Start -**In this version ABP uses Angular [21.2.x](https://github.com/angular/angular/tree/21.2.x) version. You don't have to install Angular CLI globally** +**In this version ABP uses Angular [22.0.x](https://github.com/angular/angular/tree/22.0.x) version. You don't have to install Angular CLI globally** ## How to Prepare Development Environment Please follow the steps below to prepare your development environment for Angular. -1. **Install Node.js:** Please visit [Node.js downloads page](https://nodejs.org/en/download/) and download proper Node.js `v20.19+` installer for your OS. An alternative is to install [NVM](https://github.com/nvm-sh/nvm) and use it to have multiple versions of Node.js in your operating system. +1. **Install Node.js:** Please visit [Node.js downloads page](https://nodejs.org/en/download/) and download proper Node.js `^22.22.3 || ^24.15.0 || ^26.0.0` installer for your OS. An alternative is to install [NVM](https://github.com/nvm-sh/nvm) and use it to have multiple versions of Node.js in your operating system. 2. **[Optional] Install Yarn:** You may install Yarn v1.22+ (not v2) following the instructions on [the installation page](https://classic.yarnpkg.com/en/docs/install). Yarn v1 delivers an arguably better developer experience compared to npm v10 and below. You may skip this step and work with npm, which is built-in in Node.js, instead. 3. **[Optional] Install VS Code:** [VS Code](https://code.visualstudio.com/) is a free, open-source IDE which works seamlessly with TypeScript. Although you can use any IDE including Visual Studio or Rider, VS Code will most likely deliver the best developer experience when it comes to Angular projects. ABP project templates even contain plugin recommendations for VS Code users, which VS Code will ask you to install when you open the Angular project folder. Here is a list of recommended extensions: - [Angular Language Service](https://marketplace.visualstudio.com/items?itemName=angular.ng-template) diff --git a/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md b/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md new file mode 100644 index 0000000000..aafd588b5b --- /dev/null +++ b/docs/en/framework/ui/angular/release-notes/angular-22-typescript-6.md @@ -0,0 +1,116 @@ +```json +//[doc-seo] +{ + "Description": "Upgrade your ABP solutions to Angular version 22.0.x" +} +``` + +# Release Notes: Angular 22 and TypeScript 6 Upgrade + +## Overview + +This release updates ABP Angular UI applications to: + +* Angular `22.x` +* TypeScript `6.x` + +This upgrade aligns ABP projects with the latest Angular ecosystem and provides access to the newest framework improvements while ensuring long-term maintainability and support. + +## What's Changed + +### 1. Frontend Stack Upgrades + +The core frontend stack has been updated: + +* `@angular/*` packages have been upgraded to version 22 +* `typescript` has been upgraded to version 6 +* ABP and ABP Commercial npm packages must be upgraded to the corresponding ABP release line (version 10.6) + +### 2. Change Detection Behavior + +Angular 22 introduces updated change detection behavior. + +* Components without explicit change detection configuration now follow OnPush-style behavior by default +* Some existing pages may no longer update automatically after asynchronous operations +* UI state should be managed using Angular Signals or the `async` pipe where appropriate + +### 3. Stricter Type and Template Checks + +Angular 22 and TypeScript 6 introduce additional compile-time validations. + +* More template and type-related issues may be reported during builds +* Existing assumptions around nullable values and optional properties may require additional guards or type refinements +* Applications with strict template checking enabled may require code updates + +### 4. Upload Progress Handling + +Applications that rely on file upload progress events may require additional HTTP client configuration. + +* Browser-side HTTP configuration may need `withXhr()` enabled to ensure upload progress events are emitted correctly + +### 5. Chart Update Behavior + +Chart components may require additional updates when used with asynchronous data sources. + +* Under OnPush-style change detection, chart updates may not be detected automatically +* Consider using Signals for chart data bindings +* Calling `reinit()` after asynchronous data updates may be necessary in some scenarios + +## Required Actions + +### 1. Upgrade Related Packages Together + +Keep Angular, TypeScript, ABP, and ABP Commercial packages on compatible versions. + +To use Angular 22: + +* Angular: `22.x` +* TypeScript: `6.x` +* ABP Framework: `10.6.x` + +### 2. Review UI State Management + +Review pages that depend on asynchronous state updates, including: + +* List and table data +* Loading and busy indicators +* Modal dialog state +* Dashboard and chart data + +Consider migrating these scenarios to Angular Signals or the `async` pipe. + +### 3. Apply a Temporary TypeScript Compatibility Setting (If Needed) + +If your project uses `downlevelIteration`, you may temporarily add the following configuration: + +```json +{ + "ignoreDeprecations": "6.0" +} +``` + +This can help ease the migration process while addressing TypeScript 6 deprecation warnings. + +### 4. Perform Regression Testing + +We recommend validating all critical application flows after upgrading, including: + +* Authentication and account management pages +* CRUD list and detail pages +* Permission and feature management dialogs +* File upload workflows +* Dashboard and chart components + +## Areas to Validate Carefully + +Pay particular attention to the following scenarios: + +* Busy or loading indicators not updating correctly +* Modal open/close state inconsistencies +* List pages not refreshing after asynchronous operations +* Upload progress events not being emitted +* Charts rendering without data after API responses + +## References + +* Detailed migration guide: [Upgrade ABP to 10.6](../../../../release-info/migration-guides/abp-10-6-angular-22.md) diff --git a/docs/en/framework/ui/angular/ssr-configuration.md b/docs/en/framework/ui/angular/ssr-configuration.md index 4d889377fd..87f95185a3 100644 --- a/docs/en/framework/ui/angular/ssr-configuration.md +++ b/docs/en/framework/ui/angular/ssr-configuration.md @@ -504,6 +504,21 @@ export class MyService { - `key(index: number): string | null` - `length: number` +#### Session and Cookie Storage + +`AbpCookieStorageService` provides the cookie-backed `Storage` implementation used during SSR. On the server, it reads cookies from the incoming Angular `REQUEST`; write and remove operations do nothing. In the browser, it reads and writes `document.cookie`. Values written with `setItem` use `Path=/`, `SameSite=Lax`, and `Secure`. Use `setItemWithExpiry` when a cookie also needs a maximum age. + +`SessionStateService` selects its storage automatically: + +- When the application started with SSR, it persists the `abpSession` value in `AbpCookieStorageService` so the server request can read the session state. +- Otherwise, it persists `abpSession` in `AbpLocalStorageService`. + +Application code normally uses `SessionStateService` for the current language and tenant instead of reading `abpSession` directly. + +#### Cross-Tab Authentication Changes + +`provideAbpCore` initializes `LocalStorageListenerService`. In the browser, this service listens for storage changes to the `access_token` key. When another browser context adds or removes that token, the current page navigates to `/` so its authentication state is refreshed. + ### 9.3. Hydration Mismatch Errors If you see "NG0500" errors in the console: @@ -515,7 +530,7 @@ If you see "NG0500" errors in the console: ### 9.4. Avoiding Duplicate API Calls -ABP Core provides a `transferStateInterceptor` that automatically prevents duplicate HTTP GET requests during hydration. When you use `provideAbpCore()`, this interceptor is already active. +ABP Core provides a `transferStateInterceptor` that automatically prevents duplicate HTTP GET requests during hydration. When you configure `provideAbpCore(withOptions(...))`, this interceptor is already active. **How it works:** - Server: Stores HTTP GET responses in `TransferState` @@ -524,16 +539,26 @@ ABP Core provides a `transferStateInterceptor` that automatically prevents dupli ```typescript // app.config.ts -import { provideAbpCore } from '@abp/ng.core'; +import { provideAbpCore, withOptions } from '@abp/ng.core'; +import { registerLocaleForEsBuild } from '@abp/ng.core/locale'; +import { ApplicationConfig } from '@angular/core'; +import { environment } from '../environments/environment'; export const appConfig: ApplicationConfig = { providers: [ - provideAbpCore(), + provideAbpCore( + withOptions({ + environment, + registerLocaleFn: registerLocaleForEsBuild(), + }), + ), // transferStateInterceptor is automatically included ] }; ``` +The example uses the Angular application builder. If your SSR application uses the Webpack builder, use `registerLocale()` instead. See [Registering a New Locale](localization.md#registering-a-new-locale) for the builder-specific configuration. + The interceptor works with all HTTP GET requests made through `HttpClient`: ```typescript diff --git a/docs/en/framework/ui/angular/testing.md b/docs/en/framework/ui/angular/testing.md index 4ec5577f73..112a785e49 100644 --- a/docs/en/framework/ui/angular/testing.md +++ b/docs/en/framework/ui/angular/testing.md @@ -137,6 +137,35 @@ expect(deleteSpy).toHaveBeenCalledWith("some-id"); The template's `home.component.spec.ts` is a good reference for mocking ABP services and asserting DOM behavior with `TestBed`. +### Configuring Permission Results + +`CoreTestingModule.withConfig()` replaces `PermissionService` with `MockPermissionService`. The mock grants every policy by default, which keeps unrelated permission checks from hiding components in a test. + +Use `grantPolicies` when a spec needs a specific authorization state. Policies not included in the array are denied: + +```ts +import { PermissionService } from '@abp/ng.core'; +import { MockPermissionService } from '@abp/ng.core/testing'; +import { TestBed } from '@angular/core/testing'; + +let permissionService: MockPermissionService; + +beforeEach(() => { + permissionService = TestBed.inject(PermissionService) as MockPermissionService; +}); + +it('shows the create action only for the create policy', () => { + permissionService.grantPolicies(['BookStore.Books.Create']); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="create-book"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="delete-book"]')).toBeFalsy(); +}); +``` + +Call `grantAllPolicies()` to restore the grant-all behavior in a test that previously selected individual policies. + ## Tips ### Clearing DOM After Each Spec diff --git a/docs/en/framework/ui/angular/title-strategy.md b/docs/en/framework/ui/angular/title-strategy.md new file mode 100644 index 0000000000..831f2e9bdf --- /dev/null +++ b/docs/en/framework/ui/angular/title-strategy.md @@ -0,0 +1,96 @@ +```json +//[doc-seo] +{ + "Description": "Configure localized Angular route titles, the application-name suffix, and a custom TitleStrategy in an ABP application." +} +``` + +# Document Title Strategy + +`provideAbpCore` registers `AbpTitleStrategy` as Angular's default `TitleStrategy`. It reads the deepest active route title, localizes it, and updates the browser document title. + +## Setting a Route Title + +Use Angular's `title` route property. The value can be an ABP localization key: + +```ts +import { Routes } from '@angular/router'; +import { BooksComponent } from './books.component'; + +export const routes: Routes = [ + { + path: 'books', + component: BooksComponent, + title: 'BookStore::Menu:Books', + }, +]; +``` + +With an application name of `Book Store`, the resulting title is: + +```text +Books | Book Store +``` + +The strategy resolves the application name from the `::AppName` localization key. If a route has no title, it uses only the application name. It also recalculates the active title when the language changes. + +## Removing the Application-Name Suffix + +Provide `DISABLE_PROJECT_NAME` with `true` to omit the application name from routes that have a title: + +```ts +import { DISABLE_PROJECT_NAME } from '@abp/ng.core'; +import { ApplicationConfig } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: DISABLE_PROJECT_NAME, + useValue: true, + }, + ], +}; +``` + +The route above then produces `Books`. A route without a title still falls back to the application name. + +## Replacing the Strategy + +Create an Angular `TitleStrategy` and pass it to the `withTitleStrategy` feature when the application needs a different title convention: + +```ts +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { inject, Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class CustomTitleStrategy extends TitleStrategy { + private readonly title = inject(Title); + + override updateTitle(routerState: RouterStateSnapshot): void { + const routeTitle = this.buildTitle(routerState); + this.title.setTitle(routeTitle ? `My Application - ${routeTitle}` : 'My Application'); + } +} +``` + +Register it together with the normal ABP Core options: + +```ts +import { provideAbpCore, withOptions, withTitleStrategy } from '@abp/ng.core'; +import { registerLocaleForEsBuild } from '@abp/ng.core/locale'; +import { ApplicationConfig } from '@angular/core'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAbpCore( + withOptions({ + environment, + registerLocaleFn: registerLocaleForEsBuild(), + }), + withTitleStrategy(CustomTitleStrategy), + ), + ], +}; +``` diff --git a/docs/en/framework/ui/angular/tree-component.md b/docs/en/framework/ui/angular/tree-component.md new file mode 100644 index 0000000000..2a7b446baa --- /dev/null +++ b/docs/en/framework/ui/angular/tree-component.md @@ -0,0 +1,129 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to use the ABP Angular tree component for hierarchical data, selection, templates and drag-and-drop." +} +``` + +# Tree Component + +`TreeComponent` is a standalone tree control exported by `@abp/ng.components/tree`. It supports selection, checkboxes, expansion, context-menu templates and drag-and-drop. + +The `@abp/ng.components` package is included in the Angular application templates. Install it if the application does not already reference it: + +```bash +npm install @abp/ng.components +``` + +Import it into a standalone component and provide nodes in the format expected by the underlying NG-ZORRO tree: + +```ts +import { Component, signal } from '@angular/core'; +import { DropEvent, TreeComponent } from '@abp/ng.components/tree'; +import { of } from 'rxjs'; + +interface Category { + id: string; + name: string; +} + +@Component({ + selector: 'app-category-tree', + templateUrl: './category-tree.component.html', + imports: [TreeComponent], +}) +export class CategoryTreeComponent { + readonly nodes = signal([ + { + key: 'books', + title: 'Books', + entity: { id: 'books', name: 'Books' } as Category, + children: [], + isLeaf: true, + }, + ]); + + readonly expandedKeys = signal([]); + readonly selectedCategory = signal(null); + + readonly allowDrop = () => of(true); + + handleDrop(event: DropEvent) { + console.log(event.dragNode?.key); + } + + edit(key: string) { + console.log(key); + } +} +``` + +```html + + + + + +``` + +The main inputs are: + +- `nodes`, `checkedKeys`, `expandedKeys` and `selectedNode` for tree state. +- `draggable`, `checkable`, `checkStrictly` and `noAnimation` for tree behavior. +- `changeCheckboxWithNode` to update checked keys when a node is selected. +- `isNodeSelected` to replace the default selected-node comparison. +- `beforeDrop` to approve or reject a drag-and-drop operation. + +State changes are exposed through `checkedKeysChange`, `expandedKeysChange`, `selectedNodeChange`, `dropOver` and `nzExpandChange`. + +The default `beforeDrop` handler rejects drops. Supply a handler that returns an observable accepted by the underlying tree control when drag-and-drop is enabled. Note that the default handler is also what records the drop position, so when you replace it, the `pos` property of the `DropEvent` emitted by `dropOver` is not set. + +## Templates + +Use the `#menu` template, as in the previous example, to add a context menu for each node. Use `abpTreeNodeTemplate` and `abpTreeExpandedIconTemplate` to replace the node and expanded-icon templates: + +{%{ +```html + + + {{ node.title }} + + + + {{ node.isExpanded ? '−' : '+' }} + + +``` +}%} + +Import `TreeNodeTemplateDirective` and `ExpandedIconTemplateDirective` from `@abp/ng.components/tree` into the standalone component that uses these templates, because Angular must see each directive used by a component template. If you use only one of these templates, import only its corresponding directive. + +## Flat-List Adapter + +`TreeAdapter` converts a flat list of `BaseNode` values into tree nodes. Each item requires an `id` and a nullable `parentId`; its `displayName` or `name` becomes the default node title. Use `getTree()`, `handleDrop()`, `handleRemove()` and `handleUpdate()` to keep the flat list and tree representations synchronized. + +## Style Loading + +The component loads `ng-zorro-antd-tree.css` when it is initialized. Provide `DISABLE_TREE_STYLE_LOADING_TOKEN` with `true` only when the application already includes that stylesheet: + +```ts +import { DISABLE_TREE_STYLE_LOADING_TOKEN } from '@abp/ng.components/tree'; + +export const providers = [ + { + provide: DISABLE_TREE_STYLE_LOADING_TOKEN, + useValue: true, + }, +]; +``` diff --git a/docs/en/framework/ui/blazor/authentication.md b/docs/en/framework/ui/blazor/authentication.md index a793a784bd..40bc6cf40f 100644 --- a/docs/en/framework/ui/blazor/authentication.md +++ b/docs/en/framework/ui/blazor/authentication.md @@ -31,4 +31,18 @@ This is a typical and recommended approach to implement authentication in Single See the [Blazor Security document](https://docs.microsoft.com/en-us/aspnet/core/blazor/security) to understand and customize the authentication process. -{{end}} \ No newline at end of file +{{end}} + +## Authentication URLs + +`AbpAuthenticationOptions` centralizes the login and logout routes used by ABP Blazor authentication services. The shared web defaults are `Account/Login` and `Account/Logout`. A standalone Blazor WebAssembly application changes them to `authentication/login` and `authentication/logout`; this override is not applied when the client is hosted as part of a Blazor Web App. + +Configure the options when the application uses custom routes: + +````csharp +Configure(options => +{ + options.LoginUrl = "authentication/sign-in"; + options.LogoutUrl = "authentication/sign-out"; +}); +```` diff --git a/docs/en/framework/ui/blazor/global-scripts-styles.md b/docs/en/framework/ui/blazor/global-scripts-styles.md index 998a16a50a..02ae892089 100644 --- a/docs/en/framework/ui/blazor/global-scripts-styles.md +++ b/docs/en/framework/ui/blazor/global-scripts-styles.md @@ -7,7 +7,7 @@ # Blazor UI: Managing Global Scripts & Styles -You can add your JavaScript and CSS files from your modules or applications to the Blazor global assets system. All the JavaScript and CSS files will be added to the `global.js` and `global.css` files. You can access these files via the following URL in a Blazor WASM project: +You can add your JavaScript and CSS files from your modules or applications to the Blazor global assets system. By default, all the JavaScript and CSS files are added to the `global.js` and `global.css` files. You can access these files via the following URL in a Blazor WASM project: - https://localhost/global.js - https://localhost/global.css @@ -71,9 +71,37 @@ This is similar to the module. You need to define JavaScript and CSS contributor ## AbpBundlingGlobalAssetsOptions -You can configure the JavaScript and CSS file names in the `GlobalAssets` property of the `AbpBundlingOptions` class. The default values are `global.js` and `global.css`. +The `GlobalAssets` property of `AbpBundlingOptions` is shared by the MVC, Blazor WebAssembly and MAUI Blazor bundling integrations. It has the following properties: + +* `Enabled`: Enables global asset generation. The Blazor WebAssembly and MAUI Blazor theming modules enable it when they configure their global bundles. +* `GlobalStyleBundleName`: The style bundle used to generate the global CSS asset. +* `GlobalScriptBundleName`: The script bundle used to generate the global JavaScript asset. +* `CssFileName`: The generated CSS file name. The default is `global.css`. +* `JavaScriptFileName`: The generated JavaScript file name. The default is `global.js`. + +```csharp +Configure(options => +{ + options.GlobalAssets.Enabled = true; + options.GlobalAssets.GlobalStyleBundleName = + BlazorWebAssemblyStandardBundles.Styles.Global; + options.GlobalAssets.GlobalScriptBundleName = + BlazorWebAssemblyStandardBundles.Scripts.Global; + options.GlobalAssets.CssFileName = "app-global.css"; + options.GlobalAssets.JavaScriptFileName = "app-global.js"; +}); +``` + +When you change `CssFileName` or `JavaScriptFileName`, update the host page references to the same names. For a standalone Blazor WebAssembly host, replace the default references in `App.razor`: + +```html + + +``` + +For a Blazor Web App, replace `global.css` and `global.js` in the `GlobalStyles` and `GlobalScripts` lists in `Components/App.razor`. Changing only `AbpBundlingOptions` generates the files under the new names but does not rewrite these host references. ## Reference -- [ASP.NET Core MVC Bundling & Minification](../mvc-razor-pages/bundling-minification#bundle-contributorsg) +- [ASP.NET Core MVC Bundling & Minification](../mvc-razor-pages/bundling-minification.md#bundle-contributors) - [ABP Global Assets - New way to bundle JavaScript/CSS files in Blazor WebAssembly app](https://github.com/abpframework/abp/blob/dev/docs/en/Community-Articles/2024-11-25-Global-Assets/POST.md) diff --git a/docs/en/framework/ui/maui/index.md b/docs/en/framework/ui/maui/index.md index 2622bdc7e0..1bd089b26d 100644 --- a/docs/en/framework/ui/maui/index.md +++ b/docs/en/framework/ui/maui/index.md @@ -34,9 +34,9 @@ Open the `appsettings.json` in the `MAUI` project: {{ end }} -After ensuring the backend application is running and the `appsettings.json` is properly configured in the mobile application, you can proceed to run the mobile application. You can run the application either by using the `dotnet build` command (e.g. `dotnet build -t:Run -f net9.0-android` for Android or `dotnet build -t:Run -f net9.0-ios` for iOS) or by running it through Visual Studio or any other IDE that supports MAUI. +After ensuring the backend application is running and the `appsettings.json` is properly configured in the mobile application, you can proceed to run the mobile application. You can run the application either by using the `dotnet build` command (e.g. `dotnet build -t:Run -f net10.0-android` for Android or `dotnet build -t:Run -f net10.0-ios` for iOS) or by running it through Visual Studio or any other IDE that supports MAUI. -> For more information about running the mobile application, please refer to the [Microsoft's documentation](https://learn.microsoft.com/en-us/dotnet/maui/?view=net-maui-9.0). +> For more information about running the mobile application, please refer to the [Microsoft's documentation](https://learn.microsoft.com/en-us/dotnet/maui/?view=net-maui-10.0). You can examine the [Users Page](#users-page) or any other pre-defined page to see how to use CSharp Client Proxy to request backend API and consume the backend API in the same way in your application. Also, if you encounter any errors on specific platforms, you can refer to the following sections for each platform to find common issues and their solutions. diff --git a/docs/en/framework/ui/mvc-razor-pages/javascript-api/clock.md b/docs/en/framework/ui/mvc-razor-pages/javascript-api/clock.md new file mode 100644 index 0000000000..520f650087 --- /dev/null +++ b/docs/en/framework/ui/mvc-razor-pages/javascript-api/clock.md @@ -0,0 +1,57 @@ +```json +//[doc-seo] +{ + "Description": "Use ABP's MVC JavaScript Clock API for time-zone detection, date normalization, localized display, and the browser time-zone cookie." +} +``` + +# ASP.NET Core MVC / Razor Pages UI: JavaScript Clock API + +The `abp.clock` namespace provides date, time-zone and browser-time-zone helpers for MVC / Razor Pages applications. The application configuration script sets `abp.clock.kind` from the server-side ABP clock configuration. + +## Time-Zone Support + +`abp.clock.supportsMultipleTimezone()` returns `true` when the configured clock kind is `Utc`. `abp.clock.timeZone()` returns the value of the `Abp.Timing.TimeZone` setting when it is available and otherwise returns the browser's IANA time-zone name. + +````js +if (abp.clock.supportsMultipleTimezone()) { + console.log(abp.clock.timeZone()); +} +```` + +## Normalize Date Values + +Use `normalizeToString` before sending a date value to the server and `normalizeToLocaleString` before displaying a server value: + +````js +const requestValue = abp.clock.normalizeToString(new Date()); +const displayValue = abp.clock.normalizeToLocaleString(requestValue); +```` + +`normalizeToString` returns an ISO-compatible date-time string. For a non-UTC clock, the core implementation uses `yyyy-MM-ddTHH:mm:ss`. Both normalization methods return empty or invalid values unchanged. + +The standard shared MVC theme bundle loads Luxon and replaces the core implementations of `normalizeToString` and `normalizeToLocaleString`. When multiple time zones are supported, this Luxon implementation interprets the input in the configured IANA time zone, converts it to UTC and returns an ISO value ending in `Z`. The output can include milliseconds (for example, `2026-07-17T08:30:00.000Z`), so do not require an exact string length when consuming it. + +If an application uses the core scripts without the shared theme's Luxon contributor, the fallback implementation produces a `Z`-suffixed transport value by detecting the numeric offset of the configured time zone (the `abp.clock.timeZone()` value, which falls back to the browser time zone). It is not a full IANA time-zone conversion and does not account for fractional-hour offsets or an offset change between the current date and the input date. Include the Luxon contributor when those cases must be handled. + +`normalizeToLocaleString` accepts standard `Intl.DateTimeFormat` options. When no options are supplied, it uses `abp.clock.toLocaleStringOptions`. The default options include the numeric year, long month, numeric day, hour, minute and second. You can replace them to define application-wide display defaults: + +````js +abp.clock.toLocaleStringOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' +}; +```` + +## Browser Time-Zone Cookie + +After application configuration is initialized, ABP writes the browser's time-zone name to the `__timezone` cookie when multiple time zones are supported. Set the following flag before configuration initialization to disable this behavior: + +````js +abp.clock.trySetBrowserTimeZoneToCookie = false; +```` + +Use `abp.clock.browserTimeZone()` to read the browser time zone or `abp.clock.setBrowserTimeZoneToCookie()` to refresh the cookie explicitly. diff --git a/docs/en/framework/ui/mvc-razor-pages/javascript-api/index.md b/docs/en/framework/ui/mvc-razor-pages/javascript-api/index.md index 193575def6..8bb09645f7 100644 --- a/docs/en/framework/ui/mvc-razor-pages/javascript-api/index.md +++ b/docs/en/framework/ui/mvc-razor-pages/javascript-api/index.md @@ -13,6 +13,7 @@ ABP provides a set of JavaScript APIs for ASP.NET Core MVC / Razor Pages applica * [AJAX](ajax.md) * [Auth](auth.md) +* [Clock](clock.md) * [CurrentUser](current-user.md) * [DOM](dom.md) * [Events](events.md) @@ -24,4 +25,4 @@ ABP provides a set of JavaScript APIs for ASP.NET Core MVC / Razor Pages applica * [Settings](settings.md) * [UI Block/Busy](block-busy.md) * [UI Message](message.md) -* [UI Notification](notify.md) \ No newline at end of file +* [UI Notification](notify.md) diff --git a/docs/en/framework/ui/mvc-razor-pages/overall.md b/docs/en/framework/ui/mvc-razor-pages/overall.md index 425c94d2d9..b029fda57c 100644 --- a/docs/en/framework/ui/mvc-razor-pages/overall.md +++ b/docs/en/framework/ui/mvc-razor-pages/overall.md @@ -164,4 +164,16 @@ ABP provides a lot of built-in solutions to common application requirements; ## Customization -There are a lot of ways to customize the theme and the UIs of the pre-built modules. You can override components, pages, static resources, bundles and more. See the [User Interface Customization Guide](customization-user-interface.md). \ No newline at end of file +There are a lot of ways to customize the theme and the UIs of the pre-built modules. You can override components, pages, static resources, bundles and more. See the [User Interface Customization Guide](customization-user-interface.md). + +### Status-Specific Error Views + +The shared MVC theme uses `~/Views/Error/Default.cshtml` by default. Map an HTTP status code to another view with `AbpErrorPageOptions.ErrorViewUrls`: + +````csharp +Configure(options => +{ + options.ErrorViewUrls["404"] = "~/Views/Error/NotFound.cshtml"; + options.ErrorViewUrls["500"] = "~/Views/Error/InternalError.cshtml"; +}); +```` diff --git a/docs/en/framework/ui/react/authorization.md b/docs/en/framework/ui/react/authorization.md index 4f6d673d87..9111badaa3 100644 --- a/docs/en/framework/ui/react/authorization.md +++ b/docs/en/framework/ui/react/authorization.md @@ -89,6 +89,28 @@ client = createAbpReactOidcAuth({ The template stores the OIDC user in local storage and enables silent renewal with `public/silent-renew.html`. +## Lower-Level OIDC Client + +`@volo/abp-oidc-auth` is the framework-agnostic client used by the React OIDC adapter. Use it directly in another JavaScript runtime or supply it to `createAbpReactOidcAuth`: + +```ts +import { createAbpOidcAuth } from '@volo/abp-oidc-auth' +import { createAbpReactOidcAuth } from '@volo/abp-react-oidc-auth' + +const client = createAbpOidcAuth({ + authority: 'https://localhost:44301/', + clientId: 'MyProject_App', + redirectUri: window.location.origin, + postLogoutRedirectUri: window.location.origin, + scope: 'offline_access MyProject', +}) + +const auth = createAbpReactOidcAuth({ client }) +await client.init() +``` + +Use `subscribe()` for ABP authentication lifecycle events and `getSnapshot()` for the current user, profile, token and initialization state. `clearStaleState()` removes abandoned OIDC state entries. The client also exposes the underlying `UserManager`, its events and the configured authority for integrations that need lower-level OIDC control. + ## Auth Provider and Hook `AuthProvider` wraps the app and handles the OIDC callback: diff --git a/docs/en/framework/ui/react/permission-management.md b/docs/en/framework/ui/react/permission-management.md index fc009ce5a2..fabfefcea2 100644 --- a/docs/en/framework/ui/react/permission-management.md +++ b/docs/en/framework/ui/react/permission-management.md @@ -29,6 +29,42 @@ export const appConfig = createAbpReactAppConfig({ }) ``` +## Framework-Agnostic Application Configuration Client + +`@volo/abp-app-config` provides the application-configuration client without a React dependency. Use it directly in another JavaScript runtime or pass an existing client to the React adapter: + +```ts +import { createAbpAppConfig } from '@volo/abp-app-config' +import { createAbpReactAppConfig } from '@volo/abp-react-app-config' + +const client = createAbpAppConfig({ + baseUrl: 'https://localhost:44300', +}) + +const appConfig = createAbpReactAppConfig({ client }) +``` + +The lower-level client exposes: + +- `fetchConfig()` and `fetchLocalization()` to load server data. +- `refetch()` to reload the application configuration. +- `getSnapshot()` and `getSections()` to read the current state. +- `subscribe(listener)` to observe state changes. It returns an unsubscribe function. +- `t()` and `tWithFallback()` for localization. +- `clear()` to reset the client state. + +```ts +const unsubscribe = client.subscribe(snapshot => { + console.log(snapshot.initialized, snapshot.currentCulture) +}) + +await client.fetchConfig() +await client.fetchLocalization('en') + +unsubscribe() +client.clear() +``` + ## Fetching Permissions After the user logs in, `AuthProvider` fetches application configuration with the current access token: diff --git a/docs/en/images/openiddict-client-credentials-application.png b/docs/en/images/openiddict-client-credentials-application.png index fdcf4a2e5b..509f2a99a0 100644 Binary files a/docs/en/images/openiddict-client-credentials-application.png and b/docs/en/images/openiddict-client-credentials-application.png differ diff --git a/docs/en/modules/account-pro.md b/docs/en/modules/account-pro.md index a79c3d4992..0084a7f10c 100644 --- a/docs/en/modules/account-pro.md +++ b/docs/en/modules/account-pro.md @@ -86,7 +86,11 @@ Configure(options => * `TenantAdminUserName` (default: admin): The tenant admin user name. * `ImpersonationTenantPermission`: The permission name for tenant impersonation. * `ImpersonationUserPermission`: The permission name for user impersonation. -* `ExternalProviderIconMap`: A dictionary of external provider names and their corresponding font-awesome icon classes. You can add new mapping to this dictionary to change the icon of an external provider.(Popular external provider icons are already defined, such as `Facebook`, `Google`, `Microsoft`, `Twitter`, etc.) +* `SwitchUserDuringImpersonate` (default: `false`): Signs the target user in with the application cookie while an impersonation flow is in progress. +* `ExternalProviderIconMap`: A dictionary of external provider names and their icon asset paths or CSS classes. Common providers such as GitHub, Google, X, Apple, LinkedIn, Facebook and Microsoft are already mapped. +* `IsTenantMultiDomain` (default: `false`): Enables tenant-domain redirects for linked-account and tenant-switching flows. +* `GetTenantDomain`: Resolves the target tenant's origin for impersonation redirects and, when `IsTenantMultiDomain` is enabled, linked-account and tenant-switching redirects. By default, it returns the current request's scheme and host. +* `ExternalProfilePictureDownloadTimeout` (default: 5 seconds): Limits how long external-login registration waits while downloading a profile picture. ### AbpProfilePictureOptions @@ -101,7 +105,26 @@ Configure(options => `AbpProfilePictureOptions` properties: -* `EnableImageCompression` (default: false): Enables the image compression for the profile picture. When enabled, the selected compression library will compress the profile picture to decrease the image size. For more information see [image manipulation](../framework/infrastructure/image-manipulation.md) +* `EnableImageCompression` (default: `false`): Enables image compression for the profile picture. When enabled, the selected compression library compresses the profile picture to decrease its size. For more information, see [image manipulation](../framework/infrastructure/image-manipulation.md). +* `AllowedFileExtensions` (default: `.jpg`, `.jpeg` and `.png`): Defines the accepted file-name extensions. The extension check runs when the upload includes a file name. +* `MaxFileSizeInBytes` (default: 5 MiB): Rejects larger uploads. Set it to `0` to disable the size limit. +* `MagicBytesVerifiers`: Verifies the file content independently of the file name. The default verifiers accept JPEG and PNG signatures. If you add an allowed extension, add a matching content verifier as well; at least one verifier must accept every uploaded image. + +### Registration Email Confirmation Codes + +The registration email confirmation code is stored with a 10-minute absolute expiration by default. Configure a different duration with `Account:EmailConfirmation:CodeExpirationTime`: + +```json +{ + "Account": { + "EmailConfirmation": { + "CodeExpirationTime": "00:15:00" + } + } +} +``` + +Sending and checking these codes use separate built-in operation rate-limit policies. Sending a new code resets the check rate-limit state for that email address. ## Local login @@ -111,6 +134,20 @@ If you use `Social / External Logins`, It is automatically called for authentica ![account-pro-module-local-login-setting](../images/account-pro-module-local-login-setting.png) +## Email Login + +Email login lets users sign in with a one-time code, a magic link or both. It is disabled by default and also requires **Local login** to remain enabled. Configure it in `Settings > Account > Email Login`. + +The available login types are: + +* `OtpAndMagicLink` (default): The email contains both a six-digit code and a magic link. +* `MagicLinkOnly`: The email contains only a magic link and the code-verification endpoint is disabled. +* `OtpOnly`: The email contains only a code and direct magic-link verification is disabled. + +The token lifespan defaults to 90 seconds and accepts values from 30 to 86,400 seconds. Codes and link tokens are single-use. Completing either path invalidates the outstanding credential for the other path, and sending a new email invalidates the previous credentials. + +Email login uses built-in send and verification rate limits. When `AccountSettingNames.PreventEmailEnumeration` is enabled, requests for an unknown or locked-out account return the same expiry-shaped response as a valid request without sending an email. This prevents callers from using the send response to distinguish those accounts. + ### Switching users during OAuth login If you have an OAuth/Auth Server application using the Account Pro module, you can pass the `prompt=select_account` parameter to force the user to select an account. @@ -345,9 +382,11 @@ export const APP_ROUTES: Routes = [ You can modify the look and behavior of the module pages by passing the following options to `createRoutes` static method: - **redirectUrl**: Default redirect URL after logging in. -- **entityActionContributors:** Changes grid actions. Please check [Entity Action Extensions for Angular](../framework/ui/angular/entity-action-extensions.md) for details. -- **toolbarActionContributors:** Changes page toolbar. Please check [Page Toolbar Extensions for Angular](../framework/ui/angular/page-toolbar-extensions.md) for details. -- **entityPropContributors:** Changes table columns. Please check [Data Table Column Extensions for Angular](../framework/ui/angular/data-table-column-extensions.md) for details. +- **entityActionContributors:** Changes actions on `eAccountComponents.MySecurityLogs`. See [Entity Action Extensions for Angular](../framework/ui/angular/entity-action-extensions.md). +- **toolbarActionContributors:** Changes the toolbar on `eAccountComponents.MySecurityLogs`. See [Page Toolbar Extensions for Angular](../framework/ui/angular/page-toolbar-extensions.md). +- **entityPropContributors:** Changes columns on `eAccountComponents.MySecurityLogs`. See [Data Table Column Extensions for Angular](../framework/ui/angular/data-table-column-extensions.md). +- **personelInfoEntityPropContributors:** Changes the edit-form properties on `eAccountComponents.PersonalSettings`. The public API uses this spelling. See [Dynamic Form Extensions for Angular](../framework/ui/angular/dynamic-form-extensions.md). +- **isPersonalSettingsChangedConfirmationActive:** Deprecated. Personal settings refresh the current user's state without requiring a new login. #### Services / Models @@ -426,4 +465,3 @@ This module doesn't define any additional distributed event. See the [standard d * [Idle Session Timeout](./account/idle-session-timeout.md) * [Web Authentication API (WebAuthn) passkeys](./account/passkey.md) * [Shared user accounts](./account/shared-user-accounts.md) -``` \ No newline at end of file diff --git a/docs/en/modules/account.md b/docs/en/modules/account.md index 844e00e06f..2148ca5d42 100644 --- a/docs/en/modules/account.md +++ b/docs/en/modules/account.md @@ -37,6 +37,8 @@ Social/external login buttons becomes visible if you setup it. See the *Social/E ![account-module-register](../images/account-module-register.png) +New users receive every Identity role marked as `Default`. + ### Forgot Password & Reset Password `/Account/ForgotPassword` page provides a way of sending password reset link to user's email address. The user then clicks to the link and determines a new password. @@ -51,6 +53,34 @@ Social/external login buttons becomes visible if you setup it. See the *Social/E ![account-module-manage-account](../images/account-module-manage-account.png) +`IdentitySettingNames.User.IsUserNameUpdateEnabled` and `IdentitySettingNames.User.IsEmailUpdateEnabled` control whether the profile application service accepts changes to those fields. Both settings are `true` by default. External users can't change a local password; the built-in MVC profile page omits the password group for them and the application service rejects a password change. + +### Login and Registration Settings + +The Account module defines two client-visible settings. Both are `true` by default: + +* `AccountSettingNames.IsSelfRegistrationEnabled` controls self-registration. It is enforced by `IAccountAppService.RegisterAsync` as well as the built-in registration pages. +* `AccountSettingNames.EnableLocalLogin` controls the local username/password login UI and handlers in the MVC Account pages, the OpenIddict and IdentityServer integrations, and the Angular Account layout. In Angular, `AuthWrapperService` reads the setting; the Basic Theme's `AuthWrapperComponent` shows the account content when it is enabled and a no-login-schemes warning when it is disabled. + +These settings are independent. Disabling local login doesn't disable the registration application service. Set `IsSelfRegistrationEnabled` to `false` as well when users must not create local accounts. Change the values with `ISettingManager` like other [settings](../framework/infrastructure/settings.md). Global or tenant values are normally appropriate because the login and registration requests run before a user is authenticated. + +### Extending the MVC Profile Page + +The MVC `/Account/Manage` page is built from the contributors in `ProfileManagementPageOptions.Contributors`. Implement `IProfileManagementPageContributor` to add a group backed by a view component, then register it from your module: + +```csharp +Configure(options => +{ + options.Contributors.Add(new MyProfileManagementPageContributor()); +}); +``` + +Each contributor receives a `ProfileManagementPageCreationContext` and appends `ProfileManagementPageGroup` instances to its `Groups` collection. Contributors run in registration order on both GET and POST requests, and can resolve services through `context.ServiceProvider` when visibility depends on the current user or another runtime condition. + +### Angular UI Extensibility + +The Angular `createRoutes` function accepts three module-specific options: `redirectUrl`, `isPersonalSettingsChangedConfirmationActive` and `editFormPropContributors`. The form contributor key is `eAccountComponents.PersonalSettings`. The login, register, forgot-password, reset-password and manage-profile routes are also registered with the corresponding `eAccountComponents` keys for [component replacement](../framework/ui/angular/component-replacement.md). See [Dynamic Form Extensions](../framework/ui/angular/dynamic-form-extensions.md) for the contributor pattern. + ## OpenIddict Integration [Volo.Abp.Account.Web.OpenIddict](https://www.nuget.org/packages/Volo.Abp.Account.Web.OpenIddict) package provides integration for the [OpenIddict](https://github.com/openiddict). This package comes as installed with the [application startup template](../solution-templates/layered-web-application). See the [OpenIddict Module](./openiddict.md) documentation. @@ -63,6 +93,15 @@ Social/external login buttons becomes visible if you setup it. See the *Social/E The Account Module has already configured to handle social or external logins out of the box. You can follow the ASP.NET Core documentation to add a social/external login provider to your application. +The MVC login and registration pages also recognize a Windows authentication scheme. `AbpAccountOptions.WindowsAuthenticationSchemeName` identifies that scheme and defaults to `"Windows"`. Set it when the registered scheme uses another name: + +```csharp +Configure(options => +{ + options.WindowsAuthenticationSchemeName = "Negotiate"; +}); +``` + ### Example: Facebook Authentication Follow the [ASP.NET Core Facebook integration document](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/facebook-logins) to support the Facebook login for your application. diff --git a/docs/en/modules/account/passkey.md b/docs/en/modules/account/passkey.md index b8f00ee576..a868977ece 100644 --- a/docs/en/modules/account/passkey.md +++ b/docs/en/modules/account/passkey.md @@ -8,6 +8,8 @@ You can enable/disable the `Web Authentication API (WebAuthn) passkeys` feature ![passkey-setting](../../images/passkey-setting.png) +Passkeys are disabled by default. The default limit is 10 passkeys per user, and the configured limit must be greater than zero. Registration is rejected when passkeys are disabled or the user has reached the limit. + ## Manage Passkeys You can add/rename/delete your passkeys in the `Account/Manage` page: diff --git a/docs/en/modules/account/shared-user-accounts.md b/docs/en/modules/account/shared-user-accounts.md index 908b342de2..5205ef10f8 100644 --- a/docs/en/modules/account/shared-user-accounts.md +++ b/docs/en/modules/account/shared-user-accounts.md @@ -74,6 +74,17 @@ From the invitation modal, you can view and manage sent invitations, including r ![Manage Invitations](../../images/manage-invitations.png) +Invitation links contain a protected, URL-safe token and expire after 7 days by default. Invalid, modified or expired tokens are rejected. Configure the lifespan with `UserInvitationTokenProviderOptions`: + +```csharp +Configure(options => +{ + options.TokenLifespan = TimeSpan.FromDays(3); +}); +``` + +Inviting the same email address again while an invitation is still pending reuses that invitation, replaces its assigned roles and refreshes its invitation date. Resending is allowed only for a pending invitation; it refreshes the invitation date and sends a newly generated token. + ## Accepting an Invitation If the invited person already has an account, clicking the email link shows a confirmation screen to join the tenant: @@ -150,13 +161,13 @@ When the Shared strategy is enabled, a user is a **global resource** across host ### Host-only operations -The following operations can only be performed by a host administrator when Shared is enabled. Both the Identity Pro UI (MVC + Blazor) and the `IdentityUserAppService` enforce this — a direct API call from a tenant context will be rejected with a `UserFriendlyException`: +The following operations can only be performed by a host administrator when Shared is enabled. Both the Identity Pro UI (MVC + Blazor) and the `IdentityUserAppService` enforce these restrictions. - Delete a user -- Activate / deactivate a user (`IsActive`) - Lock / unlock a user - Enable or disable two-factor authentication -- Change `LockoutEnabled` or `ShouldChangePasswordOnNextLogin` + +Direct tenant API calls for these operations are rejected with a `UserFriendlyException`. When a tenant update request changes `IsActive`, `LockoutEnabled` or `ShouldChangePasswordOnNextLogin`, the application service restores the current host-managed values and continues processing the remaining editable fields. > `Delete` here means deleting the **global user account**, not removing a user from a single tenant. Removing a member from one tenant is a tenant-level soft operation and is available to tenant administrators — see **Remove from tenant** below. @@ -176,6 +187,4 @@ Users can leave a tenant from their own account menu (`Switch Tenant` → `Leave If you plan to migrate an existing multi-tenant application from an isolated strategy to Shared User Accounts, keep the following in mind: 1. **Uniqueness check**: Before enabling Shared, ensure all existing usernames and emails are unique globally. ABP performs this check when you switch the strategy and reports conflicts. -2. **Tenants with separate databases**: If some tenants use separate databases, you must ensure the Host database contains matching user records in the `AbpUsers` table (and, if you use social login / passkeys, also sync `AbpUserLogins` and `AbpUserPasskeys`) so the Host-side records match the tenant-side data. After that, the framework can create/manage the user-to-tenant associations. - - **Important — each host-side shadow row must have a new primary key (`Id`) different from the tenant user's `Id`.** Generate a fresh `Guid` for every shadow row instead of reusing the tenant user's primary key. The framework relies on this to distinguish a separate-database tenant from a shared-database one; reusing the Id can mask "Leave Tenant" and external login / passkey synchronization on legacy data. The other identifying fields (`UserName`, `Email`, `PasswordHash`, `TenantId`, etc.) should still match the tenant-side row. - +2. **Tenants with separate databases**: The module detects a separate Identity database by comparing the resolved Identity connection string for the tenant with the host connection string. If some tenants use separate databases, ensure that the host database contains the corresponding shadow users in the `AbpUsers` table. Host-side shadow users are located by the tenant identifier and email address. If you use social login or passkeys, also synchronize `AbpUserLogins` and `AbpUserPasskeys`. Existing shadow rows that reuse the tenant user's primary key remain compatible with leaving a tenant. diff --git a/docs/en/modules/ai-management/index.md b/docs/en/modules/ai-management/index.md index 1cef520472..9ce3caf865 100644 --- a/docs/en/modules/ai-management/index.md +++ b/docs/en/modules/ai-management/index.md @@ -1,7 +1,7 @@ ```json //[doc-seo] { - "Description": "Discover how to implement AI management in your ABP Framework application, enhancing workspace dynamics with easy installation options." + "Description": "Manage persisted AI workspaces, providers, RAG data sources, MCP tools and remote AI clients with the ABP AI Management module." } ``` @@ -13,7 +13,7 @@ This module implements AI (Artificial Intelligence) management capabilities on t ## How to Install -The **AI Management Module** is not included in [the startup templates](../solution-templates/layered-web-application) by default. However, when creating a new application with [ABP Studio](../../tools/abp-studio/index.md), you can easily enable it during setup via the *AI Integration* step in the project creation wizard. Alternatively, you can install it using the ABP CLI or ABP Studio: +The **AI Management Module** is not included in [the startup templates](../../solution-templates/layered-web-application/index.md) by default. However, when creating a new application with [ABP Studio](../../studio/overview.md), you can easily enable it during setup via the *AI Integration* step in the project creation wizard. Alternatively, you can install it using the ABP CLI or ABP Studio: **Using ABP CLI:** @@ -97,11 +97,13 @@ AI Management module packages are designed for various usage scenarios. Packages ## User Interface -This module provides UI integration for all three officially supported UI frameworks by ABP: +This module provides administration and client UI for the following UI stacks: * **MVC / Razor Pages** UI -* **Angular** UI -* **Blazor** UI (Server & WebAssembly) +* **Angular** UI +* **Blazorise** UI (Server & WebAssembly) +* **MudBlazor** UI (Server & WebAssembly) +* **React Admin Console** management UI ### Menu Items @@ -129,12 +131,12 @@ You can create a new workspace or edit an existing workspace in this page. The w * **API Key**: Authentication key (if required by provider) * **API Base URL**: Custom endpoint URL (optional) * **System Prompt**: Default system instructions -* **Temperature**: Response randomness (0.0-1.0) -* **Application Name**: Associate with specific application -* **Required Permission**: Permission needed to use this workspace +* **Temperature**: Provider-specific response randomness * **Embedder Provider / Model**: Embedding generator used for RAG * **Vector Store Provider / Settings**: Storage backend and connection settings for document vectors +The **Application Name** is assigned by the application that creates or synchronizes the workspace. Workspace access is managed from the resource permission action; it is not a field in the create/edit form. + #### Chat Interface The AI Management module includes a built-in chat interface for testing workspaces. You can: @@ -177,6 +179,21 @@ When a workspace has MCP servers associated, the AI model can invoke tools from ![ai-management-chat-mcp-tools](../../images/ai-management-chat-mcp-tools.png) +##### MCP Timeouts + +Use `McpClientFactoryOptions` when an MCP server needs more time to initialize or when a stdio connection test starts a slow process: + +```csharp +Configure(options => +{ + options.DefaultTimeoutMs = 180_000; + options.StdioInitializationTimeoutMs = 240_000; + options.StdioConnectionTestTimeoutSeconds = 300; +}); +``` + +`DefaultTimeoutMs` defaults to 120 seconds and applies to HTTP transports and, unless overridden, stdio initialization. `StdioConnectionTestTimeoutSeconds` defaults to 180 seconds and is constrained to 30-600 seconds by the connection-test service. HTTP connection tests use a fixed one-minute timeout. + #### Workspace Data Sources Workspace Data Sources page is used to upload and manage RAG documents per workspace. Uploaded files are processed and indexed in the background. @@ -207,12 +224,12 @@ When creating or managing a workspace, you can configure the following propertie | `ApiKey` | No | API authentication key (required by some providers) | | `ApiBaseUrl` | No | Custom endpoint URL (defaults to provider's default) | | `SystemPrompt` | No | Default system prompt for all conversations | -| `Temperature` | No | Response randomness (0.0-1.0, defaults to provider default) | +| `Temperature` | No | Provider-specific response randomness | | `Description` | No | Workspace description | | `IsActive` | No | Enable/disable the workspace (default: true) | -| `ApplicationName` | No | Associate workspace with specific application | -| `RequiredPermissionName` | No | Permission required to use this workspace | -| `IsSystem` | No | Whether it's a system workspace (read-only) | +| `ApplicationName` | No | Application identity recorded during creation or synchronization | +| `RequiredPermissionName` | No | Optional policy checked by client consumption services | +| `IsSystem` | No | Whether the workspace was synchronized from code | | `OverrideSystemConfiguration` | No | Allow database configuration to override code-defined settings | | `EmbedderProvider` | No | Embedding provider name (e.g., "OpenAI", "Ollama") | | `EmbedderModelName` | No | Embedding model identifier (e.g., "text-embedding-3-small") | @@ -231,27 +248,38 @@ The AI Management module supports two types of workspaces: * **Defined in code** using `PreConfigure` * **Cannot be deleted** through the UI -* **Read-only by default**, but can be overridden when `OverrideSystemConfiguration` is enabled +* **Use the code-defined chat client by default**; persisted provider settings take effect only when `OverrideSystemConfiguration` is enabled * **Useful for** application-critical AI features that must always be available -* **Created automatically** when the application starts +* **Synchronized automatically** when the application starts Example: ```csharp -PreConfigure(options => +public override void PreConfigureServices(ServiceConfigurationContext context) { - options.Workspaces.Configure(configuration => + PreConfigure(options => { - configuration.ConfigureChatClient(chatClientConfiguration => - { - chatClientConfiguration.Builder = new ChatClientBuilder( - sp => new OpenAIClient(apiKey).GetChatClient("gpt-4") - ); - }); + options.Workspaces.Configure(); }); +} +``` + +Configure the code-defined keyed client with the [Framework AI provider integration](../../framework/infrastructure/artificial-intelligence/index.md) used by your application. + +At startup, AI Management records the registered chat, embedding and vector-store providers and synchronizes the code-defined workspace names for the current application. When at least one code-defined workspace remains, names removed from that application's configuration are also removed from its synchronized set. An empty workspace collection skips the update, so removing the last code-defined workspace doesn't delete its persisted record automatically. Synchronization errors are logged without stopping application startup. + +You can disable startup synchronization or override the recorded application name: + +```csharp +Configure(options => +{ + options.AutoUpdateAtStartup = false; + options.ApplicationName = "MyAIGateway"; }); ``` +Disabling synchronization means code-defined workspaces are not added to or updated in the management database automatically. Their code-defined keyed clients can still be resolved by the Framework AI infrastructure. + #### Dynamic Workspaces * **Created through the UI** or programmatically via `ApplicationWorkspaceManager` and `IWorkspaceRepository` @@ -264,12 +292,16 @@ Example (data seeding): ```csharp public class WorkspaceDataSeederContributor : IDataSeedContributor, ITransientDependency { + private readonly IConfiguration _configuration; private readonly IWorkspaceRepository _workspaceRepository; private readonly ApplicationWorkspaceManager _applicationWorkspaceManager; + public WorkspaceDataSeederContributor( + IConfiguration configuration, IWorkspaceRepository workspaceRepository, ApplicationWorkspaceManager applicationWorkspaceManager) { + _configuration = configuration; _workspaceRepository = workspaceRepository; _applicationWorkspaceManager = applicationWorkspaceManager; } @@ -281,18 +313,32 @@ public class WorkspaceDataSeederContributor : IDataSeedContributor, ITransientDe provider: "OpenAI", modelName: "gpt-4"); - workspace.ApiKey = "your-api-key"; + workspace.ApiKey = _configuration["AI:OpenAI:ApiKey"]; workspace.SystemPrompt = "You are a helpful customer support assistant."; await _workspaceRepository.InsertAsync(workspace); } +} ``` +> [!WARNING] +> Provider API keys, embedding keys, vector-store connection settings, MCP headers and MCP credentials are sensitive persisted configuration. Do not hard-code them in source control. Restrict workspace and MCP administration permissions, protect the application database and configuration backups, and supply secrets from a secure configuration provider. Duplicating a workspace also duplicates its provider and RAG configuration, including credentials. + +### Resolution Precedence + +When an application requests a workspace chat client, AI Management resolves it in this order: + +1. If no persisted configuration exists, the workspace is inactive, or it is a system workspace with `OverrideSystemConfiguration = false`, resolution first tries the code-defined keyed `IChatClient` and then the default `IChatClient`. +2. An active dynamic workspace, or an overridden system workspace, uses the factory registered for its persisted `Provider` value. + +If neither fallback exists, an inactive workspace produces an inactive-workspace error; the other fallback cases produce a provider-not-found error. A configured provider without a registered factory also produces a provider-not-found error. + ### Workspace Naming Rules * Workspace names **must be unique** * Workspace names **cannot contain spaces** (use underscores or camelCase) -* Workspace names are **case-sensitive** + +Workspace names are global identifiers in an AI Management database. Workspaces, MCP server configurations, data sources and the data-source blob container are not tenant-scoped entities. In a multi-tenant application, use workspace resource permissions and application-level design to control access; do not treat these records or blobs as tenant-isolated storage. ## RAG with File Upload @@ -367,7 +413,12 @@ RAG is enabled per workspace when both embedding and vector store settings are c * `MongoDb`: Standard MongoDB connection string including database name. * `Pgvector`: Standard PostgreSQL/Npgsql connection string. -* `Qdrant`: Qdrant endpoint string (`http://host:port`, `https://host:port`, or `host:port`). +* `Qdrant`: Qdrant endpoint string (`http://host:port`, `https://host:port`, or `host:port`). The current provider discards the URL scheme and creates a non-TLS client from only the host and port. Do not rely on an `https://` value to enable TLS. + +> [!IMPORTANT] +> The `MongoDb` vector-store provider requires MongoDB Atlas or an Atlas CLI local deployment because it uses `$vectorSearch`. Standard MongoDB Docker images do not provide this feature. Create an Atlas Vector Search index named `vector_index` on the `AIVectorEmbeddings` collection, with `Embedding` as the vector field, cosine similarity and dimensions matching the configured embedding model. The provider creates the collection and its regular workspace index, but it does not create the Atlas vector index. + +The Pgvector provider creates the target database when its credentials permit it, enables the `vector` extension and creates workspace tables lazily. Qdrant creates a workspace-specific collection when the first vector is stored. The service account therefore needs the corresponding database, extension, table or collection privileges. #### Document Processing Pipeline @@ -376,9 +427,11 @@ When a file is uploaded as a workspace data source: 1. File is stored in blob storage. 2. `IndexDocumentJob` is queued. 3. `DocumentProcessingManager` extracts text using content-type-specific extractors. -4. Text is chunked (default chunk size: `1000`, overlap: `200`). -5. Embeddings are generated in batches and stored through the configured vector store. -6. Data source is marked as processed (`IsProcessed = true`). +4. Text is chunked with `1000` characters as a target size. The normal paragraph path carries up to `200` characters into the next chunk; oversized paragraphs are split by line with a `50`-character carry. An indivisible line can produce a chunk larger than the target. +5. Embeddings are generated in ordered batches and stored through the configured vector store. +6. The data source is marked as processed (`IsProcessed = true`) after the final batch succeeds. + +Each indexing run has an identifier and a per-data-source distributed lock. Stale and duplicate batches are ignored, while an out-of-order batch is retried by the background job system. A failed run can therefore leave partial vector data until a retry succeeds or the data source is re-indexed again. #### Workspace Data Source HTTP API @@ -395,20 +448,45 @@ The module exposes workspace data source endpoints under `/api/ai-management/wor #### Chat Integration Behavior -When a workspace has embedder configuration, AI Management wraps the chat client with a document search tool function named `search_workspace_documents`. +When an active dynamic workspace, or a system workspace with `OverrideSystemConfiguration = true`, is resolved through its persisted provider factory and has embedder configuration, AI Management wraps the factory-created client with a document search tool function named `search_documents`. The keyed/default fallback branches described in [Resolution Precedence](#resolution-precedence) return their code-defined client directly and don't add persisted RAG or MCP tools. * The tool delegates to `IDocumentSearchService` (`DocumentSearchService` by default). * The search currently uses `TopK = 5` chunks. * If RAG retrieval fails, chat continues without injected context. +The underlying chat client must be a `FunctionInvokingChatClient`. The built-in OpenAI and Ollama factories add function invocation automatically. A custom chat factory must build its client with `ChatClientBuilder.UseFunctionInvocation()` before RAG or MCP tools can execute. + #### Automatic Reindexing on Configuration Changes When workspace embedder or vector store configuration changes, AI Management automatically: * Initializes the new vector store configuration (if needed). -* Deletes existing embeddings when embedder provider/model changes. +* Attempts to delete existing embeddings when the embedder provider or model changes. * Re-queues all workspace data sources for re-indexing. +Embedding cleanup and automatic re-index queueing are best-effort follow-up operations. Their failures are logged without rolling back the workspace update. Use the data-source page's **Re-index All** action after correcting the configuration if automatic re-indexing could not be queued. + +### Configuring Indexing Options + +`AIManagementIndexingOptions` controls the work performed by background indexing jobs: + +```csharp +Configure(options => +{ + options.EmbeddingBatchSize = 100; + options.MaxConcurrentIndexingJobs = 2; + options.DistributedLockTimeoutSeconds = 15; +}); +``` + +| Property | Default | Description | +| -------- | ------- | ----------- | +| `EmbeddingBatchSize` | `50` | Maximum chunks embedded and stored by one batch job | +| `MaxConcurrentIndexingJobs` | `1` | Maximum indexing batches running concurrently across the application | +| `DistributedLockTimeoutSeconds` | `0` | Time to wait for an indexing lock; `0` performs an immediate attempt | + +Increase concurrency only after checking the embedding provider's rate limits and vector-store capacity. The concurrency limiter and the per-data-source lock both use the [distributed lock](../../framework/infrastructure/distributed-locking.md), so they apply across all application instances when a distributed lock provider is configured (with the default in-process implementation, they only cover a single process). The per-data-source lock prevents two batches from mutating the same data source concurrently. + ### Configuring Data Source Upload Options The `WorkspaceDataSourceOptions` class allows you to customize the file upload constraints for workspace data sources. You can configure the allowed file extensions, maximum file size, and content type mappings. @@ -418,15 +496,13 @@ public override void ConfigureServices(ServiceConfigurationContext context) { Configure(options => { - // Add support for additional file types - options.AllowedFileExtensions = new[] { ".txt", ".md", ".pdf", ".docx", ".csv" }; + // Allow log files to use the built-in plain-text extractor. + options.AllowedFileExtensions = [".txt", ".md", ".pdf", ".log"]; // Increase the maximum file size to 50 MB options.MaxFileSize = 50 * 1024 * 1024; - // Add content type mappings for new extensions - options.ContentTypeMap[".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - options.ContentTypeMap[".csv"] = "text/csv"; + options.ContentTypeMap[".log"] = "text/plain"; }); } ``` @@ -439,6 +515,8 @@ public override void ConfigureServices(ServiceConfigurationContext context) | `MaxFileSize` | `long` | `10485760` (10 MB) | Maximum file size in bytes | | `ContentTypeMap` | `Dictionary` | `.txt`, `.md`, `.pdf` with their MIME types | Maps file extensions to MIME content types | +Adding an extension and MIME mapping only allows the upload. The mapped content type must also be supported by an `IDocumentTextExtractor`. Implement and register an extractor when adding a format that cannot use the built-in plain-text, Markdown or PDF extractors. + The options class also provides helper methods: | Method | Description | @@ -447,9 +525,6 @@ The options class also provides helper methods: | `GetAllowedExtensionsDisplay()` | Returns a comma-separated display string (e.g., ".txt, .md, .pdf") | | `GetAcceptAttribute()` | Returns a string for the HTML `accept` attribute (e.g., ".txt,.md,.pdf") | -> [!NOTE] -> Adding new file extensions also requires a matching content extractor to be registered for document processing. The built-in extractors support `.txt`, `.md`, and `.pdf` files. - #### Hosting-Level Upload Limits `WorkspaceDataSourceOptions.MaxFileSize` controls the module-level validation, but your hosting stack may reject large uploads before the request reaches AI Management. If you increase `MaxFileSize`, make sure the underlying server and proxy limits are also updated. @@ -523,6 +598,7 @@ The AI Management module defines the following permissions: | `AIManagement.Workspaces.Delete` | Delete workspaces | | `AIManagement.Workspaces.Playground` | Access workspace chat playground | | `AIManagement.Workspaces.ManagePermissions` | Manage workspace resource permissions | +| `Volo.AIManagement.Workspaces.Workspace.Consume` | Consume a specific workspace; granted as a resource permission | | `AIManagement.McpServers` | View MCP servers | | `AIManagement.McpServers.Create` | Create new MCP servers | | `AIManagement.McpServers.Update` | Edit existing MCP servers| @@ -539,9 +615,17 @@ The module also defines workspace data source permissions for RAG document opera | `AIManagement.WorkspaceDataSources.Download` | Download original uploaded file | | `AIManagement.WorkspaceDataSources.ReIndex` | Re-index one or all workspace files | -### Workspace-Level Permissions +### Workspace Consumption Authorization + +The `IChatCompletionClientAppService` client API, OpenAI-compatible API and MCP tool catalog authorize a workspace when the current user satisfies any one of these conditions: + +1. The user has `AIManagement.Workspaces.Playground`. +2. The workspace has a `RequiredPermissionName` and the user has that policy. +3. The user has the `Volo.AIManagement.Workspaces.Workspace.Consume` resource permission for that workspace ID. -In addition to module-level permissions, you can restrict access to individual workspaces by setting the `RequiredPermissionName` property: +Use the **Manage Permissions** action on the workspace page to grant the per-workspace resource permission. `AIManagement.Workspaces.ManagePermissions` controls access to that action. + +`RequiredPermissionName` is a programmatic alternative for workspaces tied to an application permission: ```csharp var workspace = await _applicationWorkspaceManager.CreateAsync( @@ -549,14 +633,11 @@ var workspace = await _applicationWorkspaceManager.CreateAsync( provider: "OpenAI", modelName: "gpt-4" ); -// Set a specific permission for the workspace workspace.RequiredPermissionName = MyAppPermissions.AccessPremiumWorkspaces; +await _workspaceRepository.InsertAsync(workspace); ``` -When a workspace has a required permission: - -* Only authorized users with that permission can access the workspace endpoints -* Users without the permission will receive an authorization error +Administrative workspace and data-source endpoints still use the module permissions in the tables above. A workspace consumption grant does not grant permission to edit the workspace, manage its credentials or upload RAG documents. ## Usage Scenarios @@ -569,57 +650,7 @@ The AI Management module is designed to support various usage patterns, from sim **Use this when:** You want to use AI in your application without any dependency on the AI Management module. -In this scenario, you only use the ABP Framework's AI features directly. You configure AI providers (like OpenAI) in your code and don't need any database or management UI. - -**Required Packages:** - -- `Volo.Abp.AI` -- Any Microsoft AI extensions (e.g., `Microsoft.Extensions.AI.OpenAI`) - -**Configuration:** - -```csharp -public class YourModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - PreConfigure(options => - { - options.Workspaces.ConfigureDefault(configuration => - { - configuration.ConfigureChatClient(chatClientConfiguration => - { - chatClientConfiguration.Builder = new ChatClientBuilder( - sp => new OpenAIClient(apiKey).GetChatClient("gpt-4") - ); - }); - }); - }); - } -} -``` - -**Usage:** - -```csharp -public class MyService -{ - private readonly IChatClient _chatClient; - - public MyService(IChatClient chatClient) - { - _chatClient = chatClient; - } - - public async Task GetResponseAsync(string prompt) - { - var response = await _chatClient.CompleteAsync(prompt); - return response.Message.Text; - } -} -``` - -> See [Artificial Intelligence](../../framework/infrastructure/artificial-intelligence/index.md) documentation for more details about workspace configuration. +In this scenario, use the ABP Framework AI infrastructure and configure provider clients in code. No AI Management database, administration UI or commercial client packages are involved. See [Artificial Intelligence](../../framework/infrastructure/artificial-intelligence/index.md) for the current static workspace registration and consumption APIs. ### Scenario 2: AI Management with Domain Layer Dependency (Local Execution) @@ -646,49 +677,12 @@ In this scenario, you install the AI Management module with its database layer, > Note: `Volo.AIManagement.EntityFrameworkCore` transitively includes `Volo.AIManagement.Domain` and `Volo.Abp.AI.AIManagement` packages. -**Workspace Definition Options:** - -**Option 1 - System Workspace (Code-based):** - -```csharp -public class YourModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - PreConfigure(options => - { - options.Workspaces.Configure(configuration => - { - configuration.ConfigureChatClient(chatClientConfiguration => - { - // Configuration will be populated from database - }); - }); - }); - } -} -``` - -**Option 2 - Dynamic Workspace (UI-based):** - -No code configuration needed. Define workspaces through: +Define dynamic workspaces through: - The AI Management UI (navigate to AI Management > Workspaces) - Data seeding in your `DataSeeder` class -**Using Chat Client:** - -```csharp -public class MyService -{ - private readonly IChatClient _chatClient; - - public MyService(IChatClient chatClient) - { - _chatClient = chatClient; - } -} -``` +For a system workspace, define the typed workspace and its code client as described in [System Workspaces](#system-workspaces). Enable `OverrideSystemConfiguration` on that workspace only when persisted provider settings should replace the code client. ### Scenario 3: AI Management Client with Remote Execution @@ -714,25 +708,6 @@ Add the remote service endpoint in your `appsettings.json`: } ``` -Optionally define workspace in your module: - -```csharp -public class YourModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - PreConfigure(options => - { - // Optional: Pre-define workspace type for type safety - options.Workspaces.Configure(configuration => - { - // Configuration will be fetched from remote service - }); - }); - } -} -``` - **Usage:** ```csharp @@ -749,14 +724,14 @@ public class MyService { var request = new ChatClientCompletionRequestDto { - Messages = new List - { - new ChatMessageDto { Role = "user", Content = prompt } - } + Messages = + [ + new ChatMessageDto { Role = ChatRole.User, Content = prompt } + ] }; var response = await _chatService.ChatCompletionsAsync(workspaceName, request); - return response.Content; + return response.Text; } // For streaming responses @@ -764,15 +739,15 @@ public class MyService { var request = new ChatClientCompletionRequestDto { - Messages = new List - { - new ChatMessageDto { Role = "user", Content = prompt } - } + Messages = + [ + new ChatMessageDto { Role = ChatRole.User, Content = prompt } + ] }; await foreach (var update in _chatService.StreamChatCompletionsAsync(workspaceName, request)) { - yield return update.Content; + yield return update.Text; } } } @@ -799,8 +774,8 @@ Same as Scenario 3, configure the remote AI Management service in `appsettings.j Once configured, other applications can call your application's endpoints: -- `POST /api/ai-management-client/chat-completion` for chat completions -- `POST /api/ai-management-client/stream-chat-completion` for streaming responses +- `POST /api/chat-completion/{workspaceName}` for chat completions +- `POST /api/chat-completion/{workspaceName}/stream` for streaming responses Your application acts as a proxy, forwarding these requests to the AI Management microservice. @@ -823,7 +798,7 @@ Each AI Management **workspace** appears as a selectable model in the client app ![ai-management-openai-anythingllm2](../../images/ai-management-openai-anythingllm2.png) -#### Available Endpoints +### Available Endpoints | Endpoint | Method | Description | | ---------------------------- | ------ | ----------------------------------------------- | @@ -832,15 +807,14 @@ Each AI Management **workspace** appears as a selectable model in the client app | `/v1/models` | GET | List available models (workspaces) | | `/v1/models/{modelId}` | GET | Retrieve a single model (workspace) | | `/v1/embeddings` | POST | Generate embeddings | -| `/v1/files` | GET | List uploaded files | -| `/v1/files` | POST | Upload a file | -| `/v1/files/{fileId}` | GET | Get file info | -| `/v1/files/{fileId}` | DELETE | Delete a file | -| `/v1/files/{fileId}/content` | GET | Download file content | -All endpoints require authentication via a **Bearer token** in the `Authorization` header. +All endpoints require an authenticated user. Non-browser API clients normally send an access token as a **Bearer token** in the `Authorization` header. + +The value of `model` is an AI Management workspace name, not the provider's model name. `GET /v1/models` returns only workspaces the current user is authorized to consume. The same workspace authorization is applied before chat, completion, model and embedding operations. -#### Usage +The module also exposes file-management routes backed by workspace data sources. They require workspace-specific parameters and are not a drop-in implementation of the OpenAI Files API. Use the [Workspace Data Source HTTP API](#workspace-data-source-http-api) for portable upload, list, download, delete and re-index operations. + +### Usage The general pattern for connecting any OpenAI-compatible client: @@ -877,13 +851,13 @@ curl -X POST https://localhost:44336/v1/chat/completions \ }' ``` -> The OpenAI-compatible endpoints are available from both the `Volo.AIManagement.Client.HttpApi` and `Volo.AIManagement.HttpApi` packages, depending on your deployment scenario. +> The OpenAI-compatible `/v1` endpoints are provided by `Volo.AIManagement.Client.HttpApi`. `Volo.AIManagement.HttpApi` instead exposes the management and integration APIs. ## Client Usage AI Management uses different packages depending on the usage scenario: -- **`Volo.AIManagement.*` packages**: These contain the core AI functionality and are used when your application hosts and manages its own AI operations. These packages don't expose any application service and endpoints to be consumed by default. +- **`Volo.AIManagement.*` packages**: These contain the core AI functionality for applications that host and manage AI operations. The application and HTTP API packages expose management and integration services. - **`Volo.AIManagement.Client.*` packages**: These are designed for applications that need to consume AI services from a remote application. They provide both server and client side of remote access to the AI services. @@ -923,6 +897,9 @@ You can customize the chat widget with the following properties: - `Title`: The title of the chat widget. - `ShowStreamCheckbox`: Whether to show the stream checkbox. Allows user to toggle streaming on and off. Default is `false`. - `UseStreaming`: Default streaming behavior. Can be overridden by user when `ShowStreamCheckbox` is true. +- `DisableWhenNoConversation`: Disables input until a `ConversationId` is selected. Default is `false`. +- `ShowUsageDetails`: Shows token usage and tool calls for assistant messages. Default is `true`. +- `ShowDetailedSourceInformation`: Shows chunk-level RAG source details instead of grouping sources by file. Default is `false`. ```csharp @await Component.InvokeAsync(typeof(ChatClientChatViewComponent), new ChatClientChatViewModel @@ -1036,8 +1013,9 @@ chatComponent.off('messageSent', callbackFunction); In order to configure the application to use the AI Management module, you first need to import `provideAIManagementConfig` from `@volo/abp.ng.ai-management/config` to root application configuration. Then, you will need to append it to the `appConfig` array: -```js +```ts // app.config.ts +import { ApplicationConfig } from '@angular/core'; import { provideAIManagementConfig } from '@volo/abp.ng.ai-management/config'; export const appConfig: ApplicationConfig = { @@ -1048,20 +1026,24 @@ export const appConfig: ApplicationConfig = { }; ``` -The AI Management module should be imported and lazy-loaded in your routing array. It has a static `createRoutes` method for configuration. It is available for import from `@volo/abp.ng.ai-management`. +The AI Management module should be imported and lazy-loaded in your routing array. It has a `createRoutes` function for configuration and is available from `@volo/abp.ng.ai-management`. -```js +```ts // app.routes.ts +import { Routes } from '@angular/router'; + const APP_ROUTES: Routes = [ // ... { path: 'ai-management', loadChildren: () => - import('@volo/abp.ng.ai-management').then(m => m.createRoutes(/* options here */)), + import('@volo/abp.ng.ai-management').then(m => m.createRoutes()), }, ]; ``` +`createRoutes` optionally accepts `AIManagementConfigOptions`. It provides entity-action, toolbar-action and entity-property contributors for workspaces and MCP servers, plus create/edit form contributors for workspaces. The Workspaces, Chat Playground, MCP Servers and Workspace Data Sources routes also use keys from `eAIManagementComponents`, so they can be replaced through ABP's replaceable component system. + #### Services / Models AI Management module services and models are generated via `generate-proxy` command of the [ABP CLI](../../cli). If you need the module's proxies, you can run the following command in the Angular project directory: @@ -1084,14 +1066,19 @@ export const environment = { AIManagement: { url: 'AI Management remote url here', }, + AIManagementClient: { + url: 'AI Management client remote url here', + }, // other api configurations }, }; ``` -The AI Management module remote URL configurations shown above are optional. +Use `AIManagement` for management and integration proxies. Non-streaming requests from the Angular chat widget use the generated `ChatCompletionClientService` proxy with `AIManagementClient`. When streaming is enabled, both the stream-start POST request and the subsequent EventSource connection use `default.url` instead of `AIManagementClient`. -> If you don't set the `AIManagement` property, the `default.url` will be used as fallback. +Both named entries are optional and generated proxy requests fall back independently to `default.url`. `default.url` is also the global fallback for other ABP and application requests that don't specify a configured named API, so changing it redirects those requests too. + +For split hosting, configure `AIManagement` and `AIManagementClient` for their respective hosts. Before pointing `default.url` to the client API and enabling streaming, make sure every other request uses an appropriate named API or that the client host provides or forwards all endpoints that still rely on the global fallback. #### The Chat Widget @@ -1103,12 +1090,19 @@ The `@volo/abp.ng.ai-management` package provides a `ChatInterfaceComponent` (`a ``` - `workspaceName` (required): The name of the workspace to use. -- `conversationId`: The unique identifier for persisting and retrieving chat history from client-side storage. When provided, the chat history is stored in the browser and restored when the user revisits the page. If `null`, the chat is ephemeral and will be lost when the component is destroyed. +- `conversationId`: The unique identifier for persisting and retrieving chat history from client-side storage. When provided, the history is restored for the current user and workspace. - `providerName`: The name of the AI provider. Used for displaying contextual error messages. +- `allowEphemeral`: Allows sending without a `conversationId`. The default is `false`; set it to `true` for an in-memory conversation that is discarded with the component. +- `showUsageDetails`: Shows usage and tool-call details. Default is `true`. +- `showStreamCheckbox`: Shows the streaming toggle. Default is `true`. + +The component emits `messageSent` and `messageReceived` events for application-level conversation orchestration. ### Blazor UI @@ -1117,26 +1111,22 @@ The `@volo/abp.ng.ai-management` package provides a `ChatInterfaceComponent` (`a The AI Management module remote endpoint URLs can be configured in your `appsettings.json`: ```json -"RemoteServices": { - "Default": { - "BaseUrl": "Default url here" - }, - "AIManagement": { - "BaseUrl": "AI Management remote url here" +{ + "RemoteServices": { + "Default": { + "BaseUrl": "Default url here" + }, + "AIManagement": { + "BaseUrl": "AI Management remote url here" + }, + "AIManagementClient": { + "BaseUrl": "AI Management client remote url here" + } } } ``` -For **Blazor WebAssembly**, you can also configure the remote endpoint URL via `AIManagementClientBlazorWebAssemblyOptions`: - -```csharp -Configure(options => -{ - options.RemoteServiceUrl = builder.Configuration["RemoteServices:AIManagement:BaseUrl"]; -}); -``` - -> If you don't set the `BaseUrl` for AIManagement, the `Default.BaseUrl` will be used as fallback. +Use the `AIManagement` remote service for management and integration clients. Use `AIManagementClient` for the remote chat and OpenAI-compatible client APIs. Blazor WebAssembly uses the standard ABP remote-service configuration shown above; it doesn't require a module-specific options class. If a named `BaseUrl` isn't set, ABP uses `Default.BaseUrl` as the fallback. #### The Chat Widget @@ -1152,10 +1142,15 @@ The `Volo.AIManagement.Client.Blazor` package provides a `ChatClientChat` Blazor ``` - `WorkspaceName` (required): The name of the workspace to use. -- `ConversationId`: The unique identifier for persisting and retrieving chat history from client-side storage. When provided, the chat history is stored in the browser's local storage and restored when the user revisits the page. If not provided or `null`, the chat is ephemeral and will be lost when the component is disposed. +- `ConversationId` (required to send messages): The unique identifier for persisting and retrieving chat history from client-side storage. The component disables message input while this value is null or empty. - `Title`: The title displayed in the chat widget header. - `ShowStreamCheckbox`: Whether to show a checkbox that allows the user to toggle streaming on and off. Default is `false`. -- `OnFirstMessage`: An `EventCallback` that is triggered when the first message is sent in a conversation. It can be used to determine the chat title after the first prompt like applied in the chat playground. The event args contain `ConversationId` and `Message` properties. +- `UseStreaming`: Initial streaming mode. Default is `true`. +- `ShowUsageDetails`: Shows token usage and tool calls. Default is `true` in the Blazorise component. +- `ShowDetailedSourceInformation`: Shows chunk-level RAG source information. Default is `true` in the Blazorise component. +- `OnFirstMessage`: An `EventCallback` that is triggered when the first message is sent in a conversation. It can be used to determine the chat title after the first prompt like applied in the chat playground. The event args contain `ConversationId` and `Message` properties. + +The MudBlazor package exposes the same core `WorkspaceName`, `ConversationId`, `Title`, `ShowStreamCheckbox` and `OnFirstMessage` parameters. The detailed usage/source display parameters above are specific to the Blazorise component. ```xml ``` -## Using Dynamic Workspace Configurations for custom requirements +## Reading Dynamic Workspace Configuration -The AI Management module allows you to access only configuration of a workspace without resolving pre-constructed chat client. This is useful when you want to use a workspace for your own purposes and you don't need to use the chat client. -The `IWorkspaceConfigurationStore` service is used to access the configuration of a workspace. It has multiple implementations according to the usage scenario. +Use `IWorkspaceConfigurationStore` when trusted server-side code needs the persisted workspace configuration without resolving an `IChatClient`. The store has local and remote implementations for the corresponding deployment scenarios. ```csharp public class MyService @@ -1179,34 +1173,29 @@ public class MyService _workspaceConfigurationStore = workspaceConfigurationStore; } - public async Task DoSomethingAsync() + public async Task GetModelNameAsync() { - // Get the configuration of the workspace that can be managed dynamically. - var configuration = await _workspaceConfigurationStore.GetAsync("MyWorkspace"); - - // Do something with the configuration - var kernel = Kernel.CreateBuilder() - .AddAzureOpenAIChatClient( - config.ModelName!, - new Uri(config.ApiBaseUrl), - config.ApiKey - ) - .Build(); + var configuration = await _workspaceConfigurationStore + .GetOrNullAsync("MyWorkspace"); + + return configuration?.ModelName; } } ``` +The returned configuration can include provider credentials. Keep it inside trusted server-side services and do not serialize it into an application response. Use `IChatClient` or `IChatCompletionClientAppService` when you only need to execute a chat request. + ## Implementing Custom AI Provider Factories -While the AI Management module provides built-in support for OpenAI through the `Volo.AIManagement.OpenAI` package, you can easily add support for other AI providers by implementing a custom `IChatClientFactory`. +AI Management provides built-in OpenAI and Ollama factories through the `Volo.AIManagement.OpenAI` and `Volo.AIManagement.Ollama` packages. You can add another provider by implementing a custom `IChatClientFactory`. ### Understanding the Factory Pattern -The AI Management module uses a factory pattern to create `IChatClient` instances based on the provider configuration stored in the database. Each provider (OpenAI, Ollama, Azure OpenAI, etc.) needs its own factory implementation. +The AI Management module uses a factory pattern to create `IChatClient` instances based on the provider configuration stored in the database. Each provider name needs one registered factory implementation. ### Creating a Custom Factory -Here's how to implement a factory for Ollama as an example: +The following example registers a separate `CustomOllama` provider to demonstrate the factory contract. Use the built-in `Volo.AIManagement.Ollama` package when you only need the standard Ollama integration. #### Step 1: Install the Provider's NuGet Package @@ -1221,6 +1210,8 @@ dotnet add package OllamaSharp Create a factory class that implements `IChatClientFactory`: ```csharp +using System; +using System.Threading.Tasks; using Microsoft.Extensions.AI; using OllamaSharp; using Volo.AIManagement.Factory; @@ -1228,20 +1219,18 @@ using Volo.Abp.DependencyInjection; namespace YourNamespace; -public class OllamaChatClientFactory : IChatClientFactory, ITransientDependency +public class CustomOllamaChatClientFactory : IChatClientFactory, ITransientDependency { - public string Provider => "Ollama"; + public string Provider => "CustomOllama"; public Task CreateAsync(ChatClientCreationConfiguration configuration) { - // Create the Ollama client with configuration from database - var client = new OllamaApiClient( + var builder = new ChatClientBuilder(sp => new OllamaApiClient( configuration.ApiBaseUrl ?? "http://localhost:11434", - configuration.ModelName - ); + configuration.ModelName ?? throw new InvalidOperationException("A model is required."))); - // Return as IChatClient - return Task.FromResult(client); + builder.UseFunctionInvocation(); + return Task.FromResult(builder.Build()); } } ``` @@ -1255,13 +1244,13 @@ public override void ConfigureServices(ServiceConfigurationContext context) { Configure(options => { - options.AddFactory("Ollama"); + options.AddFactory("CustomOllama"); }); } ``` -> [!TIP] -> For production scenarios, you may want to add validation for the factory configuration. +> [!IMPORTANT] +> Validate every required configuration value in the factory. Build a `FunctionInvokingChatClient` with `UseFunctionInvocation()` when the workspace can use RAG or MCP tools. ### Available Configuration Properties @@ -1270,6 +1259,7 @@ The `ChatClientCreationConfiguration` object provides the following properties f | Property | Type | Description | | ------------------------ | ------- | ------------------------------------------- | | `Name` | string | Workspace name | +| `WorkspaceId` | Guid? | Persisted workspace ID, when available | | `Provider` | string | Provider name (e.g., "OpenAI", "Ollama") | | `ApiKey` | string? | API key for authentication | | `ModelName` | string | Model identifier (e.g., "gpt-4", "mistral") | @@ -1279,6 +1269,7 @@ The `ChatClientCreationConfiguration` object provides the following properties f | `Description` | string? | Workspace description | | `IsActive` | bool | Whether the workspace is active | | `IsSystem` | bool | Whether it's a system workspace | +| `OverrideSystemConfiguration` | bool | Whether persisted settings override a system workspace | | `RequiredPermissionName` | string? | Permission required to use this workspace | | `HasEmbedderConfiguration` | bool | Whether the workspace has embedder/RAG configuration | @@ -1286,7 +1277,11 @@ The `ChatClientCreationConfiguration` object provides the following properties f Here's an example of implementing a factory for Azure OpenAI: +Install the `Azure.AI.OpenAI` and `Microsoft.Extensions.AI.OpenAI` NuGet packages before adding this factory (the `AsIChatClient()` extension method comes from `Microsoft.Extensions.AI.OpenAI`). + ```csharp +using System; +using System.Threading.Tasks; using Azure.AI.OpenAI; using Azure; using Microsoft.Extensions.AI; @@ -1306,8 +1301,13 @@ public class AzureOpenAIChatClientFactory : IChatClientFactory, ITransientDepend new AzureKeyCredential(configuration.ApiKey ?? throw new ArgumentNullException(nameof(configuration.ApiKey))) ); - var chatClient = client.GetChatClient(configuration.ModelName); - return Task.FromResult(chatClient.AsIChatClient()); + var modelName = configuration.ModelName ?? + throw new ArgumentNullException(nameof(configuration.ModelName)); + var builder = new ChatClientBuilder(sp => + client.GetChatClient(modelName).AsIChatClient()); + + builder.UseFunctionInvocation(); + return Task.FromResult(builder.Build()); } } ``` @@ -1318,7 +1318,7 @@ After implementing and registering your factory: 1. **Through UI**: Navigate to the AI Management workspaces page and create a new workspace: - - Select your provider name (e.g., "Ollama", "AzureOpenAI") + - Select your provider name (for this example, `CustomOllama`) - Configure the API settings - Set the model name @@ -1326,8 +1326,8 @@ After implementing and registering your factory: ```csharp var workspace = await _applicationWorkspaceManager.CreateAsync( - name: "MyOllamaWorkspace", - provider: "Ollama", + name: "MyCustomOllamaWorkspace", + provider: "CustomOllama", modelName: "mistral" ); workspace.ApiBaseUrl = "http://localhost:11434"; @@ -1396,16 +1396,16 @@ The module exposes the following integration services for inter-service communic Workspace configurations are cached for performance. The cache key format: -``` -WorkspaceConfiguration:{ApplicationName}:{WorkspaceName} +```text +WorkspaceConfiguration:{WorkspaceName} ``` +The cache is invalidated when workspaces are created, updated or deleted. A rename invalidates both the old and current workspace names, and invalidation participates in the current unit of work. + ### HttpApi Client Layer - `IntegrationWorkspaceConfigurationStore`: Integration service for remote workspace configuration retrieval. Implements `IWorkspaceConfigurationStore` interface. -The cache is automatically invalidated when workspaces are created, updated, or deleted. - ## See Also - [Artificial Intelligence Infrastructure](../../framework/infrastructure/artificial-intelligence/index.md): Learn about the underlying AI workspace infrastructure diff --git a/docs/en/modules/audit-logging-pro.md b/docs/en/modules/audit-logging-pro.md index 851787d390..1e7ab562dc 100644 --- a/docs/en/modules/audit-logging-pro.md +++ b/docs/en/modules/audit-logging-pro.md @@ -25,7 +25,7 @@ See [the module description page](https://abp.io/modules/Volo.AuditLogging.Ui) f ## How to install -Identity is pre-installed in [the startup templates](../solution-templates). So, no need to manually install it. +Audit Logging is pre-installed in [the startup templates](../solution-templates). So, no need to manually install it. ### Packages @@ -41,7 +41,7 @@ Audit logs module adds the following items to the "Main" menu, under the "Admini * **Audit Logs**: List, view and filter audit logs and entity changes. -`IAbpAuditLoggingMainMenuNames` class has the constants for the menu item names. +`AbpAuditLoggingMainMenuNames` class has the constants for the menu item names. ### Pages @@ -67,7 +67,7 @@ You can view details of an audit log by clicking the magnifier icon on each audi ##### Export to Excel -You can export audit logs to Excel by clicking the "Export to Excel" button in the toolbar. If the result set is small (less than a configurable threshold), the file will be generated and downloaded immediately. For larger result sets, the export will be processed as a background job and you'll receive an email with a download link once the export is completed. +You can export audit logs to Excel by clicking the "Export to Excel" button in the toolbar. The file is generated and downloaded immediately when the result set contains 1,000 records or fewer. If the result set contains more than 1,000 records, the export is processed as a background job and you'll receive an email with a download link once the export is completed. #### Entity Changes @@ -97,7 +97,7 @@ You can view details of all changes of an entity by clicking the "Full Change Hi ##### Export to Excel -You can export entity changes to Excel by clicking the "Export to Excel" button in the toolbar. Similar to audit logs export, for large datasets the export will be processed as a background job and you'll receive an email notification once completed. +You can export entity changes to Excel by clicking the "Export to Excel" button in the toolbar. As with audit log exports, result sets with 1,000 records or fewer are downloaded immediately. Result sets with more than 1,000 records are processed as a background job, and you'll receive an email notification once the export is completed. #### Audit Log Settings @@ -113,6 +113,111 @@ To view the audit log settings, you need to enable the feature. For the host sid > If you don't enable the *Cleanup Service System Wide* from the host side under *Settings* -> *Audit logs* -> *Global*, it won't remove the expired audit logs, even if there are tenant specific settings. +## Reusable widgets + +The module provides **Error Rate** and **Average Execution Duration Per Day** widgets. The current user needs the `AuditLogging.AuditLogs` permission to load their data. + +### Angular + +Import the widget components from `@volo/abp.ng.audit-logging`, add them to your component imports and keep references when you need to refresh their date range: + +```ts +import { Component, ViewChild } from '@angular/core'; +import { + AverageExecutionDurationWidgetComponent, + ErrorRateWidgetComponent, +} from '@volo/abp.ng.audit-logging'; + +@Component({ + selector: 'app-audit-statistics', + templateUrl: './audit-statistics.component.html', + imports: [ + AverageExecutionDurationWidgetComponent, + ErrorRateWidgetComponent, + ], +}) +export class AuditStatisticsComponent { + @ViewChild('averageExecutionDurationWidget') + averageExecutionDurationWidget!: AverageExecutionDurationWidgetComponent; + + @ViewChild('errorRateWidget') + errorRateWidget!: ErrorRateWidgetComponent; + + refresh(startDate: string, endDate: string) { + this.averageExecutionDurationWidget.draw({ startDate, endDate }); + this.errorRateWidget.draw({ startDate, endDate }); + } +} +``` + +The `width` and `height` inputs are optional. Both default to `273` and `136`, respectively. + +```html + + + +``` + +### Blazor + +The Bootstrap and MudBlazor packages expose components with the same parameters and `RefreshAsync` method. The following example uses the Bootstrap Blazor package. For MudBlazor, use the corresponding `Volo.Abp.AuditLogging.Blazor.MudBlazor` namespaces. + +```razor +@using Volo.Abp.AuditLogging.Blazor.Pages.Shared.AverageExecutionDurationPerDayWidget +@using Volo.Abp.AuditLogging.Blazor.Pages.Shared.ErrorRateWidget + + + + + +@code { + private DateTime StartDate { get; set; } = DateTime.Today.AddMonths(-1); + private DateTime EndDate { get; set; } = DateTime.Today; + + private AuditLoggingAverageExecutionDurationPerDayWidgetComponent AverageExecutionDurationWidget { get; set; } = default!; + private AuditLoggingErrorRateWidgetComponent ErrorRateWidget { get; set; } = default!; + + private async Task RefreshAsync() + { + await AverageExecutionDurationWidget.RefreshAsync(); + await ErrorRateWidget.RefreshAsync(); + } +} +``` + +### MVC / Razor Pages + +Use `IWidgetManager` to check the widget permission before invoking its view component: + +```cshtml +@using Volo.Abp.AspNetCore.Mvc.UI.Widgets +@using Volo.Abp.AuditLogging.Web.Pages.Shared.Components.AverageExecutionDurationPerDayWidget +@using Volo.Abp.AuditLogging.Web.Pages.Shared.Components.ErrorRateWidget +@inject IWidgetManager WidgetManager + +@if (await WidgetManager.IsGrantedAsync(typeof(AuditLoggingErrorRateWidgetViewComponent))) +{ + @await Component.InvokeAsync(typeof(AuditLoggingErrorRateWidgetViewComponent)) +} + +@if (await WidgetManager.IsGrantedAsync(typeof(AuditLoggingAverageExecutionDurationPerDayWidgetViewComponent))) +{ + @await Component.InvokeAsync(typeof(AuditLoggingAverageExecutionDurationPerDayWidgetViewComponent)) +} +``` + ## Data seed This module doesn't seed any data. @@ -145,7 +250,7 @@ Configure(options => // The Hangfire Cron expression is different from the Quartz Cron expression, Please refer to the following links: // https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontriggers.html#cron-expressions // https://docs.hangfire.io/en/latest/background-methods/performing-recurrent-tasks.html - options.ExcelFileCleanupOptions.CronExpression = "0 23 * * *"; // Quartz Cron expression is "0 0 23 * * ?" + options.CronExpression = "0 23 * * *"; // Quartz Cron expression is "0 0 23 * * ?" }); ``` @@ -237,16 +342,30 @@ See the [connection strings](../framework/fundamentals/connection-strings.md) do * AbpAuditLogActions * AbpEntityChanges * AbpEntityPropertyChanges +* **AbpAuditLogExcelFiles** #### MongoDB ##### Collections * **AbpAuditLogs** +* **AbpAuditLogExcelFiles** ### Permissions -See the `AbpAuditLoggingPermissions` class members for all permissions defined for this module. +The module defines the following feature and permission relationships: + +* `AuditLogging.Enable` is enabled by default. The `AuditLogging.AuditLogs` permission requires this feature, and the audit log application service also checks it. +* `AuditLogging.SettingManagement` is a child feature of `AuditLogging.Enable` and is disabled by default. The `AuditLogging.AuditLogs.SettingManagement` permission requires this feature. +* `AuditLogging.AuditLogs.Export` is a child permission of `AuditLogging.AuditLogs`. Audit log and entity change export operations require this permission. + +See the `AbpAuditLoggingPermissions` and `AbpAuditLoggingFeatures` class members for the complete definitions. + +#### Entity-specific change history permissions + +You can define a permission for the change history of a specific entity by using the `AuditLogging.ViewChangeHistory:{EntityTypeFullName}` naming convention. For example, the permission name for `Acme.BookStore.Books.Book` is `AuditLogging.ViewChangeHistory:Acme.BookStore.Books.Book`. + +When a matching permission is defined and granted, the user can view that entity's change history. If the entity-specific permission is not defined or is not granted, authorization falls back to `AuditLogging.AuditLogs`. Users who have the general audit log permission can therefore still view the entity history. ### Angular UI diff --git a/docs/en/modules/audit-logging.md b/docs/en/modules/audit-logging.md index 5cba52cf34..a3f99147db 100644 --- a/docs/en/modules/audit-logging.md +++ b/docs/en/modules/audit-logging.md @@ -29,12 +29,37 @@ The source code of this module can be accessed [here](https://github.com/abpfram - `EntityChange` (collection): Changed entities of audit log. - `AuditLogAction` (collection): Executed actions of audit log. +#### Extending the Entities + +The `AuditLog`, `AuditLogAction` and `EntityChange` entities support the [Module Entity Extensions](../framework/architecture/modularity/extending/module-entity-extensions.md) system. Configure them in the `Domain.Shared` project before the database model is created. The following example adds a property to `AuditLog`: + +````csharp +ObjectExtensionManager.Instance.Modules() + .ConfigureAuditLogging(auditLogging => + { + auditLogging.ConfigureAuditLog(auditLog => + { + auditLog.AddOrUpdateProperty("ExternalId"); + }); + }); +```` + +Use `ConfigureAuditLogAction` or `ConfigureEntityChange` in the same way to extend the other supported entities. + #### Repositories Following custom repositories are defined for this module: - `IAuditLogRepository` +#### Audit Log Conversion + +The module uses `IAuditLogInfoToAuditLogConverter` to convert the `AuditLogInfo` collected by the auditing system into the persisted `AuditLog` aggregate. You can inject this service when you need the same conversion in a custom persistence flow, or replace its default implementation using the [dependency injection system](../framework/fundamentals/dependency-injection.md#replace-a-service) to customize the mapping. + +#### Persistence Limits + +Before saving an audit log, the module truncates fields that have maximum lengths defined by `AuditLogConsts`, `AuditLogActionConsts`, `EntityChangeConsts` and `EntityPropertyChangeConsts`. Action parameters are handled differently: if `AuditLogAction.Parameters` exceeds `AuditLogActionConsts.MaxParametersLength` (2,000 by default), it is persisted as an empty string instead of being truncated. + ### Database providers #### Common @@ -55,13 +80,15 @@ This module uses `AbpAuditLogging` for the connection string name. If you don't - AbpAuditLogActions - AbpEntityChanges - AbpEntityPropertyChanges +- **AbpAuditLogExcelFiles** #### MongoDB ##### Collections - **AbpAuditLogs** +- **AbpAuditLogExcelFiles** ## See Also -* [Audit logging system](../framework/infrastructure/audit-logging.md) \ No newline at end of file +* [Audit logging system](../framework/infrastructure/audit-logging.md) diff --git a/docs/en/modules/background-jobs.md b/docs/en/modules/background-jobs.md index 971284b7c9..63ba851e2b 100644 --- a/docs/en/modules/background-jobs.md +++ b/docs/en/modules/background-jobs.md @@ -33,13 +33,15 @@ Following custom repositories are defined for this module: - `IBackgroundJobRepository` +> `IBackgroundJobRepository` supports filtering the waiting jobs for dedicated workers and cleaning up retained completed jobs. See the *Dedicated Workers per Job Type* and *Storing Successful Jobs* sections of the [background jobs](../framework/infrastructure/background-jobs) document. + ### Database providers #### Common ##### Table / collection prefix & schema -All tables/collections use the `Abp` prefix by default. Set static properties on the `BackgroundJobsDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). +All tables/collections use the `Abp` prefix by default. Set static properties on the `AbpBackgroundJobsDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). ##### Connection string @@ -59,4 +61,4 @@ This module uses `AbpBackgroundJobs` for the connection string name. If you don' ## See Also -* [Background job system](../framework/infrastructure/background-jobs) \ No newline at end of file +* [Background job system](../framework/infrastructure/background-jobs) diff --git a/docs/en/modules/blogging.md b/docs/en/modules/blogging.md new file mode 100644 index 0000000000..39492a696c --- /dev/null +++ b/docs/en/modules/blogging.md @@ -0,0 +1,172 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to install and configure the standalone ABP Blogging module, including its MVC UI, routes, permissions, files and database providers." +} +``` + +# Blogging Module + +The Blogging module is a free and open-source application module for creating one or more blogs. It provides an MVC / Razor Pages user interface, application services, HTTP APIs and Entity Framework Core and MongoDB integrations for blogs, posts, tags, comments and member profiles. + +> This page documents the standalone `Volo.Blogging` module. [CMS Kit: Blogging](cms-kit/blogging.md) is a different feature family with different entities, configuration and UI. + +## How to Install + +Use the ABP CLI to add the module to an existing solution: + +```bash +abp add-module Volo.Blogging +``` + +The command adds the module packages and dependencies to the compatible projects in your solution. Apply the generated database migration after adding the module. + +### The Source Code + +The source code of this module is available in the [ABP repository](https://github.com/abpframework/abp/tree/dev/modules/blogging). It is licensed with [MIT](https://choosealicense.com/licenses/mit/), so you can use and customize it. + +## User Interface and Content Workflow + +The module provides two MVC / Razor Pages surfaces: + +* The public site lists blogs, posts and popular tags, renders post content as Markdown, displays member profiles and allows authenticated users to add comments. +* The administration page manages blogs. Post creation and editing are available from the public blog UI to users with the corresponding permissions. + +When the application has one blog, the blog index redirects directly to that blog. With multiple blogs, the index displays the available blogs. + +Creating a post makes it available to the public list and reading APIs immediately. The standalone module does not add a draft, review or scheduled-publication state. If a post URL is already used in the same blog, the application service appends a generated suffix and returns the resulting URL. + +Tags are entered with a post. The application service normalizes tag names to lowercase, removes duplicates and maintains their usage counts. + +### Member Profiles + +The module keeps a local `BlogUser` record for post and comment authors. It uses ABP's [user lookup and synchronization](identity/user-synchronization.md) infrastructure to obtain Identity user data. The public member page lists an active user's posts and profile information; the current user can edit the Blogging-specific fields on their own profile. + +## Permissions + +The administration menu is visible with the `Blogging.Blog.Management` permission. Blog operations use separate child permissions: + +* `Blogging.Blog.Create` +* `Blogging.Blog.Update` +* `Blogging.Blog.Delete` +* `Blogging.Blog.ClearCache` + +Post creation, update and deletion require `Blogging.Post.Create`, `Blogging.Post.Update` and `Blogging.Post.Delete`, respectively. + +Any authenticated user can create a comment. A comment can be updated or deleted by its creator or by a user with `Blogging.Comment.Update` or `Blogging.Comment.Delete`. + +See the [Authorization](../framework/fundamentals/authorization/index.md) documentation to learn how to grant permissions to roles and users. + +## Routing + +`BloggingUrlOptions.RoutePrefix` controls the public URL prefix. Its default value produces URLs under `/blog/`. The following example moves the public blog under `/articles/` and enables single-blog mode for the blog whose short name is `engineering`: + +```csharp +Configure(options => +{ + options.RoutePrefix = "articles"; + options.SingleBlogMode.Enabled = true; + options.SingleBlogMode.BlogName = "engineering"; +}); +``` + +Single-blog mode removes the blog short-name segment from post URLs. If the application contains multiple blogs, set `SingleBlogMode.BlogName` to the short name of the blog to expose. If it contains exactly one blog, the module can select it without setting `BlogName`. + +Set `RoutePrefix` to an empty string to serve the blog from `/`. In this mode the route constraint uses `IgnoredPaths` to avoid capturing other top-level application routes. The module already adds its framework endpoints, bundle folder and member route; add application-specific top-level paths when needed: + +```csharp +Configure(options => +{ + options.RoutePrefix = ""; + options.IgnoredPaths.Add("health"); +}); +``` + +## Post Images + +Post images are saved through the [BLOB Storing](../framework/infrastructure/blob-storing) system in the `blogging-files` container. Configure a BLOB provider for this container as you would for any other typed container. + +The upload service accepts JPEG, PNG, GIF and BMP images. The maximum file size is 5 MiB by default. `BloggingWebConsts.FileUploading.MaxFileSize` is a process-wide static value, so set it once during application startup, before the application begins accepting uploads: + +```csharp +BloggingWebConsts.FileUploading.MaxFileSize = 10 * 1024 * 1024; +``` + +## Social Media Metadata + +The post page emits Twitter card metadata. Configure the site handle with `BloggingTwitterOptions`: + +```csharp +Configure(options => +{ + options.Site = "@myblog"; +}); +``` + +## Post List Cache + +The time-ordered post list is cached per blog for one hour. Creating, updating or deleting posts through `IPostAppService` invalidates that cache within the current unit of work. The blog administration page also provides a **Clear Cache** action to users with the `Blogging.Blog.ClearCache` permission. + +If custom code changes posts directly through `IPostRepository`, publish the module's `PostChangedEvent` with the affected blog ID. The built-in event handler then removes that blog's cached list as part of the current unit of work. + +## Internals + +### Domain Layer + +The main aggregates are: + +* `Blog`: A named blog identified in public URLs by its short name. +* `Post`: Markdown content, cover image, URL, tags and read count for a blog. +* `Comment`: A comment or direct reply attached to a post. +* `Tag`: A normalized tag and its usage count within a blog. +* `BlogUser`: The module-local representation of an Identity user and Blogging profile. + +### Application Layer + +The public application-service contracts are `IBlogAppService`, `IPostAppService`, `ICommentAppService`, `ITagAppService` and `IFileAppService`. `IBlogManagementAppService` provides blog administration operations. + +The HTTP API uses the `Blogging` remote-service name for public operations and `BloggingAdmin` for administration operations. Add `BloggingHttpApiClientModule` or `BloggingAdminHttpApiClientModule` to a client application when these services run remotely. + +### Database Providers + +#### Common + +The module uses `Blogging` as its connection string name and falls back to `Default` when that connection is not configured. See the [Connection Strings](../framework/fundamentals/connection-strings.md) documentation. + +Tables and collections use the `Blg` prefix by default. Set `AbpBloggingDbProperties.DbTablePrefix` and, for providers that support schemas, `AbpBloggingDbProperties.DbSchema` before the database model is created: + +```csharp +AbpBloggingDbProperties.DbTablePrefix = "MyBlog"; +AbpBloggingDbProperties.DbSchema = "blogging"; +``` + +#### Entity Framework Core + +The Entity Framework Core provider uses these tables: + +* `BlgUsers` +* `BlgBlogs` +* `BlgPosts` +* `BlgComments` +* `BlgTags` +* `BlgPostTags` + +Call `ConfigureBlogging()` from your migration DbContext when integrating the module manually. + +#### MongoDB + +The MongoDB provider uses these collections: + +* `BlgUsers` +* `BlgBlogs` +* `BlgPosts` +* `BlgComments` +* `BlgTags` + +Post-tag links are stored with the post documents, so MongoDB does not use a separate `BlgPostTags` collection. + +## See Also + +* [CMS Kit: Blogging](cms-kit/blogging.md) +* [BLOB Storing](../framework/infrastructure/blob-storing) +* [User Lookup and Synchronization](identity/user-synchronization.md) diff --git a/docs/en/modules/chat.md b/docs/en/modules/chat.md index fa87a8eef6..f3d30691a7 100644 --- a/docs/en/modules/chat.md +++ b/docs/en/modules/chat.md @@ -36,7 +36,7 @@ If you modified your solution structure, adding module using ABP Suite might not In order to do that, add packages listed below to matching project on your solution. For example, ```Volo.Chat.Application``` package to your **{ProjectName}.Application.csproj** like below; -```json +```xml ``` @@ -49,7 +49,7 @@ After adding the package reference, open the module class of the project (eg: `{ )] ``` -> If you are using Blazor Web App, you need to add the `Volo.Chat.Blazor.WebAssembly` package to the **{ProjectName}.Blazor.Client.csproj** project and ad the `Volo.Chat.Blazor.Server` package to the **{ProjectName}.Blazor.csproj** project. +> If you are using Blazor Web App, you need to add the `Volo.Chat.Blazor.WebAssembly` package to the **{ProjectName}.Blazor.Client.csproj** project and add the `Volo.Chat.Blazor.Server` package to the **{ProjectName}.Blazor.csproj** project. The `Volo.Chat.SignalR` package must be added according to your project structure: @@ -107,18 +107,38 @@ You can visit [Chat module package list page](https://abp.io/packages?moduleName ## User interface -### Manage chat feature +### Chat feature and permissions -Chat module defines the chat feature, you need to enable the chat feature to use chat. +The `Chat.Enable` feature is disabled by default. Enable it for a tenant or edition before granting chat permissions. ![chat-feature](../images/chat-feature.png) +The module defines the following permissions: + +* `Chat.Messaging`: Allows a user to open the chat page and exchange messages. A message can only be sent if the target user also has this permission. +* `Chat.Searching`: A child permission of `Chat.Messaging`. It allows a user to start a conversation with a user who is not already in the conversation history. Without this permission, the user can continue existing conversations. +* `Chat.SettingManagement`: Allows a user to manage the chat settings. + ### Chat page This is the page that users send messages to each other. ![chat-page](../images/chat-page.png) +Message text is required. By default, it must contain between 1 and 4,096 characters. The conversation API returns the newest messages first; the built-in user interfaces reverse that result to display messages in chronological order. + +### Deleting messages and conversations + +The following settings control deletion behavior: + +| Setting | Default | Description | +| --- | --- | --- | +| `Volo.Chat.Messaging.DeletingMessages` | `Enabled` | `Enabled` allows deletion at any time, `Disabled` prevents deletion, and `EnabledWithDeletionPeriod` allows deletion only during the configured period after the message is created. | +| `Volo.Chat.Messaging.MessageDeletionPeriod` | `0` | Deletion period in seconds when message deletion is set to `EnabledWithDeletionPeriod`. | +| `Volo.Chat.Messaging.DeletingConversations` | `Enabled` | Enables conversation deletion. It is effective only when message deletion is set to `Enabled`. | + +Deleting a message removes the shared message and both users' message records. Deleting a conversation removes both users' conversation records and all messages between them. These operations are shared deletions, not a "hide for me" operation. Deletion notifications are sent through the real-time channel. + ### Chat icon on navigation bar An icon that shows unread message count of the user and leads to chat page when clicked is added to navigation menu. @@ -213,10 +233,11 @@ See the [connection strings](../framework/fundamentals/connection-strings.md) do #### Installation -In order to configure the application to use the chat module, you first need to import `provideChatConfig` from `@volo/abp.ng.chat/config` to root application confiuration. Then, you will need to append it to the `appConfig` array. +In order to configure the application to use the chat module, you first need to import `provideChatConfig` from `@volo/abp.ng.chat/config` to the root application configuration. Then, you will need to append it to the `providers` array. -```js +```ts // app.config.ts +import { ApplicationConfig } from '@angular/core'; import { provideChatConfig } from '@volo/abp.ng.chat/config'; export const appConfig: ApplicationConfig = { @@ -228,20 +249,26 @@ export const appConfig: ApplicationConfig = { ``` -The chat module should be imported and lazy-loaded in your routing array. It has a static `createRoutes` method for configuration. It is available for import from `@volo/abp.ng.chat`. +The chat module should be imported and lazy-loaded in your routing array. It exports a `createRoutes` function from `@volo/abp.ng.chat`. -```js +```ts // app.routes.ts +import { Routes } from '@angular/router'; + const APP_ROUTES: Routes = [ // ... { path: 'chat', loadChildren: () => - import('@volo/abp.ng.chat').then(c => c.createRoutes(/* options here */)), + import('@volo/abp.ng.chat').then(c => c.createRoutes()), }, ]; ``` +#### Component replacement + +The Angular chat route uses the `Chat.ChatComponent` replacement key and `ChatComponent` as its default component. See the [Angular component replacement documentation](../framework/ui/angular/component-replacement.md) if you need to replace the page. + #### Services / Models Chat module services and models are generated via `generate-proxy` command of the [ABP CLI](../cli). If you need the module's proxies, you can run the following command in the Angular project directory: @@ -254,7 +281,7 @@ abp generate-proxy --module chat The Chat module remote endpoint URLs can be configured in the environment files. -```js +```ts export const environment = { // other configurations apis: { @@ -274,12 +301,24 @@ The Chat module remote URL configurations shown above are optional. > If you don't set the `signalRUrl`, `Chat.url` will be used as fallback. If you don't set the `Chat` property, the `default.url` will be used as fallback. -### Blazor WebAssembly UI +### Blazor and MAUI UIs #### Remote Endpoint URL +The SignalR base URL can be configured with the option type that matches the UI package: + +| UI package | Option type | Fallback when `SignalrUrl` is not set | +| --- | --- | --- | +| Blazor Server | `ChatBlazorServerOptions` | The current application URL. | +| Blazor WebAssembly | `ChatBlazorWebAssemblyOptions` | `AbpRemoteServiceOptions.RemoteServices.Default.BaseUrl`. | +| Blazor MAUI | `ChatBlazorMauiBlazorOptions` | `AbpRemoteServiceOptions.RemoteServices.Default.BaseUrl`. | +| MudBlazor Server | `ChatBlazorMudBlazorServerOptions` | The current application URL. | +| MudBlazor WebAssembly | `ChatBlazorMudBlazorWebAssemblyOptions` | `AbpRemoteServiceOptions.RemoteServices.Default.BaseUrl`. | +| MudBlazor MAUI | `ChatBlazorMudBlazorMauiBlazorOptions` | `AbpRemoteServiceOptions.RemoteServices.Default.BaseUrl`. | -The Chat module remote endpoint URLs can be configured via the `ChatBlazorWebAssemblyOptions`. +The module appends the `/signalr-hubs/chat` path to the resolved base URL. + +For example, the Chat module remote endpoint URL can be configured for Blazor WebAssembly via the `ChatBlazorWebAssemblyOptions`: ```csharp Configure(options => @@ -303,4 +342,12 @@ Configure(options => ## Distributed Events -This module defines an event for messaging. It is published when a new message is sent from a user to another user, with an Event Transfer Object type of `ChatMessageEto`. See the [standard distributed events](../framework/infrastructure/event-bus/distributed) for more information about distributed events. +The module defines the following Event Transfer Object types for real-time operations: + +| Event Transfer Object | Operation | SignalR client method | +| --- | --- | --- | +| `ChatMessageEto` | A message is sent. | `ReceiveMessage` | +| `ChatDeletedMessageEto` | A message is deleted. | `DeleteMessage` | +| `ChatDeletedConversationEto` | A conversation is deleted. | `DeleteConversation` | + +The default application-layer implementation of `IRealTimeChatMessageSender` publishes these events to the distributed event bus. The `Volo.Chat.SignalR` package replaces that implementation in the SignalR host, handles the distributed events and sends them to the target user through the corresponding SignalR client method. See the [standard distributed events](../framework/infrastructure/event-bus/distributed) for more information about distributed events. diff --git a/docs/en/modules/cms-kit-pro/contact-form.md b/docs/en/modules/cms-kit-pro/contact-form.md index eafa4b80b3..005e85073c 100644 --- a/docs/en/modules/cms-kit-pro/contact-form.md +++ b/docs/en/modules/cms-kit-pro/contact-form.md @@ -13,25 +13,25 @@ CMS Kit provides a widget to create a contact form on your website. ## Enabling the Contact Management System -By default, CMS Kit features are disabled. Therefore, you need to enable the features you want, before starting to use it. You can use the [Global Feature](../framework/infrastructure/global-features.md) system to enable/disable CMS Kit features on development time. Alternatively, you can use the ABP's [Feature System](../framework/infrastructure/features.md) to disable a CMS Kit feature on runtime. +By default, CMS Kit features are disabled. Therefore, you need to enable the features you want before starting to use them. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable or disable CMS Kit features at development time. Alternatively, you can use ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature at runtime. -> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable/disable CMS Kit features on development time. +> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable or disable CMS Kit features at development time. ## Contact Widget The contact management system provides a contact form [widget](../../framework/ui/mvc-razor-pages/widgets.md) to create contact forms on the UI: ```csharp -@await Component.InvokeAsync(typeof(ContactViewComponent)) +@await Component.InvokeAsync(typeof(ContactViewComponent), new { }) ``` -Here, a screenshot from the widget: +Here is a screenshot of the widget: ![contact-form](../../images/cmskit-module-contact-form.png) ## Multiple Contact Widgets -The contact management system allows you to create multiple contact forms. You can define a named contact widget as below: +The contact management system allows you to create multiple contact forms with different receivers. You can define a named contact widget as shown below: ```csharp @await Component.InvokeAsync(typeof(ContactViewComponent), new @@ -40,7 +40,7 @@ The contact management system allows you to create multiple contact forms. You c }); ``` -Then, you need to configure the defined contact widgets in the `ConfigureServices` method of your module class: +Then, configure the receiver for each name in the `ConfigureServices` method of your module class: ```csharp Configure(options => @@ -50,29 +50,30 @@ Configure(options => }); ``` -Here, is a screenshot that shows multiple contact forms on a page: +The following screenshot shows multiple contact forms on a page: ![multiple-contact-forms](../../images/cmskit-module-multiple-contact-forms.png) +When the submitted `contactName` matches a configured entry, that entry's receiver is used. Otherwise, the module uses the receiver email address configured on the CMS settings page. The contact name is also prefixed to the email subject when it is not empty. ## Options -You can configure the `CmsKitContactOptions` to enable/disable recaptcha for contact form in the `ConfigureServices` method of your [module](../../framework/architecture/modularity/basics.md). +You can configure `CmsKitContactOptions` to enable or disable reCAPTCHA for the contact form in the `ConfigureServices` method of your [module](../../framework/architecture/modularity/basics.md). Example: ```csharp Configure(options => { - options.IsRecaptchaEnabled = true; //false by default + options.IsRecaptchaEnabled = true; }); ``` `CmsKitContactOptions` properties: -* `IsRecaptchaEnabled` (default: false): This flag enables or disables the reCaptcha for the contact form. You can set it as **true** if you want to use reCaptcha in your contact form. +* `IsRecaptchaEnabled` (default: `false`): Enables reCAPTCHA v3 validation for public contact submissions. -If you set **IsRecaptchaEnabled** as **true**, you also need to specify **SiteKey** and **SiteSecret** options for reCaptcha. To do that, add **CmsKit:Contact** section into your `appsettings.json` file: +If you set `IsRecaptchaEnabled` to `true`, also specify `SiteKey` and `SiteSecret` for reCAPTCHA. Add the `CmsKit:Contact` section to your `appsettings.json` file: ```json { @@ -85,9 +86,9 @@ If you set **IsRecaptchaEnabled** as **true**, you also need to specify **SiteKe } ``` -## Settings +## Settings -You can configure the receiver (email address) by using the CMS tab in the settings page. +You can configure the fallback receiver email address on the CMS tab of the settings page. This setting is tenant-aware and is used when the form has no matching named receiver. Its default value is `info@mycompanyname.com`; replace it with an address that belongs to your application before deploying to production. ![contact-settings](../../images/cmskit-module-contact-settings.png) diff --git a/docs/en/modules/cms-kit-pro/faq.md b/docs/en/modules/cms-kit-pro/faq.md index 23d90d9705..230b9578cc 100644 --- a/docs/en/modules/cms-kit-pro/faq.md +++ b/docs/en/modules/cms-kit-pro/faq.md @@ -9,15 +9,15 @@ > You must have an [ABP Team or a higher license](https://abp.io/pricing) to use CMS Kit Pro module's features. -The CMS kit provides a **FAQ** system to allow users to create, edit and delete FAQ's. Here is a screenshot of the FAQ widget: +CMS Kit Pro provides an **FAQ** system to organize questions into groups and sections and display them on public pages. Here is a screenshot of the FAQ widget: ![cmskit-module-faq-widget](../../images/cmskit-module-faq-widget.png) ## Enabling the FAQ System -By default, CMS Kit features are disabled. Therefore, you need to enable the features you want, before starting to use it. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable/disable CMS Kit features on development time. Alternatively, you can use the ABP Framework's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature on runtime. +By default, CMS Kit features are disabled. Therefore, you need to enable the features you want before starting to use them. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable or disable CMS Kit features at development time. Alternatively, you can use ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature at runtime. -> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable/disable CMS Kit features on development time. +> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable or disable CMS Kit features at development time. ## User Interface @@ -25,21 +25,21 @@ By default, CMS Kit features are disabled. Therefore, you need to enable the fea CMS Kit module admin side adds the following items to the main menu, under the **CMS** menu item: -**FAQ's**: FAQ management page. +**FAQs**: FAQ group, section and question management page. `CmsKitProAdminMenus` class has the constants for the menu item names. ### Pages -You can list, create, update and delete sections and their questions FAQ's on the admin side of your solution. +You can list, create, update and delete FAQ groups, sections and questions on the admin side of your solution. A group contains sections, and a section contains questions. ![faq-page](../../images/cmskit-module-faq-page.png) ![faq-edit-page](../../images/cmskit-module-faq-edit-page.png) ![faq-edit-question-page](../../images/cmskit-module-faq-edit-question-page.png) -## Faq Widget +## FAQ Widget -The FAQ system provides a FAQ [widget](../../framework/ui/mvc-razor-pages/widgets.md) for users to display FAQ's. You can place the widget on a page like below: +The FAQ system provides an FAQ [widget](../../framework/ui/mvc-razor-pages/widgets.md) for displaying FAQs. You can place the widget on a page as shown below: ```csharp @await Component.InvokeAsync( @@ -47,30 +47,20 @@ The FAQ system provides a FAQ [widget](../../framework/ui/mvc-razor-pages/widget new { groupName = "Community", - name = "Development" + sectionName = "Development" }) ``` `FaqViewComponent` parameters: -- `groupName` (optional): It allows to specify which FAQ group to show. If not specified, all groups will be shown. -- `sectionName` (optional): It is used to determine which section within the specified group will be shown. If not specified, all sections in the related group will be shown. -The FAQ system can also be used in combination with the [dynamic widget](../cms-kit/dynamic-widget.md) feature. - -## Options - -The FAQ system provides a mechanism to group sections by group name. For example, if you want to use the FAQ system for community and support page, you need to define two group names named Community and Support and add sections under these groups. So, before using the FAQ system, you need to define groups. For that, you can use `FaqOptions`. `FaqOptions` can be configured at the domain layer, in the `ConfigureServices` method of your [module]../../framework/architecture/modularity/basics.md). +- `groupName` (required): Specifies the FAQ group to show. Create this group on the FAQ administration page before rendering the widget. +- `sectionName` (optional): Specifies a section within the selected group. If it is not set, all sections in the group are shown. -```csharp -Configure(options => -{ - options.SetGroups(new[] { "General", "Community", "Support" }); -}); -``` +The FAQ system can also be used in combination with the [dynamic widget](../cms-kit/dynamic-widget.md) feature. -`FaqOptions` properties: +## FAQ Groups -- `Groups`: Dictionary of defined groups in the FAQ system. The `options.SetGroups` method is a shortcut to add a new groups to this dictionary. +FAQ groups are persisted data and are managed from the FAQ administration page. Create groups such as `Community` or `Support`, then assign each section to one of those groups. Group names must be unique. ## Internals @@ -82,10 +72,11 @@ This module follows the [Entity Best Practices & Conventions](../../framework/ar ##### FAQ -A FAQ represents a generated FAQ with its questions: +An FAQ represents a generated FAQ with its questions: - `FaqSection` (aggregate root): Represents the defined FAQ sections related to the FAQ in the system. - `FaqQuestion` (aggregate root): Represents the defined FAQ questions with section identifier related to the FAQ in the system. +- `FaqGroup` (aggregate root): Represents a named group that contains FAQ sections. #### Repositories @@ -95,6 +86,7 @@ The following special repositories are defined for these features: - `IFaqSectionRepository` - `IFaqQuestionRepository` +- `IFaqGroupRepository` #### Domain services @@ -108,7 +100,9 @@ This module follows the [Domain Services Best Practices & Conventions](../../fra - `FaqSectionAdminAppService` (implements `IFaqSectionAdminAppService`): Implements the use cases of FAQ section management for admin side. - `FaqQuestionAdminAppService` (implements `IFaqQuestionAdminAppService`): Implements the use cases of FAQ question management for admin side. -- `FaqSectionPublicAppService` (implements `IFaqSectionPublicAppService`): Implements the use cases of FAQ's for public websites. +- `FaqGroupAdminAppService` (implements `IFaqGroupAdminAppService`): Implements the use cases of FAQ group management for admin side. +- `FaqSectionPublicAppService` (implements `IFaqSectionPublicAppService`): Implements the use cases of FAQs for public websites. +- `FaqGroupPublicAppService` (implements `IFaqGroupPublicAppService`): Finds FAQ groups by name for public widgets. ### Database providers @@ -116,11 +110,11 @@ This module follows the [Domain Services Best Practices & Conventions](../../fra ##### Table / collection prefix & schema -All tables/collections use the `Cms` prefix by default. Set static properties on the `CmsKitDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). +All tables/collections use the `Cms` prefix by default. Set static properties on the `AbpCmsKitDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). ##### Connection string -This module uses `CmsKit` for the connection string name. If you don't define a connection string with this name, it fallbacks to the `Default` connection string. +This module uses `CmsKit` for the connection string name. If you don't define a connection string with this name, it falls back to the `Default` connection string. See the [connection strings](../../framework/fundamentals/connection-strings.md) documentation for details. @@ -130,6 +124,7 @@ See the [connection strings](../../framework/fundamentals/connection-strings.md) - CmsFaqSections - CmsFaqQuestions +- CmsFaqGroups #### MongoDB @@ -137,7 +132,4 @@ See the [connection strings](../../framework/fundamentals/connection-strings.md) - CmsFaqSections - CmsFaqQuestions - -## Entity Extensions - -Check the ["Entity Extensions" section of the CMS Kit Module documentation](index.md#entity-extensions) to see how to extend entities of the FAQ Feature of the CMS Kit Pro module. \ No newline at end of file +- CmsFaqGroups diff --git a/docs/en/modules/cms-kit-pro/index.md b/docs/en/modules/cms-kit-pro/index.md index 2aec352cab..13c5f416f7 100644 --- a/docs/en/modules/cms-kit-pro/index.md +++ b/docs/en/modules/cms-kit-pro/index.md @@ -11,7 +11,7 @@ This module extends the [open-source CMS Kit module](../cms-kit) and adds additional CMS (Content Management System) capabilities to your application. -> **This module is currently available for MVC / Razor Pages and Blazor UIs**. +The administration UI is available for MVC / Razor Pages, Angular and Blazor (Blazorise and MudBlazor). The public website widgets documented in the feature pages are MVC / Razor Pages components. The following features are provided by the open-source CMS Kit module: @@ -30,10 +30,10 @@ The following features are provided by the CMS Kit Pro version: * [**Newsletter**](newsletter.md) It allows users to subscribe to newsletters. * [**Contact form**](contact-form.md) It allows users to write messages to you. -* [**URL forwarding**](URL-forwarding.md) It allows the creation of URLs that point to other pages or external websites. -* [**Poll**](poll.md) It allows to create simple polls for your visitors. +* [**URL forwarding**](url-forwarding.md) It allows the creation of URLs that point to other pages or external websites. +* [**Poll**](poll.md) Allows you to create simple polls for your visitors. * [**Page Feedback**](page-feedback.md) It allows users to send feedback for your pages. -* [**Faq**](faq.md) system to create dynamic FAQ. +* [**FAQ**](faq.md) system to create dynamic FAQs. Click on a feature to understand and learn how to use it. See [the module description page](https://abp.io/modules/Volo.CmsKit.Pro) for an overview of the module features. @@ -41,7 +41,7 @@ Click on a feature to understand and learn how to use it. See [the module descri ### New Solutions -CMS Kit Pro is pre-installed in [the startup templates](../../solution-templates) if you create the solution with the **public website** option. If you are using ABP CLI, you should specify the the `--with-public-website` option as shown below: +CMS Kit Pro is pre-installed in [the startup templates](../../solution-templates) if you create the solution with the **public website** option. If you are using ABP CLI, specify the `--with-public-website` option as shown below: ```bash abp new Acme.BookStore --with-public-website @@ -49,7 +49,7 @@ abp new Acme.BookStore --with-public-website ### Existing Solutions -If you want to add the CMS kit to your existing solution, you can use the ABP CLI `add-module` command: +If you want to add CMS Kit Pro to your existing solution, you can use the ABP CLI `add-module` command: ```bash abp add-module Volo.CmsKit.Pro @@ -77,11 +77,47 @@ Alternatively, you can enable features individually, like `cmsKit.Comments.Enabl > If you are using Entity Framework Core, remember to add a new migration and update your database. +### Angular Administration UI + +The Angular package publishes the administration routes and their menu configuration in separate entry points. Register the configuration provider in your application configuration: + +```typescript +import { ApplicationConfig } from '@angular/core'; +import { provideCmsKitAdminConfig } from '@abp/ng.cms-kit/admin/config'; +import { provideCmsKitProAdminConfig } from '@volo/abp.ng.cms-kit-pro/admin/config'; + +export const appConfig: ApplicationConfig = { + providers: [provideCmsKitAdminConfig(), provideCmsKitProAdminConfig()], +}; +``` + +Then combine the open-source and Pro administration routes under the `cms` path: + +```typescript +import { Routes } from '@angular/router'; + +export const appRoutes: Routes = [ + { + path: 'cms', + loadChildren: () => + Promise.all([ + import('@volo/abp.ng.cms-kit-pro/admin').then(cmsKitPro => + cmsKitPro.createRoutes(), + ), + import('@abp/ng.cms-kit/admin').then(cmsKit => cmsKit.createRoutes()), + ]).then(([cmsKitProRoutes, cmsKitRoutes]) => [ + ...cmsKitProRoutes, + ...cmsKitRoutes, + ]), + }, +]; +``` + ## Entity Extensions -[Module entity extension](../../framework/architecture/modularity/extending/module-entity-extensions.md) system is a **high-level** extension system that allows you to **define new properties** for existing entities of the dependent modules. It automatically **adds properties to the entity**, **database**, **HTTP API and user interface** in a single point. +The [module entity extension](../../framework/architecture/modularity/extending/module-entity-extensions.md) system allows you to define new properties for supported entities of a dependent module from a single configuration point. -To extend entities of the CMS Kit Pro module, open your `YourProjectNameModuleExtensionConfigurator` class inside of your `DomainShared` project and change the `ConfigureExtraProperties` method like shown below. +To extend entities of the CMS Kit Pro module, open your `YourProjectNameModuleExtensionConfigurator` class in the `Domain.Shared` project and change the `ConfigureExtraProperties` method as shown below. ```csharp public static void ConfigureExtraProperties() @@ -91,49 +127,44 @@ public static void ConfigureExtraProperties() ObjectExtensionManager.Instance.Modules() .ConfigureCmsKitPro(cmsKitPro => { - cmsKitPro.ConfigurePoll(plan => // extend the Poll entity + cmsKitPro.ConfigurePoll(poll => { - plan.AddOrUpdateProperty( //property type: string - "PollDescription", //property name - property => { - //validation rules - property.Attributes.Add(new RequiredAttribute()); //adds required attribute to the defined property - - //...other configurations for this property - } + poll.AddOrUpdateProperty( + "PollDescription", + property => + { + property.Attributes.Add(new RequiredAttribute()); + } ); - }); + }); - cmsKitPro.ConfigureNewsletterRecord(newsletterRecord => // extend the NewsletterRecord entity + cmsKitPro.ConfigureNewsletterRecord(newsletterRecord => { - newsletterRecord.AddOrUpdateProperty( //property type: string - "NewsletterRecordDescription", //property name - property => { - //validation rules - property.Attributes.Add(new RequiredAttribute()); //adds required attribute to the defined property - property.Attributes.Add( - new StringLengthAttribute(MyConsts.MaximumDescriptionLength) { - MinimumLength = MyConsts.MinimumDescriptionLength - } - ); - - //...other configurations for this property - } + newsletterRecord.AddOrUpdateProperty( + "NewsletterRecordDescription", + property => + { + property.Attributes.Add(new RequiredAttribute()); + property.Attributes.Add( + new StringLengthAttribute(MyConsts.MaximumDescriptionLength) + { + MinimumLength = MyConsts.MinimumDescriptionLength + } + ); + } ); - }); + }); }); }); } ``` - + * `ConfigureCmsKitPro` method is used to configure the entities of the CMS Kit Pro module. -* `cmsKit.ConfigurePoll(...)` is used to configure the **Poll** entity of the CMS Kit Pro module. You can add or update the extra properties of the **Poll** entity. +* `cmsKitPro.ConfigurePoll(...)` is used to configure the **Poll** entity of the CMS Kit Pro module. You can add or update the extra properties of the **Poll** entity. -* `cmsKit.ConfigureNewsletterRecord(...)` is used to configure the **NewsletterRecord** entity of the CMS Kit Pro module. You can add or update the extra properties of the **NewsletterRecord** entity. +* `cmsKitPro.ConfigureNewsletterRecord(...)` is used to configure the **NewsletterRecord** entity of the CMS Kit Pro module. You can add or update the extra properties of the **NewsletterRecord** entity. -* You can also set some validation rules for the property that you defined. In the above sample, `RequiredAttribute` and `StringLengthAttribute` were added for the property named **"NewsletterRecord"**. +* You can also set validation rules for the properties you define. In the example above, `RequiredAttribute` and `StringLengthAttribute` are added to the **NewsletterRecordDescription** property. -* When you define the new property, it will automatically add to **Entity**, **HTTP API** and **UI** for you. - * Once you define a property, it appears in the create and update forms of the related entity. - * New properties also appear in the data table on the related page. +* Extra properties are added to the entity and HTTP API. UI integration is entity- and UI-specific: Poll has create and update forms, while Newsletter records are read-only and expose their extra properties through the application DTOs. diff --git a/docs/en/modules/cms-kit-pro/newsletter.md b/docs/en/modules/cms-kit-pro/newsletter.md index 3097011658..6f2837fecd 100644 --- a/docs/en/modules/cms-kit-pro/newsletter.md +++ b/docs/en/modules/cms-kit-pro/newsletter.md @@ -9,15 +9,15 @@ > You must have an [ABP Team or a higher license](https://abp.io/pricing) to use CMS Kit Pro module's features. -CMS Kit provides a **newsletter** system to allow users to subscribe to newsletters. Here a screenshot of the newsletter subscription widget: +CMS Kit provides a **newsletter** system that allows users to subscribe to newsletters. Here is a screenshot of the newsletter subscription widget: ![cmskit-module-newsletter-widget](../../images/cmskit-module-newsletter-widget.png) ## Enabling the Newsletter System -By default, CMS Kit features are disabled. Therefore, you need to enable the features you want, before starting to use it. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable/disable CMS Kit features on development time. Alternatively, you can use the ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature on runtime. +By default, CMS Kit features are disabled. Therefore, you need to enable the features you want before starting to use them. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable or disable CMS Kit features at development time. Alternatively, you can use ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature at runtime. -> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable/disable CMS Kit features on development time. +> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable or disable CMS Kit features at development time. ## User Interface @@ -29,20 +29,19 @@ By default, CMS Kit features are disabled. Therefore, you need to enable the fea #### Newsletters -You can then view the subscribers and export the list as CSV file, in the admin side of your solution: +You can view subscribers, edit their preferences, import subscriptions from a CSV file and export the filtered list as a CSV file on the admin side of your solution: ![newsletter-page](../../images/cmskit-module-newsletter-page.png) #### Email Preferences Management -You (and users of your public web application) can manage your email preferences and unsubscribe from newsletters by visiting the **Email Preferences page** (*/cms/newsletter/email-preferences*), in the public side of your solution: +Users can manage their email preferences and unsubscribe from newsletters on the public **Email Preferences** page at `/cms/newsletter/email-preferences`: ![manage-email-preferences](../../images/manage-email-preferences.png) ## The Newsletter Subscription Widget -The newsletter subscription system provides a newsletter subscription [widget](../../framework/ui/mvc-razor-pages/widgets.md) to allow users to subscribe to a newsletter. -You can simply place the widget on a page like below: +The newsletter subscription system provides a newsletter subscription [widget](../../framework/ui/mvc-razor-pages/widgets.md) to allow users to subscribe to a newsletter. You can place the widget on a page as shown below: ```csharp @await Component.InvokeAsync( @@ -52,10 +51,12 @@ You can simply place the widget on a page like below: preference = "TechNewsletter", source = "Footer", requestAdditionalPreferencesLater = false - }) +}) ``` -When you're adding the newsletter component, you can the specify `source` parameter to see where users subscribe to newsletters. See the options to understand the preferences. +The `preference` and `source` parameters are required. `preference` must match a registered preference. Use `source` to distinguish where subscriptions originate, such as `Footer` or `Blog`. If `requestAdditionalPreferencesLater` is `true`, the widget requests the additional subscriptions in the success dialog instead of the initial form. You can also pass `privacyPolicyConfirmation` to override the preference's configured privacy-policy text for that widget instance. + +New subscriptions require email confirmation. Once confirmed, users can manage all registered preferences from `/cms/newsletter/email-preferences`; disabling every preference removes the subscription record. ## Options @@ -64,25 +65,67 @@ Before using the newsletter system, you need to define the preferences. You can **Example:** ```csharp -options.AddPreference("TechNewsletter", - new NewsletterPreferenceDefinition( - "Daily Technology Newsletter", - privacyPolicyConfirmation: "I accept the Privacy Policy.") - ) -); +Configure(options => +{ + options.AddPreference( + "ProductUpdates", + new NewsletterPreferenceDefinition( + new LocalizableString( + typeof(MyProjectResource), + "Newsletter:ProductUpdates") + ) + ); + + options.AddPreference( + "TechNewsletter", + new NewsletterPreferenceDefinition( + new LocalizableString( + typeof(MyProjectResource), + "Newsletter:TechNewsletter"), + definition: new LocalizableString( + typeof(MyProjectResource), + "Newsletter:TechNewsletterDescription"), + privacyPolicyConfirmation: new LocalizableString( + typeof(MyProjectResource), + "Newsletter:PrivacyPolicyConfirmation"), + additionalPreferences: new List { "ProductUpdates" } + ) + ); +}); ``` `NewsletterOptions` properties: -- `Preferences`: List of defined newsletter preferences (`NewsletterPreferenceDefinition`) in the newsletter system. +- `Preferences`: Dictionary of registered preference names and their `NewsletterPreferenceDefinition` values. - `WidgetViewPath`: Default view path for all newsletter preferences. `NewsletterPreferenceDefinition` properties: - `Preference`: Name of the preference. We will use this field while displaying the newsletter component on the UI. -- `PrivacyPolicyConfirmation`: Privacy policy confirmation text shown in the newsletter subscription widget. -- `AdditionalPreferences`: Additional preference list that will show up after a user subscribes to the newsletter. -- `WidgetPath`: If you want to use a different newsletter widget instead of the default widget, you can specify the newsletter widget path using this field. +- `DisplayPreference`: Localizable display name of the preference. +- `Definition`: Optional localizable description shown on the email preferences page. +- `PrivacyPolicyConfirmation`: Privacy policy confirmation text for the newsletter subscription widget. The preference-level value currently reaches the widget only when the selected definition has a non-empty `AdditionalPreferences` list; otherwise the service returns before localizing this value. Pass `privacyPolicyConfirmation` when invoking the widget if you need an override that is independent of that list. +- `AdditionalPreferences`: Names of other registered preferences that participate in the additional-preference flow. +- `WidgetViewPath`: Optional Razor view path for this preference. It overrides the default `NewsletterOptions.WidgetViewPath`. + +The widget uses `~/Pages/Public/Shared/Components/Newsletter/Default.cshtml` when neither view-path option is set. + +The current implementation first checks whether the selected preference has a non-empty `AdditionalPreferences` list. If it does, the service collects registered preference names referenced by the `AdditionalPreferences` lists of all registered definitions, excludes the selected preference and removes duplicates. If the selected preference has no additional preferences, the widget does not offer any. Keep this global collection behavior in mind when multiple definitions reference different additional preferences. + +### Email Preferences Page Options + +Use `NewsletterPreferencesManagementOptions` to set the source recorded for changes made on the email preferences page and an optional privacy-policy confirmation message: + +```csharp +Configure(options => +{ + options.Source = "EmailPreferences"; + options.PrivacyPolicyConfirmation = new LocalizableString( + typeof(MyProjectResource), + "Newsletter:PrivacyPolicyConfirmation" + ); +}); +``` ## Internals @@ -94,7 +137,7 @@ This module follows the [Entity Best Practices & Conventions](../../framework/ar ##### NewsletterRecord -A newsletter record represents a newsletter subscription for a specific email address +A newsletter record represents a newsletter subscription for a specific email address. - `NewsletterRecord` (aggregate root): Represents a newsletter subscription in the system. @@ -127,11 +170,11 @@ This module follows the [Domain Services Best Practices & Conventions](../../fra ##### Table / collection prefix & schema -All tables/collections use the `Cms` prefix by default. Set static properties on the `CmsKitDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). +All tables/collections use the `Cms` prefix by default. Set static properties on the `AbpCmsKitDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). ##### Connection string -This module uses `CmsKit` for the connection string name. If you don't define a connection string with this name, it fallbacks to the `Default` connection string. +This module uses `CmsKit` for the connection string name. If you don't define a connection string with this name, it falls back to the `Default` connection string. See the [connection strings](../../framework/fundamentals/connection-strings.md) documentation for details. @@ -150,4 +193,4 @@ See the [connection strings](../../framework/fundamentals/connection-strings.md) ## Entity Extensions -Check the ["Entity Extensions" section of the CMS Kit Module documentation](index.md#entity-extensions) to see how to extend entities of the Newsletter Feature of the CMS Kit Pro module. \ No newline at end of file +Check the ["Entity Extensions" section of the CMS Kit Module documentation](index.md#entity-extensions) to see how to extend entities of the Newsletter Feature of the CMS Kit Pro module. diff --git a/docs/en/modules/cms-kit-pro/page-feedback.md b/docs/en/modules/cms-kit-pro/page-feedback.md index 559ed98a3a..495f7b6669 100644 --- a/docs/en/modules/cms-kit-pro/page-feedback.md +++ b/docs/en/modules/cms-kit-pro/page-feedback.md @@ -15,9 +15,9 @@ The CMS Kit Pro module provides a comprehensive **Page Feedback** system that en ## Enabling the Page Feedback System -All CMS Kit features are disabled bu default. Therefore, you need to enable the features you want before starting to use it. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable/disable the CMS Kit features on development time. Alternatively, you can use the ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature on runtime. +All CMS Kit features are disabled by default. Therefore, you need to enable the features you want before starting to use them. You can use the [Global Feature](../../framework/infrastructure/global-features.md) system to enable or disable CMS Kit features at development time. Alternatively, you can use ABP's [Feature System](../../framework/infrastructure/features.md) to disable a CMS Kit feature at runtime. -> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable/disable CMS Kit features on development time. +> Check the ["How to Install" section of the CMS Kit Module documentation](index.md#how-to-install) to see how to enable or disable CMS Kit features at development time. ## User Interface @@ -27,13 +27,13 @@ The CMS Kit module admin side adds the following items to the main menu, under t **Page Feedbacks**: Page feedback management page. -The `CmsKitProAdminMenus` class has the constants for the menu items names. +The `CmsKitProAdminMenus` class defines the menu item name constants. ### Pages #### Page Feedbacks -You can list, view, update and delete page feedbacks in the admin side of your solution, and you can also set the email addresses to send notifications. +You can list, view, update and delete page feedback from the administration interface. You can also configure the email addresses that receive notifications. ![page-feedback-page](../../images/cmskit-module-page-feedback-page.png) ![page-feedback-view-page](../../images/cmskit-module-page-feedback-view-page.png) @@ -42,7 +42,16 @@ You can list, view, update and delete page feedbacks in the admin side of your s ## Page Feedback Widget -The page feedback system provides a page feedback [widget](../../framework/ui/mvc-razor-pages/widgets.md) for users to send feedback about the current page. You can place the widget on a page like the below: +The page feedback system accepts only registered entity types. Register the entity type in the domain layer before rendering its widget: + +```csharp +Configure(options => +{ + options.EntityTypes.Add(new PageFeedbackEntityTypeDefinition("Page")); +}); +``` + +You can then place the page feedback [widget](../../framework/ui/mvc-razor-pages/widgets.md) on a page: ```csharp @(await Component.InvokeAsync(typeof(PageFeedbackViewComponent), new PageFeedbackViewDto @@ -59,8 +68,12 @@ The page feedback system provides a page feedback [widget](../../framework/ui/mv - `YesButtonText`: Yes button text. Used to change the default text of the yes button. Default value is `Yes`. +- `VeryHelpfulText`: Description shown with the positive feedback choice. + - `NoButtonText`: No button text. Used to change the default text of the no button. Default value is `No`. +- `NeedsImprovementText`: Description shown with the negative feedback choice. + - `UserNotePlaceholder`: User note placeholder. Used to change the default placeholder of the user note input. - `SubmitButtonText`: Submit button text. Used to change the default text of the submit button. Default value is `Submit`. @@ -77,7 +90,7 @@ The page feedback system provides a page feedback [widget](../../framework/ui/mv ### Page Feedback Modal Widget -The page feedback system provides a page feedback modal [widget](../../framework/ui/mvc-razor-pages/widgets.md) for users to send feedback about the current page. You can place the widget on a page like the below: +The page feedback system provides a page feedback modal [widget](../../framework/ui/mvc-razor-pages/widgets.md) for users to send feedback about the current page. You can place the widget on a page as shown below: ```html @@ -96,36 +189,124 @@ There are 2 types of pages that are supported by default. You can define a pre-p ``` ```csharp + using System; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Options; + using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + using Volo.Payment; + using Volo.Payment.Requests; + public class PreCheckoutModel : AbpPageModel { - [BindProperty] public Guid PaymentRequestId { get; set; } + private readonly IOptions _paymentWebOptions; + private readonly IPaymentRequestAppService _paymentRequestAppService; + + [BindProperty] + public Guid PaymentRequestId { get; set; } + + public PreCheckoutModel( + IOptions paymentWebOptions, + IPaymentRequestAppService paymentRequestAppService) + { + _paymentWebOptions = paymentWebOptions; + _paymentRequestAppService = paymentRequestAppService; + } public virtual ActionResult OnGet() { - // GET operation is not supported here. All the selected gateway requests will be sent as POST. return BadRequest(); } - public virtual async Task OnPostAsync() + public virtual IActionResult OnPost() { - // You can get the payment request from the database by using `PaymentRequestId` and render something on the UI side. + return Page(); } - public virtual async Task OnPostContinueToCheckout() + public virtual async Task OnPostContinueToCheckoutAsync() { - return Redirect("the-actual-checkout-link-of-gateway"); + await _paymentWebOptions.SetAsync(); + var rootUrl = _paymentWebOptions.Value.RootUrl.TrimEnd('/'); + + var result = await _paymentRequestAppService.StartAsync( + "MyGateway", + new PaymentRequestStartDto + { + PaymentRequestId = PaymentRequestId, + ReturnUrl = rootUrl + "/MyGateway/PostCheckout", + CancelUrl = rootUrl + }); + + return Redirect(result.CheckoutLink); } } ``` -- Create **PostCheckout.cshtml** and **PostCheckout.cshtml.cs** + The gateway selection page preserves the POST method when it redirects to the pre-payment URL. `OnPost` handles that initial request and displays the page. The `ContinueToCheckout` handler starts the payment only after the user submits the pre-payment form. + +- Create **Pages/MyGateway/PostCheckout.cshtml** and **Pages/MyGateway/PostCheckout.cshtml.cs**. ```html + @page "/MyGateway/PostCheckout" @model PostCheckoutModel

Operation Done

``` + ```csharp + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc; + using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + using Volo.Payment.Requests; + + [IgnoreAntiforgeryToken] + public class PostCheckoutModel : AbpPageModel + { + private readonly IPaymentRequestAppService _paymentRequestAppService; + + public PostCheckoutModel( + IPaymentRequestAppService paymentRequestAppService) + { + _paymentRequestAppService = paymentRequestAppService; + } + + public virtual async Task OnGetAsync() + { + var parameters = Request.Query.ToDictionary( + item => item.Key, + item => item.Value.ToString()); + + return await CompleteAsync(parameters); + } + + public virtual async Task OnPostAsync() + { + var form = await Request.ReadFormAsync(); + var parameters = form.ToDictionary( + item => item.Key, + item => item.Value.ToString()); + + return await CompleteAsync(parameters); + } + + private async Task CompleteAsync( + Dictionary parameters) + { + var paymentRequest = await _paymentRequestAppService.CompleteAsync( + "MyGateway", + parameters); + + return paymentRequest.State == PaymentRequestState.Completed + ? Page() + : BadRequest("The payment was not completed."); + } + } + ``` + + Keep only the callback method used by your provider. If the provider sends an external POST request to the Razor Page, keep `[IgnoreAntiforgeryToken]` and rely on the gateway's provider-signature validation instead of an antiforgery token. Redirect to a success page only after `CompleteAsync` returns the `Completed` state. + - Configure your pages using `PaymentWebOptions` in **Web** layer of your project. ```csharp diff --git a/docs/en/modules/payment.md b/docs/en/modules/payment.md index 487e0dc64d..193580dac2 100644 --- a/docs/en/modules/payment.md +++ b/docs/en/modules/payment.md @@ -11,7 +11,8 @@ Payment module implements payment gateway integration of an application. It provides one time payment and recurring payment options. -* Supports [Stripe](https://stripe.com/), [PayPal](https://www.paypal.com/), [2Checkout](https://www.2checkout.com/), [PayU](https://corporate.payu.com/), [Iyzico](https://www.iyzico.com/en) and [Alipay](https://global.alipay.com/) payment gateways. +* Supports [Stripe](https://stripe.com/), [PayPal](https://www.paypal.com/), [2Checkout](https://www.2checkout.com/), [PayU](https://corporate.payu.com/), [Iyzico](https://www.iyzico.com/en), and [Alipay](https://global.alipay.com/) payment gateways. +* All listed gateways support one-time payments. Stripe also supports subscriptions. See [the module description page](https://abp.io/modules/Volo.Payment) for an overview of the module features. @@ -22,7 +23,7 @@ The Payment module is not installed in [the startup templates](../solution-templ ### Using ABP CLI -ABP CLI allows adding a module to a solution using ```add-module``` command. You can check its [documentation](../cli#add-module) for more information. So, payment module can be added using the command below; +Use the ABP CLI `add-module` command to add the Payment module to an existing solution: ```bash abp add-module Volo.Payment @@ -32,9 +33,9 @@ abp add-module Volo.Payment If you modified your solution structure, adding a module using ABP CLI might not work for you. In such cases, the Payment module can be added to a solution manually. -In order to do that, add packages listed below to matching project on your solution. For example, ```Volo.Payment.Application``` package to your **{ProjectName}.Application.csproj** like below; +In order to do that, add packages listed below to matching project on your solution. For example, `Volo.Payment.Application` package to your **{ProjectName}.Application.csproj** like below; -```json +```xml ``` @@ -53,7 +54,7 @@ After adding the package reference, open the module class of the project (eg: `{ ### Supported Gateway Packages -In order to use a Payment Gateway, you need to add related NuGet packages to your related project as explained in Manual Installation section above and add ```DependsOn``` to your related module. For example, if you don't want to use PayU, you don't have to use its NuGet packages. +In order to use a Payment Gateway, you need to add related NuGet packages to your related project as explained in Manual Installation section above and add `DependsOn` to your related module. For example, if you don't want to use PayU, you don't have to use its NuGet packages. After adding packages of a payment gateway to your application, you also need to configure global payment module options and options for the payment modules you have added. See the Options section below. @@ -75,35 +76,33 @@ The Payment module provides both **public pages** (for payment processing) and * ### MVC / Razor Pages UI -For MVC/Razor Pages applications, the `abp add-module Volo.Payment` command automatically adds the required packages (`Volo.Payment.Web` and gateway-specific Web packages) and the necessary `DependsOn` statements to your module. The only thing you need to do is configure `PaymentWebOptions` as explained in the [PaymentWebOptions](#paymentweboptions) section. +For MVC/Razor Pages applications, add `Volo.Payment.Web` and the Web package for each gateway you want to use, then add the corresponding module dependencies. Configure `PaymentWebOptions` as explained in the [PaymentWebOptions](#paymentweboptions) section. ### Blazor UI -For Blazor applications, the `abp add-module Volo.Payment` command automatically adds the required packages (`Volo.Payment.Blazor.Server` or `Volo.Payment.Blazor.WebAssembly` and gateway-specific Blazor packages) and the necessary `DependsOn` statements to your module. The only thing you need to do is configure `PaymentBlazorOptions` as explained below. +For Blazor applications, add the core Blazor package and the packages for the gateways you want to use, then add the corresponding module dependencies. Configure `PaymentBlazorOptions` as explained below. #### Installation -> **Note:** If you used the `abp add-module Volo.Payment` command to install the Payment module, the following packages and module dependencies are automatically added to your project. You can skip to the [Gateway-Specific Blazor Packages](#gateway-specific-blazor-packages) section. The information below is provided for reference or manual installation scenarios. - To use the Payment module's public pages in a Blazor application, you need to install the core Blazor packages and the gateway-specific Blazor packages for each payment gateway you want to support. ##### Core Blazor Packages For **Blazor Server** applications, add the following package to your **{ProjectName}.Blazor.Server.csproj** (or **{ProjectName}.Blazor.csproj** for Blazor Web App): -```json +```xml ``` For **Blazor WebAssembly** applications, add the following package to your **{ProjectName}.Blazor.csproj** (or **{ProjectName}.Blazor.Client.csproj** for Blazor Web App): -```json +```xml ``` -#### Gateway-Specific Blazor Packages +##### Gateway-Specific Blazor Packages -Each payment gateway requires its own Blazor package. Add the packages for the gateways you want to support: +Each supported Blazor gateway integration requires its own package. Add the packages for the integrations you want to use: **Stripe:** - Blazor Server: `Volo.Payment.Stripe.Blazor.Server` @@ -114,8 +113,8 @@ Each payment gateway requires its own Blazor package. Add the packages for the g - Blazor WebAssembly: `Volo.Payment.PayPal.Blazor.WebAssembly` **PayU:** -- Blazor Server: `Volo.Payment.PayU.Blazor.Server` -- Blazor WebAssembly: `Volo.Payment.PayU.Blazor.WebAssembly` +- Blazor Server: `Volo.Payment.Payu.Blazor.Server` +- Blazor WebAssembly: `Volo.Payment.Payu.Blazor.WebAssembly` **Iyzico:** - Blazor Server: `Volo.Payment.Iyzico.Blazor.Server` @@ -177,55 +176,32 @@ Configure(options => }); ``` -You can also configure these options in your `appsettings.json` file: +##### Gateway-Specific Blazor Options -```json -{ - "Payment": { - "Blazor": { - "RootUrl": "https://localhost:44300", - "CallbackUrl": "https://localhost:44300/PaymentSucceed", - "GatewaySelectionCheckoutButtonStyle": "btn btn-primary" - } - } -} -``` +Each supported Blazor gateway integration has its own options for customizing the UI. The gateway modules bind them from the following configuration sections: -##### Gateway-Specific Blazor Options +| Gateway | Blazor configuration section | +| --- | --- | +| PayU | `Payment:PayuBlazor` | +| PayPal | `Payment:PayPalBlazor` | +| Iyzico | `Payment:IyzicoBlazor` | +| Alipay | `Payment:AlipayBlazor` | +| Stripe | `Payment:Stripe` | +| TwoCheckout | `Payment:TwoCheckout` | -Each payment gateway has its own Blazor options for customizing the UI. These options can be configured in `appsettings.json`: +For example: ```json { "Payment": { - "Blazor": { - "Payu": { - "PrePaymentCheckoutButtonStyle": "btn btn-success", - "Recommended": true, - "ExtraInfos": ["Fast checkout", "Secure payment"] - }, - "TwoCheckout": { - "Recommended": false, - "ExtraInfos": ["International payments"] - }, - "PayPal": { - "Recommended": true, - "ExtraInfos": ["Pay with PayPal balance", "Buyer protection"] - }, - "Stripe": { - "Recommended": true, - "ExtraInfos": ["Credit/Debit cards", "Apple Pay", "Google Pay"] - }, - "Iyzico": { - "PrePaymentCheckoutButtonStyle": "btn btn-primary", - "Recommended": false, - "ExtraInfos": ["Turkish payment gateway"] - }, - "Alipay": { - "PrePaymentCheckoutButtonStyle": "btn btn-info", - "Recommended": false, - "ExtraInfos": ["Chinese payment gateway", "CNY only"] - } + "PayuBlazor": { + "PrePaymentCheckoutButtonStyle": "btn btn-success", + "Recommended": true, + "ExtraInfos": ["Fast checkout", "Secure payment"] + }, + "Stripe": { + "Recommended": true, + "ExtraInfos": ["Credit/Debit cards", "Apple Pay", "Google Pay"] } } } @@ -292,7 +268,7 @@ var paymentRequest = await PaymentRequestAppService.CreateAsync( TotalPrice = 60 } }, - ExtraProperties = new ExtraPropertyDictionary + ExtraProperties = { // For Iyzico - Customer information { "Name", "John" }, @@ -302,7 +278,7 @@ var paymentRequest = await PaymentRequestAppService.CreateAsync( { "City", "Istanbul" }, { "Country", "Turkey" }, { "ZipCode", "34000" }, - + // For PayU - Customer information { "BuyerName", "John" }, { "BuyerSurname", "Doe" }, @@ -313,41 +289,51 @@ var paymentRequest = await PaymentRequestAppService.CreateAsync( #### Handling the Callback (Optional) -When a user completes a payment on the external payment gateway, the following flow occurs: +When a payment provider redirects the user back to the application, the following flow occurs: 1. The user is redirected to the **PostPayment page** (handled internally by the payment module) -2. The PostPayment page validates the payment with the gateway and updates the payment request status to **Completed** +2. The PostPayment page validates the provider response and updates the payment request state 3. If a `CallbackUrl` is configured in `PaymentBlazorOptions`, the user is then redirected to that URL with the `paymentRequestId` as a query parameter -Create a page to handle this callback and perform any application-specific actions: +The callback URL is a browser navigation target, not proof that the payment succeeded. Query the payment request and check its state before showing a result: ```csharp @page "/PaymentSucceed" -@using Microsoft.AspNetCore.WebUtilities +@using Microsoft.AspNetCore.Components +@using Volo.Payment.Requests +@inject IPaymentRequestAppService PaymentRequestAppService -

Payment Successful!

-

Thank you for your purchase.

-

Payment Request ID: @PaymentRequestId

+@if (PaymentRequest is null) +{ +

Payment request not found.

+} +else if (PaymentRequest.State == PaymentRequestState.Completed) +{ +

Payment Successful!

+

Thank you for your purchase.

+} +else +{ +

The payment was not completed.

+} @code { [Parameter] [SupplyParameterFromQuery] public Guid? PaymentRequestId { get; set; } - - protected override async Task OnInitializedAsync() + + private PaymentRequestWithDetailsDto PaymentRequest { get; set; } + + protected override async Task OnParametersSetAsync() { - if (PaymentRequestId.HasValue) - { - // The payment is already completed at this point. - // Perform application-specific actions here: - // e.g., activate subscription, send confirmation email, - // update order status, grant access to purchased content, etc. - } + PaymentRequest = PaymentRequestId.HasValue + ? await PaymentRequestAppService.GetAsync(PaymentRequestId.Value) + : null; } } ``` -> **Note:** By the time the user reaches your callback page, the payment request status has already been set to **Completed** by the PostPayment page. Your callback page is for performing additional application-specific logic. It is also your responsibility to handle if a payment request is used more than once. If you have already delivered your product for a given `PaymentRequestId`, you should not deliver it again when the callback URL is visited a second time. +Keep the callback page limited to displaying the current result because users can revisit a callback URL. `PaymentRequestCompletedEto` is published when `IPaymentRequestAppService.CompleteAsync` completes a request, so an idempotent handler can fulfill orders for flows that finish through that application service. It isn't emitted for every direct request-state update; for example, the built-in Stripe webhook can complete a request without publishing this event. If your gateway can complete payments only through a webhook, add an application-owned, idempotent fulfillment or reconciliation path for that trusted webhook flow and verify the persisted payment-request state before granting access. ### Angular UI @@ -355,38 +341,61 @@ For Angular applications, you need to read and apply the steps explained in the #### Configurations -In order to configure the application to use the payment module, you first need to import `PaymentAdminConfigModule` from `@volo/abp.ng.payment/admin/config` to the root configuration. `PaymentAdminConfigModule` has a static `forRoot` method which you should call for a proper configuration: +Add `providePaymentAdminConfig` from `@volo/abp.ng.payment/admin/config` to the root application configuration. The same example shows the optional remote endpoint entries when the public and admin APIs are hosted separately: -```js +```typescript // app.config.ts -import { ApplicationConfig, importProvidersFrom } from '@angular/core'; -import { PaymentAdminConfigModule } from '@volo/abp.ng.payment/admin/config'; +import { ApplicationConfig } from '@angular/core'; +import { providePaymentAdminConfig } from '@volo/abp.ng.payment/admin/config'; export const appConfig: ApplicationConfig = { providers: [ // ... - importProvidersFrom([ - PaymentAdminConfigModule.forRoot() - ]), + providePaymentAdminConfig(), ], }; +// environment.ts +export const environment = { + apis: { + default: { + url: 'https://localhost:44300', + }, + AbpPaymentCommon: { + url: 'https://localhost:44301', + }, + AbpPaymentAdmin: { + url: 'https://localhost:44302', + }, + }, +}; ``` -The payment admin module should be imported and lazy-loaded in your routing array as below: +`AbpPaymentCommon` is used by the payment-request proxies and is the server-side remote service name for the gateway endpoints. The currently published Angular gateway proxy, which is used by the public gateway-selection component, sends gateway requests through `AbpPaymentAdmin`; administration proxies also use `AbpPaymentAdmin`. When the APIs are hosted separately, configure both entries and expose `/api/payment/gateways` through the `AbpPaymentAdmin` URL until the client and server remote service names are aligned. Each missing entry independently falls back to `default.url`. -```js +Lazy-load both the admin and public payment routes under the `payment` path: + +```typescript // app.routes.ts +import { Routes } from '@angular/router'; + const APP_ROUTES: Routes = [ // ... { - path: 'payment', - loadChildren: () => - import('@volo/abp.ng.payment/admin').then(c => c.createRoutes()), + path: 'payment', + loadChildren: () => + Promise.all([ + import('@volo/abp.ng.payment/admin').then(c => c.createRoutes()), + import('@volo/abp.ng.payment').then(c => c.createRoutes()), + ]).then(([adminRoutes, publicRoutes]) => [...adminRoutes, ...publicRoutes]), }, ]; ``` +The public route factory adds `gateway-selection`, `:gateway/prepayment`, and `:gateway/post-payment`. The admin route factory adds plans, gateway plans, requests, and payment request products. + +The public package exposes replaceable component keys through `ePaymentComponents`. Use `registerPrePaymentComponent` or `registerPostPaymentComponent` with `ReplaceableComponentsService` to replace a gateway-specific payment page. The admin `createRoutes` method accepts `PaymentConfigOptions` for entity action, toolbar action, entity property, create form, and edit form contributors on the plans and gateway plans pages. + ### Pages #### Public Pages @@ -425,231 +434,234 @@ This page lists all the payment request operations in the application. `PaymentOptions` is used to store list of payment gateways. You don't have to configure this manually for existing payment gateways. You can, however, add a new gateway like below; +Add `using Volo.Abp.Localization;` to the module class file for `FixedLocalizableString`. + ````csharp Configure(options => { - options.Gateways.Add( - new PaymentGatewayConfiguration( - "MyPaymentGatewayName", - new FixedLocalizableString("MyPaymentGatewayName"), - typeof(MyPaymentGateway) - ) - ); + options.Gateways.Add( + new PaymentGatewayConfiguration( + "MyPaymentGatewayName", + new FixedLocalizableString("MyPaymentGatewayName"), + isSubscriptionSupported: false, + typeof(MyPaymentGateway) + ) + ); }); ```` -`AbpIdentityAspNetCoreOptions` properties: +`PaymentOptions` properties: -* `PaymentGatewayConfigurationDictionary`: List of gateway configuration. - * ```Name```: Name of payment gateway. - * ```DisplayName```: DisplayName of payment gateway. - * ```PaymentGatewayType```: type of payment gateway. - * ```Order```: Order of payment gateway. +* `Gateways`: Dictionary of gateway configurations keyed by gateway name. + * `Name`: Name of payment gateway. + * `DisplayName`: DisplayName of payment gateway. + * `IsSubscriptionSupported`: Whether the gateway can process recurring payment products. + * `PaymentGatewayType`: type of payment gateway. + * `Order`: Order of payment gateway. ### PaymentWebOptions -```PaymentWebOptions``` is used to configure web application related configurations. +`PaymentWebOptions` is used to configure web application related configurations. -* ```CallbackUrl```: Final callback URL for internal payment gateway modules to return. User will be redirected to this URL on your website. -* ```RootUrl```: Root URL of your website. -* ```GatewaySelectionCheckoutButtonStyle```: CSS style to add Checkout button on gateway selection page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. -* ```PaymentGatewayWebConfigurationDictionary```: Used to store web related payment gateway configuration. - * ```Name```: Name of payment gateway. - * ```PrePaymentUrl```: URL of the page before redirecting user to payment gateway for payment. - * ```PostPaymentUrl```: URL of the page when user redirected back from payment gateway to your website. This page is used to validate the payment mostly. - * ```Order```: Order of payment gateway for gateway selection page. - * ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. - * ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `CallbackUrl`: Final callback URL for internal payment gateway modules to return. A redirect to this URL is not proof of a successful payment. Query the payment request and check its state before displaying the result. +* `RootUrl`: Root URL of your website. +* `GatewaySelectionCheckoutButtonStyle`: CSS style to add Checkout button on gateway selection page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. +* `Gateways`: Used to store web related payment gateway configurations. + * `Name`: Name of payment gateway. + * `PrePaymentUrl`: URL of the page before redirecting user to payment gateway for payment. + * `PostPaymentUrl`: URL of the page when user redirected back from payment gateway to your website. This page is used to validate the payment mostly. + * `Order`: Order of payment gateway for gateway selection page. + * `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. + * `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### PaymentBlazorOptions -```PaymentBlazorOptions``` is used to configure Blazor application related configurations. This is the Blazor equivalent of `PaymentWebOptions`. +`PaymentBlazorOptions` is used to configure Blazor application related configurations. This is the Blazor equivalent of `PaymentWebOptions`. -* ```CallbackUrl```: Final callback URL for internal payment gateway modules to return. User will be redirected to this URL on your website after a successful payment. -* ```RootUrl```: Root URL of your Blazor application. -* ```GatewaySelectionCheckoutButtonStyle```: CSS style to add to the Checkout button on the gateway selection page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. -* ```PaymentGatewayBlazorConfigurationDictionary```: Used to store Blazor related payment gateway configuration. - * ```Name```: Name of payment gateway. - * ```PrePaymentUrl```: URL of the Blazor page before redirecting user to payment gateway for payment. - * ```PostPaymentUrl```: URL of the Blazor page when user is redirected back from payment gateway to your website. - * ```Order```: Order of payment gateway for gateway selection page. - * ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. - * ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `CallbackUrl`: Final callback URL for internal payment gateway modules to return. A redirect to this URL is not proof of a successful payment. Query the payment request and check its state before displaying the result. +* `RootUrl`: Root URL of your Blazor application. +* `GatewaySelectionCheckoutButtonStyle`: CSS style to add to the Checkout button on the gateway selection page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. +* `Gateways`: Used to store Blazor related payment gateway configurations. + * `Name`: Name of payment gateway. + * `PrePaymentUrl`: URL of the Blazor page before redirecting user to payment gateway for payment. + * `PostPaymentUrl`: URL of the Blazor page when user is redirected back from payment gateway to your website. + * `Order`: Order of payment gateway for gateway selection page. + * `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. + * `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### PayuOptions -```PayuOptions``` is used to configure PayU payment gateway options. +`PayuOptions` is used to configure PayU payment gateway options. -* ```Merchant```: Merchant code for PayU account. -* ```Signature```: Signature of Merchant. -* ```LanguageCode```: Language of the order. This will be used for notification email that are sent to the client, if available. -* ```CurrencyCode```: Currency code of order (USD, EUR, etc...). -* ```VatRate```: Vat rate of order. -* ```PriceType```: Price type of order (GROSS or NET). -* ```Shipping```: A positive number indicating the price of shipping. -* ```Installment```: The number of installments. It can be an integer between 1 and 12. -* ```TestOrder```: Is the order a test order or not (true or false). -* ```Debug```: Writes detailed log on PAYU side. +* `CheckoutLink`: PayU checkout URL. Its default value is `https://secure.payu.ro/order/lu.php`. +* `Merchant`: Merchant code for PayU account. +* `Signature`: Signature of Merchant. +* `LanguageCode`: Language of the order. This will be used for notification email that are sent to the client, if available. +* `VatRate`: Vat rate of order. +* `PriceType`: Price type of order (GROSS or NET). +* `Shipping`: A positive number indicating the price of shipping. +* `Installment`: The number of installments. It can be an integer between 1 and 12. +* `TestOrder`: Is the order a test order or not (true or false). +* `Debug`: Writes detailed log on PAYU side. ### PayuWebOptions -```PayuWebOptions``` is used to configure PayU payment gateway web options. +`PayuWebOptions` is used to configure PayU payment gateway web options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the PayU prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the PayU prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. ### PayuBlazorOptions -```PayuBlazorOptions``` is used to configure PayU payment gateway Blazor options. +`PayuBlazorOptions` is used to configure PayU payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the PayU prepayment page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the PayU prepayment page. ### TwoCheckoutOptions -```TwoCheckoutOptions``` is used to configure TwoCheckout payment gateway options. +`TwoCheckoutOptions` is used to configure TwoCheckout payment gateway options. -* ```Signature```: Signature of Merchant's 2Checkout account. -* ```CheckoutUrl```: 2Checkout checkout URL (it must be set to https://secure.2checkout.com/order/checkout.php). -* ```LanguageCode```: Language of the order. This will be used for notification email that are sent to the client, if available. -* ```CurrencyCode```: Currency code of order (USD, EUR, etc...). +* `Signature`: Signature of Merchant's 2Checkout account. +* `CheckoutUrl`: 2Checkout checkout URL (it must be set to https://secure.2checkout.com/order/checkout.php). +* `LanguageCode`: Language of the order. This will be used for notification email that are sent to the client, if available. ### TwoCheckoutWebOptions -```TwoCheckoutWebOptions``` is used to configure TwoCheckout payment gateway web options. +`TwoCheckoutWebOptions` is used to configure TwoCheckout payment gateway web options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### TwoCheckoutBlazorOptions -```TwoCheckoutBlazorOptions``` is used to configure TwoCheckout payment gateway Blazor options. +`TwoCheckoutBlazorOptions` is used to configure TwoCheckout payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### StripeOptions -```StripeOptions```: is used to configure Stripe payment gateway options. +`StripeOptions`: is used to configure Stripe payment gateway options. -* ```PublishableKey```: Publishable Key for Stripe account. -* ```SecretKey```: Secret Key for Stripe account. +* `PublishableKey`: Publishable Key for Stripe account. +* `SecretKey`: Secret Key for Stripe account. * `WebhookSecret`: Used for handling webhooks. You can get if from [Stripe Dashboard](https://dashboard.stripe.com/webhooks). If you don't use subscription & recurring payment it's not necessary. -* ```Currency```: Currency code of order (USD, EUR, etc..., see [Stripe docs](https://stripe.com/docs/currencies) for the full list). Its default value is USD. -* ```Locale```: Language of the order. Its default value is 'auto'. -* ```PaymentMethodTypes```: A list of the types of payment methods (e.g., card) this Checkout session can accept. See https://stripe.com/docs/payments/checkout/payment-methods. Its default value is 'card'. +* `Currency`: Currency code of order (USD, EUR, etc..., see [Stripe docs](https://stripe.com/docs/currencies) for the full list). Its default value is USD. +* `Locale`: Language of the order. Its default value is 'auto'. +* `PaymentMethodTypes`: A list of the types of payment methods (e.g., card) this Checkout session can accept. See https://stripe.com/docs/payments/checkout/payment-methods. Its default value is 'card'. ### StripeWebOptions -```StripeWebOptions``` is used to configure Stripe payment gateway web options. +`StripeWebOptions` is used to configure Stripe payment gateway web options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### StripeBlazorOptions -```StripeBlazorOptions``` is used to configure Stripe payment gateway Blazor options. +`StripeBlazorOptions` is used to configure Stripe payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### PayPalOptions -```PayPalOptions``` is used to configure PayPal payment gateway options. +`PayPalOptions` is used to configure PayPal payment gateway options. -* ```ClientId```: Client Id for the PayPal account. -* ```Secret``` Secret for the PayPal account. -* ```CurrencyCode```: Currency code of order (USD, EUR, etc...). -* ```Environment```: Payment environment. ("Sandbox" or "Live", default value is "Sandbox") -* ```Locale```: PayPal-supported language and locale to localize PayPal checkout pages. See https://developer.paypal.com/docs/api/reference/locale-codes/. +* `ClientId`: Client Id for the PayPal account. +* `Secret`: Secret for the PayPal account. +* `Environment`: Payment environment. ("Sandbox" or "Live", default value is "Sandbox") +* `Locale`: PayPal-supported language and locale to localize PayPal checkout pages. See https://developer.paypal.com/docs/api/reference/locale-codes/. ### PayPalWebOptions -```PayPalWebOptions``` is used to configure PayPal payment gateway web options. +`PayPalWebOptions` is used to configure PayPal payment gateway web options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### PayPalBlazorOptions -```PayPalBlazorOptions``` is used to configure PayPal payment gateway Blazor options. +`PayPalBlazorOptions` is used to configure PayPal payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. ### IyzicoOptions -```IyzicoOptions``` is used to configure Iyzico payment gateway options. +`IyzicoOptions` is used to configure Iyzico payment gateway options. -* ```BaseUrl```: Base API URL for the Iyzico (ex: https://sandbox-api.iyzipay.com). -* ```ApiKey``` Api key for the Iyzico account. -* ```SecretKey ``` Secret for the Iyzico account. -* ```Currency```: Currency code for the order (USD, EUR, GBP and TRY can be used). -* ```Locale```: Language of the order. -* ```InstallmentCount```: Installment count value. For single installment payments it should be 1 (valid values: 1, 2, 3, 6, 9, 12). +* `BaseUrl`: Base API URL for the Iyzico (ex: https://sandbox-api.iyzipay.com). +* `ApiKey`: API key for the Iyzico account. +* `SecretKey`: Secret for the Iyzico account. +* `Currency`: Currency code for the order (USD, EUR, GBP and TRY can be used). +* `Locale`: Language of the order. ### IyzicoWebOptions -```IyzicoWebOptions``` is used to configure Iyzico payment gateway web options. +`IyzicoWebOptions` is used to configure Iyzico payment gateway web options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the Iyzico prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the Iyzico prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. ### IyzicoBlazorOptions -```IyzicoBlazorOptions``` is used to configure Iyzico payment gateway Blazor options. +`IyzicoBlazorOptions` is used to configure Iyzico payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the Iyzico prepayment page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the Iyzico prepayment page. ### AlipayOptions -```AlipayOptions``` is used to configure Alipay payment gateway options。 **Alipay gateway only supports CNY currency**. +`AlipayOptions` is used to configure Alipay payment gateway options. **Alipay gateway only supports CNY currency**. -* ```Protocol```:Protocol for the Alipay (ex: https). -* ```GatewayHost```: Gateway host for the Aliapy. -* ```SignType```: Sign type for the Alipay. -* ```AppId```: AppId for the Alipay account. -* ```MerchantPrivateKey```: Merchant private key of the Alipay account. -* ```MerchantCertPath```Merchant cert path of the Alipay account. -* ```AlipayCertPath```: Alipay cert path of the Alipay account. -* ```AlipayRootCertPath```: Alipay root cert path of the Alipay account. -* ```AlipayPublicKey```: Alipay public key of the Alipay account. -* ```NotifyUrl```: Notify url of the Alipay. -* ```EncryptKey```: Encrypt key of the Alipay. +* `Protocol`: Protocol for Alipay (for example, https). +* `GatewayHost`: Gateway host for Alipay. +* `SignType`: Sign type for the Alipay. +* `AppId`: AppId for the Alipay account. +* `MerchantPrivateKey`: Merchant private key of the Alipay account. +* `MerchantCertPath`: Merchant certificate path of the Alipay account. +* `AlipayCertPath`: Alipay cert path of the Alipay account. +* `AlipayRootCertPath`: Alipay root cert path of the Alipay account. +* `AlipayPublicKey`: Alipay public key of the Alipay account. +* `NotifyUrl`: Notify url of the Alipay. +* `EncryptKey`: Encrypt key of the Alipay. -#### AlipayWebOptions +### AlipayWebOptions -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the Alipay prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the Alipay prepayment page. This class can be used for tracking user activity via 3rd party tools like Google Tag Manager. -#### AlipayBlazorOptions +### AlipayBlazorOptions -```AlipayBlazorOptions``` is used to configure Alipay payment gateway Blazor options. +`AlipayBlazorOptions` is used to configure Alipay payment gateway Blazor options. -* ```Recommended```: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. -* ```ExtraInfos```: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. -* ```PrePaymentCheckoutButtonStyle```: CSS style to add to the Checkout button on the Alipay prepayment page. +* `Recommended`: Is payment gateway recommended or not. This information is displayed on payment gateway selection page. +* `ExtraInfos`: List of informative strings for payment gateway. These texts are displayed on payment gateway selection page. +* `PrePaymentCheckoutButtonStyle`: CSS style to add to the Checkout button on the Alipay prepayment page. > You can check the [Alipay document](https://opendocs.alipay.com/open/02np97) for more details. +Set the currency for a one-time payment with `PaymentRequestCreateDto.Currency`. The legacy `CurrencyCode` settings on PayU, PayPal, and TwoCheckout options are obsolete fallbacks. In the current TwoCheckout integration, you must still configure `CurrencyCode` because it is also used when the product price parameters are generated. + Instead of configuring options in your module class, you can configure it in your appsettings.json file like below; ```json -"Payment": { +{ + "Payment": { "Payu": { "Merchant": "TEST", "Signature": "SECRET_KEY", "LanguageCode": "en", - "CurrencyCode": "USD", - "VatRate": "0", + "VatRate": 0, "PriceType": "GROSS", - "Shipping": "0", + "Shipping": 0, "Installment": "1", "TestOrder": "1", "Debug": "1" @@ -659,12 +671,11 @@ Instead of configuring options in your module class, you can configure it in you "CheckoutUrl": "https://secure.2checkout.com/order/checkout.php", "LanguageCode": "en", "CurrencyCode": "USD", - "TestOrder": "1" + "TestOrder": true }, "PayPal": { "ClientId": "CLIENT_ID", "Secret": "SECRET", - "CurrencyCode": "USD", "Environment": "Sandbox", "Locale": "en_US" }, @@ -687,8 +698,11 @@ Instead of configuring options in your module class, you can configure it in you "MerchantPrivateKey": "MERCHANT_PRIVATE_KEY" } } +} ``` +The domain and MVC gateway modules share `Payment:Payu`, `Payment:PayPal`, `Payment:Iyzico`, `Payment:Stripe`, and `Payment:TwoCheckout`. Alipay MVC options use `Payment:AlipayWeb`. + ## Internals ### Domain layer @@ -706,19 +720,21 @@ A payment request represents a request for a payment in the application. * `State` : State of payment request (can be Waiting, Completed, Failed or Refunded). * `Currency` : Currency code of payment request (USD, EUR, etc...). * `Gateway` : Name of payment gateway used for this payment request. - * ```FailReason```: Reason for failed payment requests. + * `FailReason`: Reason for failed payment requests. + +`Complete()` moves a waiting or failed request to `Completed` and is idempotent for an already completed request. `Failed()` accepts waiting or failed requests, while `Refunded()` accepts only completed requests. ##### Plan -A plan is used for subscription payments. Contains PlanGateway list to configure each gateway. +A plan is used for subscription payments. It contains a `GatewayPlans` collection for gateway-specific configurations. - `Plan` (aggregate root): Represents a plan for recurring payments. - - `PlanGateways` (collection): List of gateway plans. - - `Name` : An optional name of plan. + - `GatewayPlans` (collection): List of gateway plans. + - `Name`: Required name of the plan. - `GatewayPlan` (entity): Represents a gateway configuration for a plan. - `PlanId`: Represents a plan belong to. - `Gateway`: Represents a gateway belong to. It has to be unique. - - `ExternalId`: Stores a unique configuration of gateway for subscrtiption, such as priceId, planId, subscriptionId or productId etc. + - `ExternalId`: Stores the gateway's external subscription configuration, such as a price ID, plan ID, subscription ID, or product ID. #### Repositories @@ -747,7 +763,7 @@ All tables/collections use the `Pay` prefix by default. Set static properties on ##### Connection string -This module uses `AbpPayment` for the connection string name. If you don't define a connection string with this name, it fallbacks to the `Default` connection string. +This module uses `Payment` for the connection string name. If you don't define a connection string with this name, it falls back to the `Default` connection string. See the [connection strings](../framework/fundamentals/connection-strings.md) documentation for details. @@ -756,8 +772,7 @@ See the [connection strings](../framework/fundamentals/connection-strings.md) do ##### Tables * **PayPaymentRequests** - * **AbpRoleClaims** - * PayPaymentRequestProducts +* **PayPaymentRequestProducts** * **PayPlans** * **PayGatewayPlans** @@ -832,7 +847,7 @@ public static void ConfigureExtraProperties() ## Distributed Events -- `Volo.Payment.PaymentRequestCompleted` (**PaymentRequestCompletedEto**): Published when a payment is completed. +- `Volo.Payment.PaymentRequestCompleted` (**PaymentRequestCompletedEto**): Published by `IPaymentRequestAppService.CompleteAsync` when the resolved gateway returns a completed payment request. It isn't a notification for every direct payment-request state update; for example, the built-in Stripe webhook can complete a request without publishing this event. - `Id`: Represents PaymentRequest entity Id. - `Gateway`: Represents the gateway which payment was done with. @@ -874,7 +889,7 @@ public static void ConfigureExtraProperties() This module implements one-time payments; -* Supports [Stripe](https://stripe.com/), [PayPal](https://www.paypal.com/), [2Checkout](https://www.2checkout.com/), [PayU](https://corporate.payu.com/) and [Iyzico](https://www.iyzico.com/en) payment gateways. +* Supports [Stripe](https://stripe.com/), [PayPal](https://www.paypal.com/), [2Checkout](https://www.2checkout.com/), [PayU](https://corporate.payu.com/), [Iyzico](https://www.iyzico.com/en), and [Alipay](https://global.alipay.com/) payment gateways. You can get one-time payments from your customers using one or more payment gateways supported by the payment module. Payment module works in a very simple way for one-time payments. It creates a local payment request record and redirects customer to payment gateway (PayPal, Stripe etc...) for processing the payment. When the customer pays on the payment gateway, payment module handles the external payment gateway's response and validates the payment to see if it is really paid or not. If the payment is validated, payment module redirects customer to main application which initiated the payment process at the beginning. @@ -886,18 +901,18 @@ Each payment gateway implementation contains PrePayment and PostPayment pages. PrePayment page asks users for extra information if requested by the external payment gateway. For example, 2Checkout doesn't require any extra information, so PrePayment page for 2Checkout redirects user to 2Checkout without asking any extra information. -PostPayment page is responsible for validation of the response of the external payment gateway. When a user completes the payment, user is redirected to PostPayment page for that payment gateway and PostPayment page validates the status of the payment. If the payment is succeeded, status of the payment request is updated and user is redirected to main application. +PostPayment page is responsible for validating the response of the external payment gateway. When a user returns from the gateway, the PostPayment page validates the payment and updates the payment request state. A subsequent redirect to the main application is a browser navigation target, not proof that the payment succeeded. -Note: It is the main application's responsibility to handle if a payment request is used more than once. For example, if the PostPayment page generates a URL like https://mywebsite.com/PaymentSucceed?PaymentRequestId={PaymentRequestId}, this URL can be visited more than once manually by end users. If you have already delivered your product for a given PaymentRequestId, you shouldn't deliver it when this URL is visited a second time. +Query the payment request and check its state before displaying the result. Do not perform fulfillment from the callback page because users can revisit or forge callback URLs. For flows that finish through `IPaymentRequestAppService.CompleteAsync`, use an idempotent `PaymentRequestCompletedEto` handler. For webhook-only completion, use an application-owned, idempotent fulfillment or reconciliation path that runs after the trusted webhook is processed and verifies the persisted request state. ### Creating One-Time Payment -In order to initiate a payment process, inject `IPaymentRequestAppService`, create a payment request using it's `CreateAsync` method and redirect user to gateway selection page with the created payment request's Id. Here is a sample Razor Page code which starts a payment process on it's OnPost method. +In order to initiate a payment process, inject `IPaymentRequestAppService`, create a payment request using its `CreateAsync` method and redirect user to gateway selection page with the created payment request's Id. Here is a sample Razor Page code which starts a payment process on its OnPost method. > Redirection of the gateway selection page has to be a **POST** request. If you implement it as a **GET** request, you will get an error. You can use `LocalRedirectPreserveMethod` to keep the method as POST in the redirected request. -```c# -public class IndexModel: PageModel +```csharp +public class IndexModel : PageModel { private readonly IPaymentRequestAppService _paymentRequestAppService; @@ -919,7 +934,7 @@ public class IndexModel: PageModel Name = "LEGO Super Mario", Count = 2, UnitPrice = 60, - TotalPrice = 200 + TotalPrice = 120 } } }); @@ -929,7 +944,9 @@ public class IndexModel: PageModel } ``` -If the payment is successful, payment module will return to the configured ```PaymentWebOptions.CallbackUrl```. The main application can take necessary actions for a successful payment (activating a user account, triggering a shipment start process, etc.). +`TotalPrice` is optional. When it is omitted, the module calculates it as `UnitPrice * Count`. + +The Payment module can redirect the browser to the configured `PaymentWebOptions.CallbackUrl` after processing the gateway response. Treat this URL only as a result page: query the payment request and check its state before displaying the result. `PaymentRequestCompletedEto` can drive idempotent fulfillment for flows completed through `IPaymentRequestAppService.CompleteAsync`; cover webhook-only completion with a trusted, application-owned reconciliation or fulfillment path as described above. ## Subscriptions @@ -1009,4 +1026,4 @@ public class SubscriptionModel : PageModel } ``` -> To track that subscription is continuing or canceled, you should keep the SubscriptionId, all events contain it. +> To track whether the subscription continues or is canceled, store its `ExternalSubscriptionId`. All subscription lifecycle events contain this value. diff --git a/docs/en/modules/permission-management.md b/docs/en/modules/permission-management.md index 5a80648ecb..342f355617 100644 --- a/docs/en/modules/permission-management.md +++ b/docs/en/modules/permission-management.md @@ -33,6 +33,87 @@ When you click *Actions* -> *Permissions* for a role, the permission management In this dialog, you can grant permissions for the selected role. The tabs in the left side represents main permission groups and the right side contains the permissions defined in the selected group. +#### Reusing the Permission Management Dialog + +The standard permission management dialog is reusable for any registered permission management provider. The provider name and key identify the object whose permissions are being managed. + +##### MVC / Razor Pages + +Use `abp.ModalManager` to open the built-in modal page: + +````javascript +var permissionModal = new abp.ModalManager( + abp.appPath + 'AbpPermissionManagement/PermissionManagementModal' +); + +permissionModal.open({ + providerName: 'R', + providerKey: roleName, + providerKeyDisplayName: roleName +}); +```` + +##### Blazor + +Add the `PermissionManagementModal` component to the page and call its `OpenAsync` method: + +````razor +@using Volo.Abp.PermissionManagement.Blazor.Components + + + +@code { + private PermissionManagementModal PermissionModal { get; set; } + + private Task OpenPermissionsAsync(string roleName) + { + return PermissionModal.OpenAsync("R", roleName, roleName); + } +} +```` + +The MudBlazor package provides the same component API in the `Volo.Abp.PermissionManagement.Blazor.MudBlazor.Components` namespace. + +##### Angular + +Import the standalone `PermissionManagementComponent` into your component: + +````typescript +import { Component } from '@angular/core'; +import { PermissionManagementComponent } from '@abp/ng.permission-management'; + +@Component({ + selector: 'app-role-actions', + templateUrl: './role-actions.component.html', + imports: [PermissionManagementComponent], +}) +export class RoleActionsComponent { + roleName = 'admin'; + permissionsVisible = false; + + openPermissions() { + this.permissionsVisible = true; + } +} +```` + +Then add the component to the template. It owns the modal, so you only need to control its `visible` value: + +````html + + + +```` + +The reusable dialog calls `IPermissionAppService`. The provider must be registered and mapped to an authorization policy as described in the [Permission Management Providers](#permission-management-providers) section. For non-admin users, only permissions that the current user already has are editable, and update requests are filtered by the same rule. Users with the built-in admin role are not subject to this editability filter. + +Use `entityDisplayName` to customize the modal title and `hideBadges` to hide the granted-provider badges. The component emits `visibleChange`, so the two-way `[(visible)]` binding keeps the caller's visibility state synchronized. + ### Resource Permission Management Dialog In addition to standard permissions, this module provides a reusable dialog for managing **resource-based permissions** on specific resource instances. This allows administrators to grant or revoke permissions for users, roles and clients on individual resources (e.g., a specific document, project, or any entity). @@ -94,32 +175,45 @@ Use the `ResourcePermissionManagementModal` component's `OpenAsync` method to op } ```` +The MudBlazor package provides the same component API in the `Volo.Abp.PermissionManagement.Blazor.MudBlazor.Components` namespace. + #### Angular -Use the `ResourcePermissionManagementComponent`: +Import the standalone `ResourcePermissionManagementComponent` into your component: ````typescript -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Component } from '@angular/core'; import { ResourcePermissionManagementComponent } from '@abp/ng.permission-management'; @Component({ - // ... + selector: 'app-document-actions', + templateUrl: './document-actions.component.html', + imports: [ResourcePermissionManagementComponent], }) -export class DocumentListComponent { - constructor(private modalService: NgbModal) {} - - openPermissionsModal(document: DocumentDto) { - const modalRef = this.modalService.open( - ResourcePermissionManagementComponent, - { size: 'lg' } - ); - modalRef.componentInstance.resourceName = 'MyApp.Document'; - modalRef.componentInstance.resourceKey = document.id; - modalRef.componentInstance.resourceDisplayName = document.title; +export class DocumentActionsComponent { + documentId = '42'; + documentTitle = 'Permission Management Guide'; + resourcePermissionsVisible = false; + + openPermissions() { + this.resourcePermissionsVisible = true; } } ```` +Render the component directly. It owns the modal and requires `resourceName` and `resourceKey` inputs: + +````html + + + +```` + ## IPermissionManager `IPermissionManager` is the main service provided by this module. It is used to read and change the global permission values. `IPermissionManager` is typically used by the *Permission Management Dialog*. However, you can inject it if you need to set a permission value. @@ -146,7 +240,7 @@ public class MyService : ITransientDependency } public async Task GrantUserPermissionDemoAsync( - Guid userId, string roleName, string permission) + Guid userId, string permission) { await _permissionManager .SetForUserAsync(userId, permission, true); @@ -154,6 +248,8 @@ public class MyService : ITransientDependency } ```` +The OpenIddict integration also provides `SetForClientAsync` for client permissions. + ## IResourcePermissionManager `IResourcePermissionManager` is the service for programmatically managing resource-based permissions. It is typically used by the *Resource Permission Management Dialog*. However, you can inject it when you need to grant, revoke, or query permissions for specific resource instances. @@ -243,32 +339,85 @@ public class MyService : ITransientDependency ## Cleaning Up Resource Permissions -When a resource is deleted, you should clean up its associated permissions to avoid orphaned permission records in the database. You can do this directly in your delete logic or handle it asynchronously through event handlers: +When a resource is deleted, you should clean up its associated permissions to avoid orphaned permission records in the database. Query all grants for the resource and delete the returned entities: + +````csharp +public class DocumentService : ITransientDependency, IUnitOfWorkEnabled +{ + private readonly IDocumentRepository _documentRepository; + private readonly IResourcePermissionGrantRepository _resourcePermissionGrantRepository; + + public DocumentService( + IDocumentRepository documentRepository, + IResourcePermissionGrantRepository resourcePermissionGrantRepository) + { + _documentRepository = documentRepository; + _resourcePermissionGrantRepository = resourcePermissionGrantRepository; + } + + public virtual async Task DeleteDocumentAsync(Guid id) + { + await _documentRepository.DeleteAsync(id); + + var grants = await _resourcePermissionGrantRepository.GetPermissionsAsync( + "MyApp.Document", + id.ToString() + ); + + await _resourcePermissionGrantRepository.DeleteManyAsync( + grants, + autoSave: true + ); + } +} +```` + +`IUnitOfWorkEnabled` keeps the resource deletion and grant cleanup in the same unit of work. `ResourcePermissionGrant` is a multi-tenant entity, so repository queries are scoped by the current tenant data filter. If cleanup runs from the host or a background process, switch `ICurrentTenant` to the resource owner's tenant before calling `GetPermissionsAsync` and `DeleteManyAsync`; otherwise the query will not return that tenant's grants. + +The `providerName` and `providerKey` arguments of `IResourcePermissionManager.DeleteAsync` identify one exact provider and key; a `null` provider key is not a wildcard. The Identity integration cleans up user and role grants, and the OpenIddict integration cleans up client grants when those entities are deleted. For your custom entities, you are responsible for removing all resource grants when the resource is deleted. + +## Application Service and HTTP API + +`IPermissionAppService` exposes the management operations under the `api/permission-management/permissions` route. Standard permission `GET` and `PUT` operations require the authorization policy mapped to the requested provider in `PermissionManagementOptions.ProviderPolicies`. + +Standard and resource update operations handle omitted permissions differently: + +* The standard permission `PUT` operation changes only the permission entries included in `UpdatePermissionsDto`. For non-admin users, entries the current user does not have are ignored. +* The resource permission `PUT` operation treats `UpdateResourcePermissionsDto.Permissions` as the complete desired set for the selected resource, provider, and provider key. Manageable permissions omitted from the list are revoked. Each resource permission is filtered by its `ManagementPermissionName` before it can be returned or changed. + +## Seeding Permission Grants + +Use `IPermissionDataSeeder` in a data seed contributor to add initial permission grants for a provider and key: ````csharp -public async Task DeleteDocumentAsync(Guid id) +public class MyPermissionDataSeedContributor + : IDataSeedContributor, ITransientDependency { - // Delete the document - await _documentRepository.DeleteAsync(id); - - // Clean up all permissions for this resource - await _resourcePermissionManager.DeleteAsync( - resourceName: "MyApp.Document", - resourceKey: id.ToString(), - providerName: "U", - providerKey: null // Deletes for all users - ); - - await _resourcePermissionManager.DeleteAsync( - resourceName: "MyApp.Document", - resourceKey: id.ToString(), - providerName: "R", - providerKey: null // Deletes for all roles - ); + private readonly IPermissionDataSeeder _permissionDataSeeder; + + public MyPermissionDataSeedContributor( + IPermissionDataSeeder permissionDataSeeder) + { + _permissionDataSeeder = permissionDataSeeder; + } + + public Task SeedAsync(DataSeedContext context) + { + return _permissionDataSeeder.SeedAsync( + RolePermissionValueProvider.ProviderName, + "admin", + new[] + { + "MyApp.Books", + "MyApp.Books.Create" + }, + context.TenantId + ); + } } ```` -> ABP modules automatically handle permission cleanup for their own entities. For your custom entities, you are responsible for cleaning up resource permissions when resources are deleted. +The seeder is additive and tenant-aware. It inserts grants that do not exist for the selected provider, key, and tenant; it does not revoke existing grants that are absent from the input. ## Permission Management Providers @@ -279,6 +428,8 @@ Permission Management Module is extensible, just like the [permission system](.. * `UserPermissionManagementProvider`: Manages user-based permissions. * `RolePermissionManagementProvider`: Manages role-based permissions. +The OpenIddict integration also registers a provider for client permissions. + `IPermissionManager` uses these providers when you get/set permissions. You can define your own provider by implementing the `IPermissionManagementProvider` or inheriting from the `PermissionManagementProvider` base class. **Example:** @@ -286,7 +437,9 @@ Permission Management Module is extensible, just like the [permission system](.. ````csharp public class CustomPermissionManagementProvider : PermissionManagementProvider { - public override string Name => "Custom"; + public const string ProviderName = "Custom"; + + public override string Name => ProviderName; public CustomPermissionManagementProvider( IPermissionGrantRepository permissionGrantRepository, @@ -309,10 +462,12 @@ Once you create your provider class, you should register it using the `Permissio Configure(options => { options.ManagementProviders.Add(); + options.ProviderPolicies[CustomPermissionManagementProvider.ProviderName] = + "MyApp.ManageCustomPermissions"; }); ```` -The order of the providers are important. Providers are executed in the reverse order. That means the `CustomPermissionManagementProvider` is executed first for this example. You can insert your provider in any order in the `Providers` list. +`IPermissionManager` enumerates `ManagementProviders` in registration order when it reads permission values. A write operation selects the registered provider whose `Name` matches the requested provider name. Every provider name must therefore be unique. ### Resource Permission Management Providers @@ -399,15 +554,20 @@ Permission value providers are used to determine if a permission is granted. The ### Resource Permission Value Providers -Similar to the standard permission system, you can create custom value providers for resource permissions. ABP comes with two built-in resource permission value providers: +Similar to the standard permission system, you can create custom value providers for resource permissions. ABP comes with three built-in resource permission value providers: * `UserResourcePermissionValueProvider` (`U`): Checks permissions granted directly to users * `RoleResourcePermissionValueProvider` (`R`): Checks permissions granted to roles +* `ClientResourcePermissionValueProvider` (`C`): Checks permissions granted directly to clients You can create your own custom value provider by implementing the `IResourcePermissionValueProvider` interface or inheriting from the `ResourcePermissionValueProvider` base class: ````csharp +using System; +using System.Linq; +using System.Security.Principal; using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; using Volo.Abp.Authorization.Permissions.Resources; public class OwnerResourcePermissionValueProvider : ResourcePermissionValueProvider @@ -441,6 +601,33 @@ public class OwnerResourcePermissionValueProvider : ResourcePermissionValueProvi : PermissionGrantResult.Undefined; } + public override async Task CheckAsync( + ResourcePermissionValuesCheckContext context) + { + var permissionNames = context.Permissions + .Select(permission => permission.Name) + .Distinct() + .ToArray(); + var result = new MultiplePermissionGrantResult(permissionNames); + + var currentUserId = context.Principal?.FindUserId(); + if (currentUserId == null || + !await CheckIfUserIsOwnerAsync( + currentUserId.Value, + context.ResourceName, + context.ResourceKey)) + { + return result; + } + + foreach (var permissionName in permissionNames) + { + result.Result[permissionName] = PermissionGrantResult.Granted; + } + + return result; + } + private Task CheckIfUserIsOwnerAsync( Guid userId, string resourceName, @@ -461,6 +648,19 @@ Configure(options => }); ```` +## Database Providers + +The module uses the `AbpPermissionManagement` connection string name. `AbpPermissionManagementDbProperties.DbTablePrefix` and `DbSchema` default to the common ABP database prefix and schema, and can be changed before configuring the database model. + +With the default `Abp` prefix, the EF Core provider maps the following tables and the MongoDB provider maps collections with the same names: + +* `AbpPermissionGrants` +* `AbpResourcePermissionGrants` +* `AbpPermissionGroups` +* `AbpPermissions` + +In EF Core, the permission group and permission definition tables are mapped only when the model is configured as a host database. + ## See Also * [Authorization](../framework/fundamentals/authorization/index.md) diff --git a/docs/en/modules/saas.md b/docs/en/modules/saas.md index 7d78a4e9df..eb527dc37b 100644 --- a/docs/en/modules/saas.md +++ b/docs/en/modules/saas.md @@ -9,41 +9,43 @@ > You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module. -This module is used to manage your tenants and editions in multi-tenant applications; +This module is used to manage tenants and editions in multi-tenant applications: -* Manage **tenants** and **editions** in the system. A tenant is allowed to have one **edition**. -* Set **features** of tenants. -* Set **connection string** of tenants. -* Set **features** of editions and tenants. +- Manage **tenants** and **editions**. A tenant can have one edition. +- Assign application **features** to editions and tenants. +- Configure default and module-specific tenant **connection strings**. +- Control tenant activation and edition expiration. See [the module description page](https://abp.io/modules/Volo.Saas) for an overview of the module features. -## How to install +## How to Install -Saas is pre-installed in [the startup templates](../solution-templates). So, no need to manually install it. +The SaaS module is pre-installed in the [startup templates](../solution-templates), so you don't need to install it manually. ## Packages This module follows the [module development best practices guide](../framework/architecture/best-practices) and consists of several NuGet and NPM packages. See the guide if you want to understand the packages and relations between them. -You can visit [SaaS module package list page](https://abp.io/packages?moduleName=Volo.Saas) to see list of packages related with this module. +See the [SaaS module package list](https://abp.io/packages?moduleName=Volo.Saas) for the related packages. ## Tenant-Edition Subscription -SaaS module implements subscribing to Editions for Tenants using Payment module. To enable it, project must contain `Volo.Saas` and `Volo.Payment` modules and these modules must be configured as shown below. +The SaaS module integrates with the Payment module to subscribe tenants to editions. The solution must contain both the `Volo.Saas` and `Volo.Payment` modules. ### Configuration -Firstly, Payment module must be configured properly: +Configure the Payment module first: + +- Install the `Volo.Payment` module: -- Install `Volo.Payment` module. ```bash abp add-module Volo.Payment ``` - _Or you can install via using ABP Studio._ -- Configure Saas module to use Payment. - + You can also install it with ABP Studio. + +- Enable the Payment integration for the SaaS module: + ```csharp Configure(options => { @@ -51,61 +53,67 @@ Firstly, Payment module must be configured properly: }); ``` - - Follow the [subscriptions](payment#subscriptions) section of [Payment Module Documentation](payment#subscriptions). Complete [enabling webhooks](payment#enabling-webhooks) and [configuring plans](payment#configuring-plans) sections. +- Complete the Payment module's [subscription](payment.md#subscriptions), [webhook](payment.md#enabling-webhooks) and [plan](payment.md#configuring-plans) configuration. -- Run the application and go to `Saas > Editions` page at your Web Application menu. +- Run the application and open the `SaaS > Editions` page. -- Create or Edit an existing Edition. **Plan** dropdown must be visible if you've done earlier steps correctly. Pick a Plan for Edition. +- Create an edition or edit an existing one, then select a Payment plan in the **Plan** field. An edition must have a plan before it can be used to create a subscription. ### Usage -SaaS module doesn't contain a public facing list page for listing editions for new customers/tenants to subscribe. First, you need to create such a page in your application. Then, when a new customer/tenant selects one of those Editions, you can create a subscription and redirect user to payment module as shown below. +The module doesn't provide a public edition catalog. Create that page in your application and call `ISubscriptionAppService` after an authenticated tenant selects an edition. The following same-process Razor Pages example derives the tenant ID from `ICurrentTenant` instead of accepting it from the request: -- Inject `ISubscriptionAppService` to create a subscription for a edition: +```csharp +[Authorize] +public class IndexModel : PageModel +{ + protected ISubscriptionAppService SubscriptionAppService { get; } + protected ICurrentTenant CurrentTenant { get; } - ```csharp - public class IndexModel : PageModel - { - protected ISubscriptionAppService SubscriptionAppService { get; } - - protected ICurrentTenant CurrentTenant { get; } - - public IndexModel( - ISubscriptionAppService subscriptionAppService, - ICurrentTenant currentTenant) - { - SubscriptionAppService = subscriptionAppService; - CurrentTenant = currentTenant; - } - - public async Task OnPostAsync(Guid editionId) - { - var paymentRequest = await SubscriptionAppService.CreateSubscriptionAsync(editionId, CurrentTenant.GetId()); - - return LocalRedirectPreserveMethod("/Payment/GatewaySelection?paymentRequestId=" + paymentRequest.Id); - } - } - -When the payment is completed successfully, the tenant and edition relation will be updated according to subscription status. Make sure Payment Gateway Web Hooks are configured properly. - -After all, payment module will redirect user to the callbackUrl if configured in [payment configuration](payment#paymentweboptions) with a paymentRequestId parameter. In this page, you can check the status of the payment request and show a success message to the user when the payment status is confirmed. Since the payment confirmation is asynchronous, you need to check the payment status repeatedly until it is confirmed. - -## User interface - -### Menu items - -SaaS module adds the following items to the "Main" menu, under the "Administration" menu item: + public IndexModel( + ISubscriptionAppService subscriptionAppService, + ICurrentTenant currentTenant) + { + SubscriptionAppService = subscriptionAppService; + CurrentTenant = currentTenant; + } + + public async Task OnPostAsync(Guid editionId) + { + var paymentRequest = await SubscriptionAppService.CreateSubscriptionAsync( + editionId, + CurrentTenant.GetId() + ); + + return LocalRedirectPreserveMethod( + "/Payment/GatewaySelection?paymentRequestId=" + paymentRequest.Id + ); + } +} +``` + +Keep this operation behind an authenticated application endpoint and never bind an arbitrary tenant ID from public input. In a tiered solution, implement this orchestration in a trusted server-side application layer; the built-in SaaS subscription HTTP endpoint requires the host-side `Saas.Editions` permission. + +Creating a payment request initializes a missing or expired `EditionEndDateUtc` to the current UTC time, but it doesn't assign the selected edition. A subscription-created event assigns the edition and period end. A subscription-updated event refreshes the period end and applies a new edition assignment when the Payment event supplies one. A cancellation keeps the edition assignment and sets its end date to the subscription's period end date. After that date, `Tenant.GetActiveEditionId()` no longer returns the edition. + +Payment confirmation is asynchronous. Configure the gateway webhooks and use the Payment module's [callback URL](payment.md#paymentweboptions) and payment-request status flow to show the final result. + +## User Interface + +### Menu Items + +The SaaS module adds a top-level **SaaS** group to the "Main" menu with the following items: * **Tenants**: Tenant management page. * **Editions**: Edition management page. -`SaasHostMenuNames` and `SaasTenantMenuNames` classes have the constants for the menu item names. +The `SaasHostMenuNames` class contains the host-side menu item name constants. The tenant-side `SaasTenantMenuNames` class currently contains only its group name. ### Pages -#### Tenant management +#### Tenant Management -Tenant page is used to manage tenants in the system. +The Tenants page is used to manage tenants in the system. ![saas-module-tenants-page](../images/saas-module-tenants-page.png) @@ -113,41 +121,55 @@ You can create a new tenant or edit a tenant in this page: ![saas-module-tenant-edit-modal](../images/saas-module-tenant-edit-modal.png) +A tenant has one of the following activation states: + +- `Active`: The tenant is active without an activation deadline. +- `ActiveWithLimitedTime`: The tenant is active through `ActivationEndDate` and becomes inactive after that time. +- `Passive`: The tenant is inactive. + +An edition assignment can also have an `EditionEndDateUtc`. The stored `EditionId` is retained after this date, but `Tenant.GetActiveEditionId()` returns `null`. This lets subscription renewals retain the previous assignment while distinguishing an expired edition. + +The module caches the dynamic edition claim used by feature resolution. `EditionDynamicClaimsPrincipalContributorCacheOptions.CacheAbsoluteExpiration` controls this distributed cache entry and defaults to one hour. Updating or deleting a tenant invalidates the entry, but the passage of `EditionEndDateUtc` alone doesn't. This cache setting doesn't control the lifetime of an already issued token or principal. + ##### Connection String -You can manage connection string of a tenant in case you want to use a separate database for a specific tenant. If you want to use Host database for a tenant, select "Use the Shared Database" option. +You can manage a tenant's connection string when it should use a separate database. Select **Use the Shared Database** to remove the tenant-specific default and module-specific connection strings. Each connection then falls back to the corresponding host-side configuration. ![saas-module-tenant-connection-strings-modal](../images/saas-module-tenant-connection-strings-modal.png) -##### Module Specific Connection Strings +##### Module-Specific Connection Strings You can also use the module-specific database connection string feature. -To use this feature, you should configure the module-specific database in the `ConfigureServices` method of your module class. For example, the following code configures the `Saas` module to use a separate database for each tenant. +To use this feature, configure the module-specific database in the `ConfigureServices` method of your module class. Only databases registered with `IsUsedByTenants = true` are available through the SaaS connection-string management API and UI. The following example makes the `Saas` database available: ```csharp -Configure(options => +Configure(options => { - options.Databases.Configure("Saas", database => + options.Databases.Configure("Saas", database => { database.IsUsedByTenants = true; }); }); ``` -You should select the "Use module specific database connection string" option, then you can determine your modules and their connection strings. Before adding you can check your connection by clicking "Check". +Select **Use module specific database connection string** to configure these databases. Use the **Check** action to validate the supplied values before saving them. + +The `Volo.Saas.EnableTenantBasedConnectionStringManagement` setting controls this feature and defaults to `true`. When it is disabled, the built-in UIs hide connection-string management, tenant creation ignores supplied connection strings, and update requests are rejected. The setting is available on the SaaS tab of the Settings page to users with the `Saas.SettingManagement` permission. + +> Tenant connection strings are sensitive. The module persists and returns the values as supplied; it doesn't encrypt them before persistence. Restrict `Saas.Tenants.ManageConnectionStrings`, protect the database and event transport, and avoid logging connection-string payloads. ![saas-module-tenant-module-specific-connection-strings-modal](../images/saas-module-tenant-module-specific-connection-strings-modal.png) ##### Tenant Features -You can set features of tenants. +You can set features for a tenant. A tenant-level value overrides the value assigned to its edition. If neither level has a value, feature resolution continues with the application's configuration and the feature's default value. ![saas-module-features-modal](../images/saas-module-features-modal.png) -#### Edition management +#### Edition Management -Editions page is used to manage the editions in your system. +The Editions page is used to manage the editions in your system. ![saas-module-editions-page](../images/saas-module-editions-page.png) @@ -155,21 +177,25 @@ You can create a new edition or edit an existing edition in this page: ![saas-module-edition-edit-modal](../images/saas-module-edition-edit-modal.png) +`EditionManager` validates edition display names for uniqueness. Before deleting an edition, you can move all of its tenants to another edition. Deleting an edition without choosing a replacement clears the edition assignment for its tenants. + ##### Edition Features -You can set features of an edition in this page: +You can set features of an edition on this page: ![saas-module-features-modal](../images/saas-module-features-modal.png) -## Data seed +## Data Seed + +Commercial startup templates include an application-level [data seed contributor](../framework/infrastructure/data-seeding.md) that calls `IEditionDataSeeder.CreateStandardEditionsAsync()` during the template's database migration and data-seeding flow. Layered applications run this flow from the `.DbMigrator` application, while no-layer applications run it from the host's database migration service. It creates the following host-side data: -This module adds some initial data (see [the data seed system](../framework/infrastructure/data-seeding.md)) to the database when you run the `.DbMigrator` application: +- A `Standard` edition, if an edition with that name doesn't already exist. -* Creates an `Standard` edition. +When integrating the SaaS module into an existing solution, call this method from your own `IDataSeedContributor` if you want the same initial edition. Referencing the SaaS module alone doesn't execute this seeder. ## Internals -### Domain layer +### Domain Layer #### Aggregates @@ -177,53 +203,75 @@ This module follows the [Entity Best Practices & Conventions](../framework/archi ##### Tenant -A tenant is generally represents a group of users who share a common access with specific privileges to the software instance. +A tenant generally represents a group of users that share access to the software with tenant-specific data and privileges. * `Tenant` (aggregate root): Represents a tenant in the system. * `TenantConnectionString` (collection): Connection strings of a tenant. ##### Edition -An edition is typically a category of features of the application. +An edition is a reusable set of application feature values that can be assigned to tenants. * `Edition` (aggregate root): Represents an edition in the system. +#### Extending the Entities + +The `Tenant` and `Edition` entities support the [Module Entity Extensions](../framework/architecture/modularity/extending/module-entity-extensions.md) system. Configure them in the `Domain.Shared` project before the database model is created. The following example adds an extra property to each entity: + +```csharp +ObjectExtensionManager.Instance.Modules() + .ConfigureSaas(saas => + { + saas.ConfigureTenant(tenant => + { + tenant.AddOrUpdateProperty("ExternalId"); + }); + + saas.ConfigureEdition(edition => + { + edition.AddOrUpdateProperty("CatalogCode"); + }); + }); +``` + +The module maps the configured extra properties through its extensible application contracts. The built-in MVC, Blazor, MudBlazor and Angular UIs consume the module entity-extension metadata and display the properties in their supported tables and forms. Use the property `UI` options or an Angular UI contributor when you need to customize visibility, order or rendering. + #### Repositories This module follows the [Repository Best Practices & Conventions](../framework/architecture/best-practices/repositories.md) guide. -Following custom repositories are defined for this module: +The following custom repositories are defined for this module: * `ITenantRepository` * `IEditionRepository` -#### Domain services +#### Domain Services This module follows the [Domain Services Best Practices & Conventions](../framework/architecture/best-practices/domain-services.md) guide. -##### Tenant manager +##### Tenant and Edition Managers -`TenantManager` is used to create tenants, change and validate name of tenants. +`TenantManager` creates tenants, changes and validates tenant names, and evaluates tenant activation. `EditionManager` validates edition display names, enforces a Payment plan for subscription editions, and moves tenants between editions. -### Application layer +### Application Layer -#### Application services +#### Application Services -* `TenantAppService` (implements `ITenantAppService`): Implements the use cases of the tenant management UI. -* `EditionAppService` (implement `IEditionAppService`): Implements the use cases of the edition management UI. -* `SubscriptionAppService` (implement`ISubscriptionAppService`): Implements the use cases of Tenant-Edition subscription. +- `TenantAppService` (implements `ITenantAppService`): Implements the tenant management use cases. +- `EditionAppService` (implements `IEditionAppService`): Implements the edition management use cases. +- `SubscriptionAppService` (implements `ISubscriptionAppService`): Creates Payment subscription requests for tenant-edition subscriptions. -### Database providers +### Database Providers #### Common -##### Table / collection prefix & schema +##### Table/Collection Prefix & Schema All tables/collections use the `Saas` prefix by default. Set static properties on the `SaasDbProperties` class if you need to change the table prefix or set a schema name (if supported by your database provider). -##### Connection string +##### Connection String -This module uses `Saas` for the connection string name. If you don't define a connection string with this name, it fallbacks to the `Default` connection string. +This module uses `Saas` for the connection string name. If you don't define a connection string with this name, it falls back to the `Default` connection string. See the [connection strings](../framework/fundamentals/connection-strings.md) documentation for details. @@ -231,30 +279,53 @@ See the [connection strings](../framework/fundamentals/connection-strings.md) do ##### Tables -* **SaasTenants** - * SaasTenantConnectionStrings -* **SaasEditions** +- **SaasTenants** + - SaasTenantConnectionStrings +- **SaasEditions** + +SaaS metadata is host-side data. The EF Core model isn't added to a tenant-only database schema. #### MongoDB ##### Collections -* **SaasTenants** -* **SaasEditions** +- **SaasTenants** (connection strings are embedded in the tenant document) +- **SaasEditions** ### Permissions -See the `SaasHostPermissions` class members for all permissions defined for this module. - +All SaaS permissions are host-side permissions: + +- `Saas.SettingManagement`: Manages the SaaS settings. +- `Saas.Tenants`: Views tenant management. + - `Saas.Tenants.Create` + - `Saas.Tenants.Update` + - `Saas.Tenants.Delete` + - `Saas.Tenants.ManageFeatures` + - `Saas.Tenants.ManageConnectionStrings` + - `Saas.Tenants.SetPassword` + - `Saas.Tenants.Impersonation` + - `AuditLogging.ViewChangeHistory:Volo.Saas.Tenant` +- `Saas.Editions`: Views edition management. + - `Saas.Editions.Create` + - `Saas.Editions.Update` + - `Saas.Editions.Delete` + - `Saas.Editions.ManageFeatures` + - `AuditLogging.ViewChangeHistory:Volo.Saas.Edition` + +The two change-history permissions are disabled when [entity history](../framework/infrastructure/audit-logging.md#entity-history-selectors) isn't enabled for the corresponding entity. + +Tenant impersonation is disabled in the MVC, Blazor and MudBlazor SaaS UI options by default. See the [impersonation documentation](account/impersonation.md) for the UI and Account module configuration required to enable it. ### Angular UI #### Installation -In order to configure the application to use the saas module, you first need to import `provideSaasConfig` from `@volo/abp.ng.saas/config` to root module. Then, you will need to append it to the `appConfig` array. +Add `provideSaasConfig` from `@volo/abp.ng.saas/config` to the root application providers. It registers the menu routes, authentication filter and SaaS settings tab. -```js +```ts // app.config.ts +import { ApplicationConfig } from '@angular/core'; import { provideSaasConfig } from '@volo/abp.ng.saas/config'; export const appConfig: ApplicationConfig = { @@ -265,11 +336,13 @@ export const appConfig: ApplicationConfig = { }; ``` -The saas module should be imported and lazy-loaded in your routing configuration. It has a static `createRoutes` method for configuration. Available options are listed below. It is available for import from `@volo/abp.ng.saas`. +Lazy-load the UI with the `createRoutes` function from `@volo/abp.ng.saas`: -```js +```ts // app.routes.ts -const APP_ROUTES: Routes = [ +import { Routes } from '@angular/router'; + +export const APP_ROUTES: Routes = [ // ... { path: 'saas', @@ -283,18 +356,19 @@ const APP_ROUTES: Routes = [

Options

-You can modify the look and behavior of the module pages by passing the following options to `createRoutes` static method: +You can modify the look and behavior of the module pages by passing the following options to `createRoutes`: - **entityActionContributors:** Changes grid actions. Please check [Entity Action Extensions for Angular](../framework/ui/angular/entity-action-extensions.md) for details. - **toolbarActionContributors:** Changes page toolbar. Please check [Page Toolbar Extensions for Angular](../framework/ui/angular/page-toolbar-extensions.md) for details. - **entityPropContributors:** Changes table columns. Please check [Data Table Column Extensions for Angular](../framework/ui/angular/data-table-column-extensions.md) for details. - **createFormPropContributors:** Changes create form fields. Please check [Dynamic Form Extensions for Angular](../framework/ui/angular/dynamic-form-extensions.md) for details. -- **editFormPropContributors:** Changes create form fields. Please check [Dynamic Form Extensions for Angular](../framework/ui/angular/dynamic-form-extensions.md) for details. +- **editFormPropContributors:** Changes edit form fields. Please check [Dynamic Form Extensions for Angular](../framework/ui/angular/dynamic-form-extensions.md) for details. +Each contributor map accepts the `eSaasComponents.Editions` and `eSaasComponents.Tenants` keys. #### Services / Models -Saas module services and models are generated via `generate-proxy` command of the [ABP CLI](../cli). If you need the module's proxies, you can run the following command in the Angular project directory: +SaaS module services and models are generated via the `generate-proxy` command of the [ABP CLI](../cli). If you need the module's proxies, you can run the following command in the Angular project directory: ```bash abp generate-proxy --module saas @@ -302,47 +376,71 @@ abp generate-proxy --module saas #### Replaceable Components -`eSaasComponents` enum provides all replaceable component keys. It is available for import from `@volo/abp.ng.saas`. +`eSaasComponents` is available for import from `@volo/abp.ng.saas`. The following keys are wired to replaceable components: -Please check [Component Replacement document](../framework/ui/angular/component-replacement.md) for details. +- `eSaasComponents.Editions`: Editions page. +- `eSaasComponents.Tenants`: Tenants page. +- `eSaasComponents.ConnectionStrings` is also exported, but the current connection-strings template isn't wired to this replacement key. +- `eSaasComponents.SetTenantPassword`: Set-tenant-password modal content. +See the [Component Replacement](../framework/ui/angular/component-replacement.md) documentation for details. #### Remote Endpoint URL -The Saas module remote endpoint URLs can be configured in the environment files. +Configure the SaaS host endpoint in the environment when it is served from a different URL: -```js +```ts export const environment = { - // other configurations + // Other configurations... apis: { default: { - url: 'default url here', + url: 'https://localhost:44300', }, SaasHost: { - url: 'SaasHost remote url here' + url: 'https://localhost:44301', }, - SaasTenant: { - url: 'SaasTenant remote url here' - }, - // other api configurations + // Other API configurations... }, }; ``` -The Saas module remote URL configurations shown above are optional. If you don't set any URLs, the `default.url` will be used as fallback. - +The `SaasHost` entry is optional. If it isn't configured, the generated SaaS Angular services use `default.url`. ## Distributed Events +### Published Events + +The tenant workflows explicitly publish the following integration events: -This module defines the following ETOs (Event Transfer Objects) to allow you to subscribe to changes on the entities of the module; +- `TenantCreatedEto` after a tenant is created. When `TenantAppService.CreateAsync` creates the tenant, the event's `Properties` dictionary contains `AdminEmail` and `AdminPassword` from the request. In the shared-user strategy, tenant creation also publishes `InviteUserToTenantRequestedEto`. +- `TenantConnectionStringUpdatedEto` for default and module-specific connection-string changes. Its `OldValue` and `NewValue` properties contain the connection-string values. +- `ApplyDatabaseMigrationsEto` when database migration is requested for a tenant. +- `InviteUserToTenantRequestedEto` and `UserPasswordChangeRequestedEto` for their corresponding tenant administration actions. -- `TenantEto` is published on changes done on a `Tenant` entity. -- `EditionEto` is published on changes done on an `Edition` entity. +> Some of these events carry passwords or connection strings. Protect the event transport and storage, restrict subscribers, and don't log or retain complete payloads. -**Example: Get notified when a new tenant has been created** +### Consumed Events +The module consumes `SubscriptionCreatedEto`, `SubscriptionUpdatedEto` and `SubscriptionCanceledEto` from the Payment module as described in [Tenant-Edition Subscription](#tenant-edition-subscription), and the following additional integration events: + +- `CreateTenantEto` creates a tenant, then publishes `TenantCreatedEto` and an `InviteUserToTenantRequestedEto` that directly adds the invited user to the tenant. +- A host-side `AppliedDatabaseMigrationsEto` publishes an `ApplyDatabaseMigrationsEto` for each tenant that has a separate connection string for the migrated database. Events that already specify a tenant ID are ignored by this handler. + +### Standard Entity Events + +The module also maps `Tenant` to `TenantEto` and `Edition` to `EditionEto` for ABP's standard distributed entity events. Registering these mappings doesn't enable automatic entity events. Add the entity types to `AbpDistributedEntityEventOptions.AutoEventSelectors` in the application that owns the SaaS data when consumers need create, update or delete notifications: + +```csharp +Configure(options => +{ + options.AutoEventSelectors.Add(); + options.AutoEventSelectors.Add(); +}); ``` + +**Example: Subscribe to the opt-in tenant-created entity event** + +```csharp public class MyHandler : IDistributedEventHandler>, ITransientDependency @@ -355,9 +453,6 @@ public class MyHandler : } ``` +See the [Distributed Event Bus](../framework/infrastructure/event-bus/distributed) documentation for delivery, handlers and pre-defined entity events. - -`TenantEto` and `EditionEto` are configured to automatically publish the events. You should configure yourself for the others. See the [Distributed Event Bus document](https://github.com/abpframework/abp/blob/rel-7.3/docs/en/Distributed-Event-Bus.md) to learn details of the pre-defined events. - -> Subscribing to the distributed events is especially useful for distributed scenarios (like microservice architecture). If you are building a monolithic application, or listening events in the same process that runs the Tenant Management Module, then subscribing to the [local events](https://github.com/abpframework/abp/blob/rel-7.3/docs/en/Local-Event-Bus.md) can be more efficient and easier. - +> Distributed events are especially useful in a distributed system. For same-process notifications in a monolithic application, the [Local Event Bus](../framework/infrastructure/event-bus/local) can be simpler. diff --git a/docs/en/modules/setting-management.md b/docs/en/modules/setting-management.md index dcb021fe14..6943316296 100644 --- a/docs/en/modules/setting-management.md +++ b/docs/en/modules/setting-management.md @@ -74,6 +74,10 @@ namespace Demo So, you can get or set a setting value for different setting value providers (Default, Global, User, Tenant... etc). +The scoped `GetOrNull...` and `GetAll...` extension methods use fallback values by default. Pass `fallback: false` when you need only the value explicitly stored for the requested provider. Setting a value to `null` clears that provider's value. + +For a non-encrypted inherited setting, setting a provider value to the same value as its fallback also clears the provider record by default. The comparison is case-insensitive. Pass `forceToSet: true` to a user- or tenant-scoped `Set...` method when you intentionally need to persist the same value as the fallback. `SetForTenantOrGlobalAsync` honors this parameter only when `tenantId` has a value; its global branch calls `SetGlobalAsync`, which does not expose the parameter. For encrypted settings, fallback-equal clearing does not apply to non-empty values because they are encrypted before comparison; an empty string can still be cleared when it equals the fallback. Pass `null` explicitly when you want to clear the provider value. + > Use the `ISettingProvider` instead of the `ISettingManager` if you only need to read the setting values, because it implements caching and supports all deployment scenarios. You can use the `ISettingManager` if you are creating a setting management UI. ### Setting Cache @@ -88,9 +92,9 @@ Setting Management module is extensible, just like the [setting system](../frame * `ConfigurationSettingManagementProvider`: Gets the value from the [IConfiguration service](../framework/fundamentals/configuration.md). It can not set the configuration value because it is not possible to change the configuration values on runtime. * `GlobalSettingManagementProvider`: Gets or sets the global (system-wide) value for a setting. * `TenantSettingManagementProvider`: Gets or sets the setting value for a tenant. -* `UserSettingManagementProvider`: Gets the setting value for a user. +* `UserSettingManagementProvider`: Gets or sets the setting value for a user. -`ISettingManager` uses the setting management providers on get/set methods. Typically, every setting management provider defines extension methods on the `ISettingManagement` service (like `SetForUserAsync` defined by the user setting management provider). +`ISettingManager` uses the setting management providers on get/set methods. Typically, every setting management provider defines extension methods on the `ISettingManager` service (like `SetForUserAsync` defined by the user setting management provider). If you want to create your own provider, implement the `ISettingManagementProvider` interface or inherit from the `SettingManagementProvider` base class: @@ -119,23 +123,43 @@ Configure(options => The order of the providers are important. Providers are executed in the reverse order. That means the `CustomSettingProvider` is executed first for this example. You can insert your provider in any order in the `Providers` list. -## See Also +## Dynamic Setting Definitions -* [Settings](../framework/infrastructure/settings.md) +The module can persist setting definitions in addition to setting values. `SettingManagementOptions` controls this behavior: + +* `SaveStaticSettingsToDatabase` is `true` by default. During application initialization, definitions contributed by code are synchronized with the `SettingDefinitions` table or collection (`AbpSettingDefinitions` by default). +* `IsDynamicSettingStoreEnabled` is `false` by default. Enable it to include persisted definitions in the setting definition store at runtime. + +```csharp +Configure(options => +{ + options.IsDynamicSettingStoreEnabled = true; +}); +``` + +Both options are disabled automatically in an ABP data migration environment. Static definitions continue to come from the application's setting definition providers when the dynamic store is disabled. ## Setting Management UI -Setting Mangement module provided the Email setting, Feature management and Timezone setting UI by default. +The MVC, Blazor and MudBlazor packages provide built-in Email and Time Zone setting groups. The Angular configuration package provides the built-in Email group. Other modules can contribute their own groups to the same page. For example, the Feature Management module contributes the Feature Management group shown below. ![EmailSettingUi](../images/setting-management-email-ui.png) > You can click the Send test email button to send a test email to check your email settings. +The Email group uses `IEmailSettingsAppService`. Reading and updating the settings requires the `SettingManagement.Emailing` permission. Sending a test email additionally requires `SettingManagement.Emailing.Test`. The operations are exposed under `/api/setting-management/emailing`, with the test operation at `/api/setting-management/emailing/send-test-email`. + +Email operations also require the `SettingManagement.Enable` feature, which is `true` by default. For tenants, they additionally require its child feature, `SettingManagement.AllowChangingEmailSettings`, which is `false` by default. The read operation does not return the stored SMTP password. On update, a blank password leaves the existing password unchanged. + ![FeatureManagementUi](../images/setting-management-feature-management-ui.png) ![TimeZoneSettingUi](../images/setting-management-time-zone-ui.png) -Setting it is extensible; You can add your tabs to this page for your application settings. +The Time Zone group uses `ITimeZoneSettingsAppService` and requires the `SettingManagement.TimeZone` permission. Its HTTP API is exposed at `/api/setting-management/timezone`; `GET /api/setting-management/timezone/timezones` returns the available IANA time zones. Updating the value to `Unspecified` clears the host-global or current-tenant value so the configured fallback is used. + +The built-in MVC, Blazor and MudBlazor contributors add this group only when `IClock.SupportsMultipleTimezone` is `true`. + +The page is extensible, so you can add groups for your application's settings. ### MVC UI @@ -180,9 +204,14 @@ Create a `BookStoreSettingPageContributor.cs` file under the `Settings` folder: The content of the file is shown below: ```csharp -public class BookStoreSettingPageContributor : ISettingPageContributor +public class BookStoreSettingPageContributor : SettingPageContributorBase { - public Task ConfigureAsync(SettingPageCreationContext context) + public BookStoreSettingPageContributor() + { + RequiredPermissions("BookStore.Settings"); + } + + public override Task ConfigureAsync(SettingPageCreationContext context) { context.Groups.Add( new SettingPageGroup( @@ -195,15 +224,13 @@ public class BookStoreSettingPageContributor : ISettingPageContributor return Task.CompletedTask; } - - public Task CheckPermissionsAsync(SettingPageCreationContext context) - { - // You can check the permissions here - return Task.FromResult(true); - } } ``` +Derive from `SettingPageContributorBase` instead of directly implementing `ISettingPageContributor`. Use `RequiredPermissions`, `RequiredFeatures` and `RequiredTenantSideFeatures` in the constructor to declare the conditions for the group. The module batches these checks before calling `ConfigureAsync`. + +`SettingPageGroup` also accepts an optional `parameter` for the view component. Groups are ordered by `order`, then by display name. + Open the `BookStoreWebModule.cs` file and add the following code: ```csharp @@ -246,8 +273,13 @@ The content of the file is shown below: ```csharp public class BookStoreSettingComponentContributor : ISettingComponentContributor { - public Task ConfigureAsync(SettingComponentCreationContext context) + public async Task ConfigureAsync(SettingComponentCreationContext context) { + if (!await CheckPermissionsAsync(context)) + { + return; + } + context.Groups.Add( new SettingComponentGroup( "Volo.Abp.MySettingGroup", @@ -256,18 +288,22 @@ public class BookStoreSettingComponentContributor : ISettingComponentContributor order : 1 ) ); - - return Task.CompletedTask; } - public Task CheckPermissionsAsync(SettingComponentCreationContext context) + public async Task CheckPermissionsAsync(SettingComponentCreationContext context) { - // You can check the permissions here - return Task.FromResult(true); + var authorizationService = context.ServiceProvider + .GetRequiredService(); + + return await authorizationService.IsGrantedAsync("BookStore.Settings"); } } ``` +The settings page calls `ConfigureAsync` for every registered contributor, while menu visibility is checked separately with `CheckPermissionsAsync`. Perform the authorization check before adding the group, as in the example, and return the same result from `CheckPermissionsAsync`. + +MudBlazor provides the equivalent `ISettingComponentContributor`, `SettingComponentCreationContext`, `SettingComponentGroup` and `SettingManagementComponentOptions` types in the `Volo.Abp.SettingManagement.Blazor.MudBlazor` namespace. Use those types for a MudBlazor UI. In both Blazor UI stacks, a group accepts an optional component `parameter` and is ordered by `order`, then by display name. + Open the `BookStoreBlazorModule.cs` file and add the following code: ```csharp @@ -293,34 +329,72 @@ Create a component with the following command: yarn ng generate component my-settings ``` -Open the `app.component.ts` and modify the file as shown below: +Register the module configuration and your setting tab in `app.config.ts`: -```js -import { Component, inject } from '@angular/core'; -import { SettingTabsService } from '@abp/ng.setting-management/config'; +```ts +import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; +import { + provideSettingManagementConfig, + SettingTabsService, +} from '@abp/ng.setting-management/config'; import { MySettingsComponent } from './my-settings/my-settings.component'; -@Component({ - // component metadata -}) -export class AppComponent { - private readonly settingTabs = inject(SettingTabsService); - - constructor() { - this.settingTabs.add([ - { - name: 'MySettings', - order: 1, - requiredPolicy: 'policy key here', - component: MySettingsComponent, - }, - ]); - } +function configureSettingTabs() { + const settingTabs = inject(SettingTabsService); + settingTabs.add([ + { + name: 'MySettings', + order: 1, + requiredPolicy: 'BookStore.Settings', + component: MySettingsComponent, + }, + ]); } + +export const appConfig: ApplicationConfig = { + providers: [ + provideSettingManagementConfig(), + provideAppInitializer(configureSettingTabs), + ], +}; ``` +`provideSettingManagementConfig` registers the route, the built-in Email tab and route visibility. The Administration -> Settings route is visible only when it has at least one visible tab and the `SettingManagement.Enable` feature is enabled. A tab can use `requiredPolicy` and `invisible` to control its own visibility. + +The `@abp/ng.setting-management` package exports the `SettingManagementComponent`, `createRoutes` and the `eSettingManagementComponents.SettingManagement` key. Use the key with the [component replacement system](../framework/ui/angular/component-replacement.md) when you need to replace the complete Setting Management page. `SettingManagementModule.forLazy` and `SettingManagementConfigModule.forRoot` are obsolete; use `createRoutes` and `provideSettingManagementConfig` in standalone applications. + #### Run the Application Navigate to `/setting-management` route to see the changes: ![Custom Settings Tab](../images/custom-settings.png) + +## Database Providers + +The Entity Framework Core and MongoDB packages persist setting values and setting definition records. + +### Common + +`AbpSettingManagementDbProperties` exposes the common persistence configuration: + +* `DbTablePrefix` is the prefix used for EF Core tables and MongoDB collections. It defaults to `AbpCommonDbProperties.DbTablePrefix`. +* `DbSchema` is the schema used by EF Core. MongoDB does not use this value. +* `ConnectionStringName` is `AbpSettingManagement`. Define this named connection string to place the module in a separate database; otherwise, it falls back to the `Default` connection string. + +### Entity Framework Core + +The Entity Framework Core provider maps the following tables with the default prefix: + +* `AbpSettings` +* `AbpSettingDefinitions` + +### MongoDB + +The MongoDB provider maps the following collections with the default prefix: + +* `AbpSettings` +* `AbpSettingDefinitions` + +## See Also + +* [Settings](../framework/infrastructure/settings.md) diff --git a/docs/en/modules/tenant-management.md b/docs/en/modules/tenant-management.md index a8b56d0c6b..101c7f076f 100644 --- a/docs/en/modules/tenant-management.md +++ b/docs/en/modules/tenant-management.md @@ -37,7 +37,7 @@ In this page, you see the all the tenants. You can create a new tenant as shown In this modal; -* **Name**: The unique name of the tenant. If you use subdomains for your tenants (like https://some-tenant.your-domain.com), this will be the subdomain name. +* **Name**: The tenant name. Tenant names are normalized by the `ITenantNormalizer` service, so duplicate-name validation is case-insensitive by default. This validation is not a database uniqueness constraint. If you use subdomains for your tenants (like https://some-tenant.your-domain.com), this will be the subdomain name. * **Admin Email Address**: Email address of the admin user for this tenant. * **Admin Password**: The password of the admin user for this tenant. @@ -55,13 +55,53 @@ The Features action opens a modal to enable/disable/set [features](../framework/ *Manage Host features* button is used to set features for the host side, if you use the features of your application also in the host side. +### Extending the Angular UI + +For a standalone Angular application, register the Tenant Management menu configuration with `provideTenantManagementConfig` from the `@abp/ng.tenant-management/config` package: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideTenantManagementConfig } from '@abp/ng.tenant-management/config'; + +export const appConfig: ApplicationConfig = { + providers: [provideTenantManagementConfig()], +}; +``` + +Lazy-load the module routes with `createRoutes` from `@abp/ng.tenant-management`: + +```ts +import { Routes } from '@angular/router'; + +export const APP_ROUTES: Routes = [ + { + path: 'tenant-management', + loadChildren: () => + import('@abp/ng.tenant-management').then(m => m.createRoutes()), + }, +]; +``` + +`createRoutes` accepts `entityActionContributors`, `toolbarActionContributors`, `entityPropContributors`, `createFormPropContributors` and `editFormPropContributors`. See the Angular guides for [entity actions](../framework/ui/angular/entity-action-extensions.md), [page toolbars](../framework/ui/angular/page-toolbar-extensions.md), [table columns](../framework/ui/angular/data-table-column-extensions.md) and [dynamic forms](../framework/ui/angular/dynamic-form-extensions.md). + +The `eTenantManagementComponents.Tenants` key identifies the Tenants page for these contributors and for [component replacement](../framework/ui/angular/component-replacement.md). + ## Distributed Events -This module defines the following ETOs (Event Transfer Objects) to allow you to subscribe to changes on the entities of the module; +`TenantAppService.CreateAsync` explicitly publishes a `TenantCreatedEto` after it persists a new tenant. Its `Properties` dictionary contains the `AdminEmail` and plain-text `AdminPassword` values supplied by the create request. Treat this event as sensitive: protect it in transit and at rest, and do not log or retain the complete payload. The Framework's EF Core migration/seeding and MongoDB seeding handlers can consume this event when they are configured by the application. -- `TenantEto` is published on changes done on an `Tenant` entity. +`TenantCreatedEto` and `EntityCreatedEto` are separate distributed events. Enabling the automatic selector below publishes `EntityCreatedEto` in addition to the explicitly published `TenantCreatedEto`. If you subscribe to both, ensure that non-idempotent handlers distinguish between them instead of treating them as the same notification. -**Example: Get notified when a new tenant has been created** +The module also defines a `TenantEto` and pre-configures the mapping from `Tenant`. However, ABP does not automatically publish distributed entity-change events by default. Enable them in the application that owns Tenant Management if you want to subscribe to `EntityCreatedEto`, `EntityUpdatedEto` or `EntityDeletedEto`: + +```csharp +Configure(options => +{ + options.AutoEventSelectors.Add(); +}); +``` + +**Example: Subscribe to the opt-in entity-created event** ```cs public class MyHandler : @@ -76,11 +116,9 @@ public class MyHandler : } ``` +See the [Distributed Event Bus](../framework/infrastructure/event-bus/distributed) documentation for details of pre-defined entity events and automatic event selectors. - -`TenantEto` is configured to automatically publish the events. You should configure yourself for the others. See the [Distributed Event Bus document](https://github.com/abpframework/abp/blob/rel-7.3/docs/en/Distributed-Event-Bus.md) to learn details of the pre-defined events. - -> Subscribing to the distributed events is especially useful for distributed scenarios (like microservice architecture). If you are building a monolithic application, or listening events in the same process that runs the Tenant Management Module, then subscribing to the [local events](https://github.com/abpframework/abp/blob/rel-7.3/docs/en/Local-Event-Bus.md) can be more efficient and easier. +> Subscribing to distributed events is especially useful for distributed scenarios (like microservice architecture). If you are building a monolithic application, or listening for events in the same process that runs the Tenant Management module, subscribing to [local events](../framework/infrastructure/event-bus/local) can be more efficient and easier. ## Internals @@ -100,12 +138,16 @@ This section can be used as a reference if you want to [customize](../framework/ * `TenantManager` +`TenantManager` normalizes names on create and rename, and uses `ITenantValidator` to reject duplicate normalized names during those operations. + ### Application Layer #### Application Services * `TenantAppService` +In addition to tenant CRUD operations, `ITenantAppService` provides `GetDefaultConnectionStringAsync`, `UpdateDefaultConnectionStringAsync` and `DeleteDefaultConnectionStringAsync`. The HTTP API exposes these operations as `GET`, `PUT` and `DELETE` on `/api/multi-tenancy/tenants/{id}/default-connection-string`; the `PUT` request receives `defaultConnectionString` as a query parameter. + #### Permissions - `AbpTenantManagement.Tenants`: Tenant management. @@ -113,6 +155,19 @@ This section can be used as a reference if you want to [customize](../framework/ - `AbpTenantManagement.Tenants.Update`: Editing an existing tenant. - `AbpTenantManagement.Tenants.Delete`: Deleting an existing tenant. - `AbpTenantManagement.Tenants.ManageFeatures`: Manage features of the tenants. +- `AbpTenantManagement.Tenants.ManageConnectionStrings`: Read, update or delete the default connection string of a tenant. + +All Tenant Management permissions are available only on the host side. + +### Database Configuration + +`AbpTenantManagementDbProperties` exposes the common persistence configuration: + +* `DbTablePrefix` is the prefix used for EF Core tables and MongoDB collections. It defaults to the common ABP database prefix. +* `DbSchema` is the schema used by EF Core. MongoDB does not use this value. +* `ConnectionStringName` is `AbpTenantManagement`. Define this named connection string to place the module in a separate database; otherwise, it falls back to the `Default` connection string. + +See the [Connection Strings](../framework/fundamentals/connection-strings.md) documentation for named connection-string configuration and fallback behavior. ### Entity Framework Core Integration @@ -133,7 +188,9 @@ This section can be used as a reference if you want to [customize](../framework/ ## Notices -ABP allows to use *database per tenant* approach that allows a tenant can have a dedicated database. This module has the fundamental infrastructure to make that implementation possible (see its source code), however it doesn't implement the application layer and UI functionalities to provide it as an out of the box implementation. You can implement these features yourself, or consider to use the [ABP Saas Module](./saas.md) that fully implements it and provides much more business features. +ABP supports the *database per tenant* approach. This module can store a tenant's default connection string through `ITenantAppService` and the corresponding HTTP API. Its built-in MVC, Blazor, MudBlazor and Angular UIs do not provide a connection-string management screen, and changing the stored value does not create or migrate the tenant database. + +You can build your own UI and migration workflow on top of the application/API contract, or use the [ABP SaaS Module](./saas.md), which provides connection-string management and additional business features. ## See Also diff --git a/docs/en/modules/text-template-management.md b/docs/en/modules/text-template-management.md index 32b7cc31a9..6941ec34af 100644 --- a/docs/en/modules/text-template-management.md +++ b/docs/en/modules/text-template-management.md @@ -9,19 +9,21 @@ > You must have an [ABP Team or a higher license](https://abp.io/pricing) to use this module. -This module is used to store and edit template contents for [the text templating system](../framework/infrastructure/text-templating/index.md) of the ABP. So, you may need to understand it to better understand the purpose of this module. +This module stores and lets users edit content for ABP's [text templating system](../framework/infrastructure/text-templating/index.md). Read the text templating documentation to understand how applications define and render templates. -There are different use cases of the text templating system. For example, [the Account Module](account.md) is using it to define templates for sending emails when it needs to send emails to users (like sending "password reset link" email). This module provides UI to easily edit these email templates. +Applications use text templates for many purposes. For example, the [Account Module](account.md) defines templates for emails such as password reset messages. This module provides a UI for editing those templates. See [the module description page](https://abp.io/modules/Volo.TextTemplateManagement) for an overview of the module features. +The `TextManagement.Enable` feature is enabled by default. The module's permissions, application services and menu items require this feature. + ## How to Install -Text Template Management module is pre-installed in [the startup templates](../solution-templates). So, no need to manually install it. +The Text Template Management module is pre-installed in [the startup templates](../solution-templates), so you do not need to install it manually. ### Existing Solutions -If you want to add the **Text Template Management** module to your existing solution, you can use the ABP CLI `add-module` command: +To add the **Text Template Management** module to an existing solution, use the ABP CLI `add-module` command: ```bash abp add-module Volo.TextTemplateManagement @@ -31,7 +33,7 @@ abp add-module Volo.TextTemplateManagement This module follows the [module development best practices guide](../framework/architecture/best-practices) and consists of several NuGet and NPM packages. See the guide if you want to understand the packages and relations between them. -You can visit [Text Template Management module package list page](https://abp.io/packages?moduleName=Volo.TextTemplateManagement) to see list of packages related with this module. +Visit the [Text Template Management module package list](https://abp.io/packages?moduleName=Volo.TextTemplateManagement) to see the packages related to this module. ## User Interface @@ -47,11 +49,11 @@ Text Template Management module adds the following items to the "Main" menu, und #### Text Templates -Text Templates page is used to view the list of templates defined in the application. +The Text Templates page lists the templates defined in the application. ![text-template-management-text-templates-page](../images/text-template-management-text-templates-page.png) -Click to the `Actions -> Edit Contents` to edit content for a template. There are two types of UI to edit a template content: +Click `Actions > Edit Contents` to edit a template. The module provides two editors: ##### Editing Content for Inline Localized Templates @@ -65,23 +67,45 @@ This kind of templates provides different content for each culture. In this way, ![text-template-management-multiple-culture-edit](../images/text-template-management-multiple-culture-edit.png) +Saved content is an override for the current tenant context. The host and each tenant have separate overrides; a tenant doesn't inherit an override saved in the host context. If no database override exists, the text templating system continues with the configured content contributors, such as the template's virtual-file content. + +For a static template, the framework content provider first tries the requested regional culture and then its parent culture. An inline-localized template then falls back to its culture-independent content, while a culture-specific template falls back to its configured default culture. + +Dynamic definitions use a different lookup order. When `IsDynamicTemplateStoreEnabled` is enabled and a definition exists only in the database, `DatabaseTemplateContentContributor` tries the requested culture, the definition's default culture (or `en` when it has no default), and then culture-independent content. This lookup can return content before the framework tries the requested culture's parent. **Restore to default** deletes the current tenant context's override for the selected template and culture, after which the applicable fallback path is used again. + +### UI Extension Points + +The MVC UI uses `textTemplateManagement.textDefinition` for both [entity action extensions](../framework/ui/mvc-razor-pages/entity-action-extensions.md) and [data table column extensions](../framework/ui/mvc-razor-pages/data-table-column-extensions.md). + +The Blazorise UI exposes entity actions and table columns through the `TextTemplateManagement` page component type. See the Blazor [entity action](../framework/ui/blazor/entity-action-extensions.md) and [data table column](../framework/ui/blazor/data-table-column-extensions.md) extension documents. + ## Configure `TextTemplateManagementOptions` -`TextTemplateManagementOptions` can be used to configure the module. You can use the below code to configure it in the ConfigureServices method of your module (eg: BookStoreApplicationModule). +`TextTemplateManagementOptions` can be used to configure the module. The following example shows the default values: ```csharp Configure(options => { - options.MinimumCacheDuration = TimeSpan.FromHours(1); + options.MinimumCacheDuration = TimeSpan.FromHours(1); + options.SaveStaticTemplatesToDatabase = true; + options.IsDynamicTemplateStoreEnabled = false; }); ``` +| Property | Description | +| --- | --- | +| `MinimumCacheDuration` | Sets the sliding expiration of cached template contents. The default is one hour. | +| `SaveStaticTemplatesToDatabase` | Starts synchronizing static template definitions and their virtual-file contents to the database during application initialization. The default is `true`. | +| `IsDynamicTemplateStoreEnabled` | Enables the database-backed dynamic template-definition store. The default is `false`. Static definitions take precedence when static and dynamic definitions share the same name. | + +Both `SaveStaticTemplatesToDatabase` and `IsDynamicTemplateStoreEnabled` are disabled automatically in a data-migration environment. Disabling static synchronization doesn't disable saved content overrides. + ## Caching -[`DatabaseTemplateContentContributor`](#DatabaseTemplateContentContributor) caches template contents to increase performance. +`DatabaseTemplateContentContributor` caches template contents to increase performance. Cache entries use the `MinimumCacheDuration` sliding expiration. -You can get cache store by injecting `IDistributedCache`. +You can access the cache by injecting `IDistributedCache`. `TemplateContentCacheKey` contains the template definition name and culture; ABP's distributed cache key normalization also isolates entries by the current tenant context. The application service removes the related cache entry when it persists a new override or deletes one through **Restore to default**, so an application restart isn't required. For more information, please check the [Caching](../framework/fundamentals/caching.md) guide. @@ -93,35 +117,35 @@ It has `TemplateDefinitionName` and `Culture` properties. ## Data Seed -This module doesn't seed any data. +This module doesn't define an `IDataSeedContributor`. The optional startup synchronization controlled by `SaveStaticTemplatesToDatabase` is separate from the data seed system. ## Internals ### Domain Layer -#### Aggregates +#### Entities and Aggregate Roots This module follows the [Entity Best Practices & Conventions](../framework/architecture/best-practices/entities.md) guide. -##### TextTemplateContent +##### Domain Model Types -* `TextTemplateContent` (aggregate root): Represents a content of text template. +* `TextTemplateContent` (aggregate root): Represents a tenant-aware template content override. +* `TextTemplateDefinitionRecord` (aggregate root): Stores synchronized template definition metadata. +* `TextTemplateDefinitionContentRecord` (entity): Stores a synchronized definition's file content. #### Repositories This module follows the [Repository Best Practices & Conventions](../framework/architecture/best-practices/repositories.md) guide. -Following custom repositories are defined for this module: +The following custom repositories are defined for this module: * `ITextTemplateContentRepository` +* `ITextTemplateDefinitionRecordRepository` +* `ITextTemplateDefinitionContentRecordRepository` -#### Domain Services +#### Template Content Contributor -This module follows the [Domain Services Best Practices & Conventions](../framework/architecture/best-practices/domain-services.md) guide. - -##### DatabaseTemplateContentContributor - -`DatabaseTemplateContentContributor` is used by `ITemplateContentProvider` to get template contents that stored in DB and Cache. +`DatabaseTemplateContentContributor` is an `ITemplateContentContributor` used by `ITemplateContentProvider` to read tenant-aware content overrides and, when the dynamic definition store is enabled, synchronized definition content from the database and cache. ### Settings @@ -144,7 +168,7 @@ All tables/collections use the `Abp` prefix by default. Set static properties on ##### Connection String -This module uses `TextTemplateManagement` for the connection string name. If you don't define a connection string with this name, it fallbacks to the `Default` connection string. +This module uses `TextTemplateManagement` as the connection string name. If you don't define a connection string with this name, it falls back to the `Default` connection string. See the [connection strings](../framework/fundamentals/connection-strings.md) documentation for details. @@ -153,25 +177,32 @@ See the [connection strings](../framework/fundamentals/connection-strings.md) do ##### Tables * **AbpTextTemplateContents** +* **AbpTextTemplateDefinitionRecords** +* **AbpTextTemplateDefinitionContentRecords** #### MongoDB ##### Collections -* **AbpTextTemplateContents** +* **AbpTextTemplates** +* **AbpTextTemplateDefinitionRecords** +* **AbpTextTemplateDefinitionContentRecords** ### Permissions -See the `TextTemplateManagementPermissions` class members for all permissions defined for this module. +All permissions require the `TextManagement.Enable` feature. + +The module defines the following permissions: -The module exposes two edit-time permissions with different risk levels: +| Permission | Purpose | +| --- | --- | +| `TextTemplateManagement.TextTemplates` | View and filter template definitions and read their contents. | +| `TextTemplateManagement.TextTemplates.EditContents` | Edit and restore template content. | +| `TextTemplateManagement.TextTemplates.EditNonSandboxedContents` | Additionally authorize editing templates rendered by a non-sandboxed engine, such as Razor. | -| Permission | Required to edit | Default grant | -|------------|------------------|---------------| -| `TextTemplateManagement.TextTemplates.EditContents` | Templates rendered by a sandboxed engine (e.g. Scriban). Editing such templates is safe for content editors because the engine cannot execute arbitrary .NET code. | Granted to roles that need to edit template text. | -| `TextTemplateManagement.TextTemplates.EditNonSandboxedContents` | Templates rendered by a **non-sandboxed** engine (e.g. Razor). Editing such templates is functionally equivalent to granting server-side code execution because the engine compiles the content into a .NET assembly that runs with the host process's privileges. | **Not granted to any role by default**, including `admin`. Must be granted explicitly. | +Whether a template is sandboxed is determined by `ITemplateRenderingEngine.IsSandboxed` on the engine that renders it. An unknown, unregistered or unresolved default engine is treated as non-sandboxed. Editing a non-sandboxed template requires **both** `EditContents` and `EditNonSandboxedContents`. -Whether a template is sandboxed is determined by `ITemplateRenderingEngine.IsSandboxed` on the engine that renders it. Editing a non-sandboxed template requires **both** `EditContents` and `EditNonSandboxedContents`. +When the standard Permission Management data seeder runs, it grants all role-applicable permissions to the `admin` role. This includes `EditNonSandboxedContents`. If the administrators in your application shouldn't be allowed to edit executable template content, customize the seeding policy and remove any existing grant. The Text Template Management UI surfaces this distinction: @@ -185,39 +216,42 @@ The Text Template Management UI surfaces this distinction: #### Installation -In order to configure the application to use the text template management module, you first need to import `provideTextTemplateManagementConfig` from `@volo/abp.ng.text-template-management/config` to root configuration. Then, you will need to append it to the `appConfig` array. +To configure the application to use the text template management module, import `provideTextTemplateManagementConfig` from `@volo/abp.ng.text-template-management/config` and add it to the root `providers` array. -```js +```ts // app.config.ts +import { ApplicationConfig } from '@angular/core'; import { provideTextTemplateManagementConfig } from '@volo/abp.ng.text-template-management/config'; export const appConfig: ApplicationConfig = { providers: [ // ... - provideTextTemplateManagementConfig() + provideTextTemplateManagementConfig(), ], }; ``` -The text template management module should be imported and lazy-loaded in your routing array. It has a static `createRoutes` method for configuration. Available options are listed below. It is available for import from `@volo/abp.ng.text-template-management`. +The text template management module should be imported and lazy-loaded in your routing array. It exports a `createRoutes` function from `@volo/abp.ng.text-template-management`. Available options are listed below. -```js +```ts // app.routes.ts +import { Routes } from '@angular/router'; + const APP_ROUTES: Routes = [ // ... { path: 'text-template-management', loadChildren: () => - import('@volo/abp.ng.text-template-management').then(c => c.createRoutes(/* options here */)), + import('@volo/abp.ng.text-template-management').then(c => c.createRoutes()), }, ]; ``` -> If you have generated your project via the startup template, you do not have to do anything, because it already has both configurations implemented. +> Startup templates already include both settings, so no additional configuration is needed.

Options

-You can modify the look and behavior of the module pages by passing the following options to `createRoutes` static method: +You can modify the look and behavior of the module pages by passing the following options to the `createRoutes` function: - **entityActionContributors:** Changes grid actions. Please check [Entity Action Extensions for Angular](../framework/ui/angular/entity-action-extensions.md) for details. - **toolbarActionContributors:** Changes page toolbar. Please check [Page Toolbar Extensions for Angular](../framework/ui/angular/page-toolbar-extensions.md) for details. @@ -226,7 +260,7 @@ You can modify the look and behavior of the module pages by passing the followin #### Services / Models -Text Template Management module services and models are generated via `generate-proxy` command of the [ABP CLI](../cli). If you need the module's proxies, you can run the following command in the Angular project directory: +The Text Template Management module's services and models are generated by the [ABP CLI](../cli) `generate-proxy` command. To generate the module's proxies, run the following command in the Angular project directory: ```bash abp generate-proxy --module textTemplateManagement @@ -238,6 +272,12 @@ abp generate-proxy --module textTemplateManagement `eTextTemplateManagementComponents` enum provides all replaceable component keys. It is available for import from `@volo/abp.ng.text-template-management`. +The available keys are: + +* `eTextTemplateManagementComponents.TextTemplates` +* `eTextTemplateManagementComponents.TemplateContents` +* `eTextTemplateManagementComponents.InlineTemplateContent` + Please check [Component Replacement document](../framework/ui/angular/component-replacement.md) for details. @@ -245,7 +285,7 @@ Please check [Component Replacement document](../framework/ui/angular/component- The Text Template Management module remote endpoint URL can be configured in the environment files. -```js +```ts export const environment = { // other configurations apis: { @@ -253,14 +293,14 @@ export const environment = { url: 'default url here', }, TextTemplateManagement: { - url: 'Text Template Management remote url here' - } + url: 'Text Template Management remote url here', + }, // other api configurations }, }; ``` -The Text Template Management module remote URL configuration shown above is optional. If you don't set a URL, the `default.url` will be used as fallback. +The Text Template Management module's remote URL configuration is optional. If you don't set a URL, `default.url` is used as a fallback. ## Distributed Events diff --git a/docs/en/modules/virtual-file-explorer.md b/docs/en/modules/virtual-file-explorer.md index a891e57140..352bbd0504 100644 --- a/docs/en/modules/virtual-file-explorer.md +++ b/docs/en/modules/virtual-file-explorer.md @@ -55,7 +55,7 @@ Or you can also manually install nuget package to `Acme.MyProject.Web` project: ##### 2.2- Adding NPM Package - * Open `package.json` and add `@abp/virtual-file-explorer": "^2.9.0` as shown below: + * Open `package.json` and add `@abp/virtual-file-explorer` with the same version as the other `@abp` packages, as shown below: ```json { @@ -63,8 +63,8 @@ Or you can also manually install nuget package to `Acme.MyProject.Web` project: "name": "my-app", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "^2.9.0", - "@abp/virtual-file-explorer": "^2.9.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0", + "@abp/virtual-file-explorer": "~10.6.0" } } ``` @@ -91,4 +91,4 @@ public override void PreConfigureServices(ServiceConfigurationContext context) options.IsEnabled = false; }); } -``` \ No newline at end of file +``` diff --git a/docs/en/multi-lingual-entities.md b/docs/en/multi-lingual-entities.md index b0419fe2a1..7028d8c6c4 100644 --- a/docs/en/multi-lingual-entities.md +++ b/docs/en/multi-lingual-entities.md @@ -1,6 +1,105 @@ -# Multi Lingual Entities +```json +//[doc-seo] +{ + "Description": "Use ABP multi-lingual objects to select translations by culture, apply fallback rules, and process translations in bulk." +} +``` -This feature is still under development. -Follow the below link to get information about the development status +# Multi-Lingual Objects -https://github.com/abpframework/abp/issues/11698 +> The `Volo.Abp.MultiLingualObject` package is **not published on NuGet**. It was removed from the release pipeline in [#8271](https://github.com/abpframework/abp/pull/8271) and re-designing this feature is tracked in [#11698](https://github.com/abpframework/abp/issues/11698). The source code is still maintained in the framework repository, so you can copy the [Volo.Abp.MultiLingualObjects](https://github.com/abpframework/abp/tree/dev/framework/src/Volo.Abp.MultiLingualObjects) project into your solution if you want to use the pattern described below. + +The `Volo.Abp.MultiLingualObjects` project provides a contract and a selection service for objects that store one translation per language. Persistence mapping is application-specific; it does not create a database relationship for the translations. + +## Getting the Code + +Copy the [Volo.Abp.MultiLingualObjects](https://github.com/abpframework/abp/tree/dev/framework/src/Volo.Abp.MultiLingualObjects) project into your solution and reference it from the project that defines the consuming module. + +Add `AbpMultiLingualObjectsModule` as a dependency of that module: + +````csharp +using Volo.Abp.Modularity; +using Volo.Abp.MultiLingualObjects; + +[DependsOn(typeof(AbpMultiLingualObjectsModule))] +public class MyApplicationModule : AbpModule +{ +} +```` + +## Define a Multi-Lingual Object + +Implement `IMultiLingualObject` on the object and `IObjectTranslation` on its translation type: + +```csharp +using System.Collections.Generic; +using Volo.Abp.MultiLingualObjects; + +public class Product : IMultiLingualObject +{ + public ICollection Translations { get; set; } = + new List(); +} + +public class ProductTranslation : IObjectTranslation +{ + public string Language { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; +} +``` + +`Language` stores a culture name such as `en`, `en-US` or `tr`. + +## Select a Translation + +Inject `IMultiLingualObjectManager` and call `GetTranslationAsync`: + +```csharp +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiLingualObjects; + +public class ProductService + : ITransientDependency +{ + private readonly IMultiLingualObjectManager _multiLingualObjectManager; + + public ProductService( + IMultiLingualObjectManager multiLingualObjectManager) + { + _multiLingualObjectManager = multiLingualObjectManager; + } + + public Task GetTranslationAsync(Product product) + { + return _multiLingualObjectManager + .GetTranslationAsync(product); + } +} +``` + +With the default arguments, the manager uses `CultureInfo.CurrentUICulture.Name`. It selects translations in this order: + +1. An exact translation for the current UI culture. +2. A translation for a parent of the current UI culture when `fallbackToParentCultures` is `true`. +3. A translation for the language configured by `LocalizationSettingNames.DefaultLanguage`. +4. The first available translation. + +The method returns `null` when the collection is null or empty. To disable only the parent-culture fallback, set `fallbackToParentCultures` to `false`. The default-language and first-available fallbacks still apply. + +## Select Translations in Bulk + +Use `GetBulkTranslationsAsync` to select translations for multiple objects with the same culture and fallback settings: + +```csharp +var results = await _multiLingualObjectManager + .GetBulkTranslationsAsync(products); + +foreach (var (product, translation) in results) +{ + // Use product and its selected translation. +} +``` + +Each result keeps the source object together with its selected translation. An object with no translations gets a `null` translation. diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md index 98ee15b919..216dcf6e05 100644 --- a/docs/en/package-version-changes.md +++ b/docs/en/package-version-changes.md @@ -7,6 +7,77 @@ # Package Version Changes +## 10.6.0-rc.1 + +| Package | Old Version | New Version | PR | +|---------|-------------|-------------|-----| +| HtmlSanitizer | 9.0.886 | 9.0.892 | #25811 | +| Magick.NET-Q16-AnyCPU | 14.13.0 | 14.15.0 | #25811 | +| MailKit | 4.13.0 | 4.17.0 | #25811 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Authentication.OpenIdConnect | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Authorization | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.Authorization | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.Web | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.WebAssembly | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.WebAssembly.Authentication | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.WebAssembly.DevServer | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Components.WebAssembly.Server | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.DataProtection.StackExchangeRedis | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Mvc.NewtonsoftJson | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.Mvc.Testing | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.TestHost | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.AspNetCore.WebUtilities | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Bcl.AsyncInterfaces | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Data.SqlClient | 6.1.1 | 7.0.2 | #25706 | +| Microsoft.Data.Sqlite | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.Design | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.InMemory | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.Proxies | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.Relational | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.SqlServer | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.Sqlite | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Caching.Hybrid | 9.9.0 | 10.7.0 | #25706 | +| Microsoft.Extensions.Caching.Memory | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Caching.StackExchangeRedis | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Configuration.Binder | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Configuration.CommandLine | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Configuration.EnvironmentVariables | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Configuration.UserSecrets | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.DependencyInjection | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.DependencyInjection.Abstractions | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.FileProviders.Composite | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.FileProviders.Embedded | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.FileProviders.Physical | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.FileSystemGlobbing | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Hosting | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Hosting.Abstractions | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Http | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Identity.Core | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Localization | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Logging | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Logging.Abstractions | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Logging.Console | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Options | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.Extensions.Options.ConfigurationExtensions | 10.0.7 | 10.0.9 | #25706 | +| Microsoft.IdentityModel.JsonWebTokens | 8.16.0 | 8.19.1 | #25706 | +| Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.16.0 | 8.19.1 | #25706 | +| Microsoft.IdentityModel.Tokens | 8.16.0 | 8.19.1 | #25706 | +| MongoDB.Driver | 3.9.0 | 3.10.0 | #25773 | +| Scriban | 7.2.1 | 7.2.5 | #25811 | +| System.Collections.Immutable | 10.0.7 | 10.0.9 | #25706 | +| System.IdentityModel.Tokens.Jwt | 8.16.0 | 8.19.1 | #25706 | +| System.Management | 10.0.7 | 10.0.9 | #25706 | +| System.Security.Cryptography.Xml | 10.0.7 | 10.0.9 | #25706 | +| System.Security.Permissions | 10.0.7 | 10.0.9 | #25706 | +| System.Text.Encoding.CodePages | 10.0.7 | 10.0.9 | #25706 | +| System.Text.Encodings.Web | 10.0.7 | 10.0.9 | #25706 | +| System.Text.Json | 10.0.7 | 10.0.9 | #25706 | + ## 10.5.1 | Package | Old Version | New Version | PR | diff --git a/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md b/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md index f19449d04f..ddae40e1d0 100644 --- a/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md +++ b/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md @@ -95,8 +95,8 @@ public partial class OrganizationUnitRoleToOrganizationUnitRoleDtoMapper : TwoWa public override partial OrganizationUnitRoleDto Map(OrganizationUnitRole source); public override partial void Map(OrganizationUnitRole source, OrganizationUnitRoleDto destination); - public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto destination); - public override partial void ReverseMap(OrganizationUnitRoleDto destination, OrganizationUnitRole source); + public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto source); + public override partial void ReverseMap(OrganizationUnitRoleDto source, OrganizationUnitRole destination); } [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] @@ -306,8 +306,8 @@ public partial class OrganizationUnitRoleToOrganizationUnitRoleDtoMapper : TwoWa public override partial OrganizationUnitRoleDto Map(OrganizationUnitRole source); public override partial void Map(OrganizationUnitRole source, OrganizationUnitRoleDto destination); - public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto destination); - public override partial void ReverseMap(OrganizationUnitRoleDto destination, OrganizationUnitRole source); + public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto source); + public override partial void ReverseMap(OrganizationUnitRoleDto source, OrganizationUnitRole destination); } [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] diff --git a/docs/en/release-info/migration-guides/abp-10-5.md b/docs/en/release-info/migration-guides/abp-10-5.md index 634506e7c9..4727824d08 100644 --- a/docs/en/release-info/migration-guides/abp-10-5.md +++ b/docs/en/release-info/migration-guides/abp-10-5.md @@ -171,7 +171,7 @@ Keep `DisablePayloadSigning` disabled for providers that support the default AWS **What changed** -- Blazorise packages were upgraded to **2.1.3**. +- Blazorise packages were upgraded to **2.2.1**. - MongoDB.Driver was upgraded to **3.9.0**. - `@abp/codemirror` was updated to CodeMirror **6.0.2**. diff --git a/docs/en/release-info/migration-guides/abp-10-6-angular-22.md b/docs/en/release-info/migration-guides/abp-10-6-angular-22.md new file mode 100644 index 0000000000..8da4026c96 --- /dev/null +++ b/docs/en/release-info/migration-guides/abp-10-6-angular-22.md @@ -0,0 +1,193 @@ +```json +//[doc-seo] +{ + "Description": "Upgrade your ABP solutions to Angular version 22.0.x" +} +``` + +# Angular 22 and ABP 10.6 Upgrade Guide + +This guide explains how to upgrade ABP Angular applications to **Angular 22** and **TypeScript 6**. + +## 1. Target Versions + +Update all frontend dependencies together to maintain compatibility: + +- `@angular/*` → `~22.0.0` +- `typescript` → `~6.0.0` +- `@abp/*` → corresponding ABP version (10.6) +- `@volo/*`, `@volosoft/*` (if applicable) → corresponding ABP version (10.6) +- `angular-oauth2-oidc` (if applicable) → `~22.0.0` + +Avoid mixing Angular 21 and Angular 22 packages within the same workspace. + +## 2. Prerequisites + +Before starting the upgrade: + +1. Use a Node.js version supported by Angular 22. +2. Ensure your backend ABP version is compatible with the frontend package versions you plan to install. +3. Commit or back up your current project state. + +## 3. Upgrade Process + +1. Update package versions in `package.json`. +2. Run the Angular or Nx migration commands applicable to your project. +3. Remove existing installation artifacts: + - Delete `node_modules` + - Delete the lock file (`package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml`) + +4. Reinstall all dependencies. +5. Build the application and resolve any compilation or template errors. + +## 4. Required Changes + +### 4.1 TypeScript 6 Deprecation Handling + +Projects that still use `downlevelIteration: true` may encounter TypeScript 6 deprecation diagnostics. + +Add the following temporary setting to your root `tsconfig` file (and library production configurations if required): + +```json +{ + "compilerOptions": { + "downlevelIteration": true, + "ignoreDeprecations": "6.0" + } +} +``` + +As a long-term solution, remove `downlevelIteration` when your target runtime environment no longer requires it. + +### 4.2 Updated Change Detection Behavior + +Angular 22 introduces updated change detection behavior for components that do not explicitly configure a change detection strategy. + +Common symptoms include: + +- Loading indicators not updating +- Modal busy states not clearing +- Lists or charts not refreshing after asynchronous operations + +Recommended approaches: + +1. Use `signal()` for component state. +2. Use `toSignal()` when consuming observable streams. +3. Use the `async` pipe for observable-based UI state. +4. Use `ChangeDetectionStrategy.Eager` only as a temporary compatibility measure for legacy components. + +### 4.3 ABP List Pages (`ListService`) + +When working with `ListService`, prefer converting observable results to signals instead of manually subscribing. + +```typescript +readonly data = toSignal( + this.list.hookToQuery(query => this.service.getList(query)), + { initialValue: { items: [], totalCount: 0 } }, +); +``` + +Update template bindings accordingly: + +- `data.items` → `data().items` +- `data.totalCount` → `data().totalCount` + +### 4.4 Modals and Loading States + +For components such as `abp-modal`, `abp-button`, and permission or feature management dialogs, maintain state using signals. + +```typescript +readonly isModalVisible = signal(false); +readonly modalBusy = signal(false); +``` + +```html + + +``` + +If you use `*abpReplaceableTemplate`, pass signal values through `inputs.value` and update state through the corresponding event callbacks. + +### 4.5 Template Type Checking + +Angular 22 enables `strictTemplates` by default. + +Resolve template typing issues where possible, or temporarily disable strict template checking: + +```json +{ + "angularCompilerOptions": { + "strictTemplates": false + } +} +``` + +Common adjustments include: + +- Updating optional chaining (`?.`) and null coalescing (`??`) usage +- Guarding optional form references before binding +- Resolving duplicate input or output bindings + +### 4.6 Upload Progress Events + +Applications that rely on upload progress events should include the XHR backend in browser-side HTTP configuration: + +```typescript +provideHttpClient(withFetch(), withXhr()); +``` + +Do not enable the XHR backend in server-side rendering (SSR) bootstrap code. + +### 4.7 Chart Components (`abp-chart`) + +If charts do not update after asynchronous data loading: + +- Store chart data in a signal +- Bind chart inputs using signal values (for example, `[data]="chartData()"`) +- Call `reinit()` after assigning new data rather than relying solely on `refresh()` + +## 5. Custom or Forked UI Modules + +If your project contains customized copies of ABP modules such as Identity, Tenant Management, Account, or CMS Kit: + +1. Compare your implementation with the updated package versions. +2. Apply the recommended signal-based state management patterns. +3. Re-test CRUD pages, permission dialogs, feature dialogs, and account-related workflows. + +## 6. Validation Checklist + +After completing the upgrade, verify that: + +- Dependencies are installed correctly without duplicate Angular versions +- The application builds successfully +- Unit tests pass (if applicable) +- Login, registration, and password recovery workflows function correctly +- CRUD list pages refresh as expected +- Modal loading and busy states behave correctly +- Permission and feature dialogs open and close correctly +- Upload progress events work as expected (if applicable) +- Dashboard charts render correctly after data is loaded + +## 7. Troubleshooting + +| Symptom | Likely Cause | Resolution | +| ----------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------- | +| Form type conflicts (`AbstractControl`, etc.) | Multiple Angular versions installed | Align package versions and perform a clean reinstall | +| TypeScript deprecation errors related to `downlevelIteration` | TypeScript 6 diagnostics | Add `ignoreDeprecations: "6.0"` temporarily | +| Errors involving optional configuration or environment properties | Stricter type checking | Add null checks and optional chaining where appropriate | +| DTO or library compilation issues | Type incompatibilities in DTO definitions | Prefer interfaces and optional properties where appropriate | +| Upload progress events are not emitted | Missing XHR backend configuration | Add `withXhr()` to browser-side HTTP configuration | +| Charts remain empty after data loads | State changes are not being detected | Use signals and call `reinit()` after updating chart data | + +## 8. Summary + +When upgrading to Angular 22, focus on the following areas: + +1. Upgrade Angular, TypeScript, ABP, and commercial packages together. +2. Update UI state management to use signals, `toSignal()`, or the `async` pipe where appropriate. +3. Resolve TypeScript 6 and template type-checking issues. +4. Validate critical application workflows, including modals, list pages, uploads, and chart components. diff --git a/docs/en/release-info/migration-guides/abp-10-6.md b/docs/en/release-info/migration-guides/abp-10-6.md new file mode 100644 index 0000000000..7015eabbaa --- /dev/null +++ b/docs/en/release-info/migration-guides/abp-10-6.md @@ -0,0 +1,242 @@ +```json +//[doc-seo] +{ + "Description": "Upgrade your ABP solutions from v10.5 to v10.6 with this migration guide covering important behavior and integration changes." +} +``` + +# ABP Version 10.6 Migration Guide + +This document is a guide for upgrading ABP v10.5 solutions to ABP v10.6. This version includes explicitly marked migration-impacting changes for specific customization scenarios, while most applications can upgrade with no additional action beyond the notes below. + +> **Package Version Changes:** Before upgrading, review the [Package Version Changes](../../package-version-changes.md) document to see version changes on dependent NuGet and NPM packages and align your project with ABP's internal package versions. + +## Open-Source (Framework) + +This version contains the following changes on the open-source side: + +### Background Jobs Infrastructure Extensions + +**Who is affected** + +- Applications using the default background job worker and wanting dedicated workers, parallel execution, or successful job retention. +- Applications with a custom `IBackgroundJobStore` or `IBackgroundJobWorker` implementation. +- Applications using the Background Jobs module with EF Core and enabling successful job retention. + +**What changed** + +- ABP adds opt-in support for: + - storing successfully completed jobs (`StoreSuccessfulJobs`) + - dedicated workers per job argument type (`AddDedicatedWorker(...)`) + - parallel job execution (`MaxParallelJobExecutionCount`) +- `IBackgroundJobStore`, `IBackgroundJobWorker`, and related infrastructure gained new members. +- EF Core stores add a `CompletionTime` column to background job records for retention scenarios. +- All new runtime features are disabled by default. + +**What to do** + +No action is required if you do not enable the new options and do not maintain a custom background job store. + +If you maintain a custom `IBackgroundJobStore` or `IBackgroundJobWorker`, implement the new interface members so your solution compiles. + +If you enable `StoreSuccessfulJobs`, add/review the EF Core migration for the `CompletionTime` column and configure retention options explicitly: + +```csharp +Configure(options => +{ + options.StoreSuccessfulJobs = true; + options.SuccessfulJobRetentionTime = TimeSpan.FromDays(7); +}); +``` + +If you enable dedicated workers or parallel execution, configure the options consistently across all application instances and use a real distributed lock provider in clustered deployments. + +> See the [Background Jobs](../../framework/infrastructure/background-jobs/index.md) document and [#25742](https://github.com/abpframework/abp/pull/25742) for details. + +### API Definition and Proxy Generation for Uploads and Content Types + +**Who is affected** + +- Applications using generated Angular, jQuery, or C# proxies for upload endpoints. +- Applications returning non-JSON response types from application services. +- Applications that customized generated upload proxy signatures. + +**What changed** + +- API definition now exposes response `ContentTypes` and `IsRemoteStream`. +- Generated Angular and jQuery proxies forward upload DTOs containing `IRemoteStreamContent` as multipart `FormData`. +- Generated Angular upload method signatures may collapse the upload argument to `FormData`. +- `RestService` now unwraps ABP error envelopes more consistently for text and blob response modes. + +**What to do** + +- Keep upload DTO types in `FormBodyBindingIgnoredTypes` as before. +- Regenerate client proxies after upgrading. +- Update custom client code that assumed upload proxies accepted the original DTO type instead of `FormData`. +- Re-test file upload flows in Angular, MVC/jQuery, and C# client integrations. + +> See [#25639](https://github.com/abpframework/abp/pull/25639) for details. + +### Angular 22 Upgrade + +**Who is affected** + +- Applications using the ABP Angular UI. +- Applications with custom Angular code, third-party Angular libraries, or CI pipelines pinned to Angular 21. + +**What changed** + +- ABP Angular packages and templates now target **Angular 22.0.x**. +- Locale loading was improved with a fallback mechanism for missing or partial locale resources. + +**What to do** + +- Upgrade your Angular application dependencies together with ABP NPM packages. +- Follow the official Angular update guidance for your current Angular version. +- Re-run UI tests and rebuild custom Angular libraries after the upgrade. +- Regenerate Angular proxies after upgrading backend packages. + +> See [#25690](https://github.com/abpframework/abp/pull/25690) and [#25734](https://github.com/abpframework/abp/pull/25734) for details. + +### Antiforgery User Id Claim Issuer Normalization + +**Who is affected** + +- Applications that serve a token-authenticated SPA and cookie-authenticated MVC/Razor Pages on the same origin. +- Applications using Razor Pages modules such as Setting Management with antiforgery-protected POST handlers. + +**What changed** + +- ABP normalizes the user id claim issuer while generating and validating antiforgery tokens. +- Razor Pages now use ABP's antiforgery validation path instead of only the built-in ASP.NET Core filter. +- The behavior is enabled by default through `AbpAntiForgeryOptions.NormalizeUserIdClaimIssuer`. + +**What to do** + +- Re-test SPA + MVC mixed authentication flows, especially pages that POST immediately on load. +- If you implemented custom antiforgery logic that depends on the raw claim issuer, review it after upgrading. +- Disable the behavior only if you intentionally rely on the previous issuer-specific antiforgery identity: + +```csharp +Configure(options => +{ + options.NormalizeUserIdClaimIssuer = false; +}); +``` + +> See [#25655](https://github.com/abpframework/abp/pull/25655) and [#25669](https://github.com/abpframework/abp/pull/25669) for details. + +### OpenIddict Interactive Cookie `client_id` Fix + +**Who is affected** + +- Applications using OpenIddict authorization-code flows together with interactive cookie authentication. +- Applications relying on audit logs or current-client resolution from cookie-authenticated requests. + +**What changed** + +- ABP removes `client_id` from the interactive authentication cookie when the cookie principal is refreshed. +- Access tokens are unaffected. +- Cookies that were already corrupted self-heal on the next refresh. + +**What to do** + +No action is required. Re-test authorization, account, and audit-log scenarios if you previously observed intermittent incorrect `ClientId` values in cookie-authenticated requests. + +> See [#25711](https://github.com/abpframework/abp/pull/25711) for details. + +### Access Token Forwarding for Authenticated Client Requests + +**Who is affected** + +- Applications using `HttpContextAbpAccessTokenProvider`. +- Machine-to-machine integrations that authenticate with `client_credentials` and then call other protected APIs from the same request pipeline. + +**What changed** + +- The provider now forwards the incoming access token whenever the request is authenticated, not only when there is an interactive user. +- `client_credentials` requests no longer fall back to configured identity clients in that scenario. + +**What to do** + +- Re-test service-to-service calls that rely on the current HTTP context access token. +- Verify downstream API authorization when the caller authenticates as a client rather than a user. + +> See [#25740](https://github.com/abpframework/abp/pull/25740) for details. + +### Thread Current Principal Accessor Behavior + +**Who is affected** + +- Background jobs, hosted services, and other non-web code that reads `ICurrentPrincipalAccessor.Principal`. +- Code that explicitly checks for `null` principals in non-web contexts. + +**What changed** + +- `ThreadCurrentPrincipalAccessor` now returns an anonymous `ClaimsPrincipal` instead of `null` when `Thread.CurrentPrincipal` is not set. + +**What to do** + +- Re-test background jobs and hosted services that branch on `Principal == null`. +- Prefer checking authentication/identity state through claims or ABP's current user/client abstractions instead of relying on a `null` principal. + +> See [#25752](https://github.com/abpframework/abp/pull/25752) for details. + +### Dependency Updates + +**Who is affected** + +- Applications that pin ABP transitive dependencies directly. +- Applications using `Microsoft.Data.SqlClient`, MongoDB.Driver, Swashbuckle, or Angular with fixed versions. + +**What changed** + +- `Microsoft.*` and `System.*` packages were upgraded to **10.0.9**. +- `Microsoft.Data.SqlClient` was upgraded to **7.0.2**. +- MongoDB.Driver was upgraded to **3.10.0**. +- `Swashbuckle.AspNetCore` was upgraded to **10.2.3**. +- ABP Angular packages were upgraded to **Angular 22.0.x**. + +**What to do** + +- Review your direct package references and align them with ABP's package versions where needed. +- Rebuild and run database/integration tests if you directly use `Microsoft.Data.SqlClient`. +- Re-test MongoDB integrations if your solution directly references MongoDB.Driver packages. +- Re-test Swagger/OpenAPI integration if you customized Swashbuckle configuration. + +## Pro + +This version contains the following changes on the PRO side: + +### OpenIddict Generate Access Token UI + +**Who is affected** + +- Applications using OpenIddict application management UIs in ABP Commercial. + +**What changed** + +- Administrators can generate access tokens for OpenIddict applications from MVC, Blazor, MudBlazor, and Angular UIs. +- The backend forwards `client_credentials` requests to `/connect/token`. + +**What to do** + +- Re-test OpenIddict application administration pages after upgrading. +- Review who can access the new token-generation action in your authorization setup. + +### AI Management Indexing Resilience + +**Who is affected** + +- Applications using the AI Management module with document indexing enabled. +- Applications that replace or customize `IDocumentChunkRepository`. + +**What changed** + +- Indexing is more resilient under memory pressure by coordinating work in bounded batches. +- `IDocumentChunkRepository` includes a new paged datasource query overload used by bounded batch indexing. + +**What to do** + +- Re-test document indexing on large datasets or memory-constrained environments after upgrading. +- If your application implements `IDocumentChunkRepository` directly, add the new overload and return chunks for the datasource ordered by `ChunkIndex`, starting at `startChunkIndex`, limited to `maxResultCount`. diff --git a/docs/en/release-info/migration-guides/abp-8-2-blazor-web-app.md b/docs/en/release-info/migration-guides/abp-8-2-blazor-web-app.md index e3b65e1221..2511ca659e 100644 --- a/docs/en/release-info/migration-guides/abp-8-2-blazor-web-app.md +++ b/docs/en/release-info/migration-guides/abp-8-2-blazor-web-app.md @@ -86,8 +86,8 @@ public override void OnApplicationInitialization(ApplicationInitializationContex var app = context.GetApplicationBuilder(); // ... - app.UseAntiforgery(); app.UseAuthorization(); + app.UseAntiforgery(); app.UseConfiguredEndpoints(builder => { diff --git a/docs/en/release-info/migration-guides/identityserver-to-openiddict.md b/docs/en/release-info/migration-guides/identityserver-to-openiddict.md index 2770722a51..b44f1c4b55 100644 --- a/docs/en/release-info/migration-guides/identityserver-to-openiddict.md +++ b/docs/en/release-info/migration-guides/identityserver-to-openiddict.md @@ -7,12 +7,12 @@ # Migration Identity Server to OpenIddict Guide -This document explains how to migrate to [OpenIddict](https://github.com/openiddict/openiddict-core) from Identity Server. From now on the ABP startup templates uses `OpenIddict` as the auth server by default since version v6.0.0. +This document explains how to migrate an application from IdentityServer4 to [OpenIddict](https://github.com/openiddict/openiddict-core). ABP startup templates have used OpenIddict as the authentication server by default since v6.0.0. -## History -We are not removing Identity Server packages and we will continue to release new versions of Identity Server related NuGet/NPM packages. That means you won't have an issue while upgrading to v6.0 when the stable version releases. We will continue to fix bugs in our packages for a while. ABP 7.0 will be based on .NET 7. If Identity Server continues to work with .NET 7, we will also continue to ship NuGet packages for our IDS integration. +> The checklist below describes the v6.0 transition. For a layer-by-layer migration, use the [IdentityServer to OpenIddict step-by-step guide](openiddict-step-by-step.md) and apply the package version that matches the ABP version of your application. -On the other hand, Identity Server ends support for the open-source Identity Server in the end of 2022. The Identity Server team has decided to move to Duende IDS and ABP will not be migrated to the commercial Duende IDS. You can see the Duende Identity Server announcement from [this link](https://blog.duendesoftware.com/posts/20220111_fair_trade). +## History +IdentityServer4 is archived and no longer maintained by its owners. ABP did not migrate its integration to the commercial Duende IdentityServer product. The ABP IdentityServer integration packages are still shipped for existing applications, while new applications use OpenIddict. See the [Duende IdentityServer announcement](https://blog.duendesoftware.com/posts/20220111_fair_trade) for the background. ## OpenIddict Migration Steps @@ -21,7 +21,7 @@ On the other hand, Identity Server ends support for the open-source Identity Ser * Replace all `IdentityServer` modules with corresponding `OpenIddict` modules. eg `AbpIdentityServerDomainModule` to `AbpOpenIddictDomainModule`, `AbpAccountWebIdentityServerModule` to `AbpAccountWebOpenIddictModule`. * Rename the `ConfigureIdentityServer` to `ConfigureOpenIddict` in your `ProjectNameDbContext` class. * Remove the `UseIdentityServer` and add `UseAbpOpenIddictValidation` after `UseAuthentication`. -* Add follow code to your startup module. +* Add the following code to your startup module. ```cs public override void PreConfigureServices(ServiceConfigurationContext context) @@ -38,7 +38,7 @@ public override void PreConfigureServices(ServiceConfigurationContext context) } ``` -* If your project is not separate AuthServer please also add `ForwardIdentityAuthenticationForBearer` +* If your project does not have a separate AuthServer, also add `ForwardIdentityAuthenticationForBearer`. ```cs private void ConfigureAuthentication(ServiceConfigurationContext context) @@ -48,9 +48,9 @@ private void ConfigureAuthentication(ServiceConfigurationContext context) ``` * Remove the `IdentityServerDataSeedContributor` from the `Domain` project. -* Create a new version of the project, with the same name as your existing project. -* Copy the `ProjectName.Domain\OpenIddict\OpenIddictDataSeedContributor.cs` of new project into your project and update `appsettings.json` base on `ProjectName.DbMigrator\appsettings.json`, Be careful to change the port number. -* Copy the `Index.cshtml.cs` and `Index.cs` of new project to your project if you're using `IClientRepository` in `IndexModel`. +* Generate a temporary project with the same name and architecture as the existing project so you can compare the current OpenIddict setup. +* Copy the generated `ProjectName.Domain\OpenIddict\OpenIddictDataSeedContributor.cs` into your project and update `appsettings.json` based on `ProjectName.DbMigrator\appsettings.json`. Adjust the ports and client URLs for your application. +* Copy the generated `Index.cshtml.cs` and `Index.cshtml` files into your project if your `IndexModel` still uses `IClientRepository`. * Update the scope name from `role` to `roles` in `AddAbpOpenIdConnect` method. * Remove `options.OAuthClientSecret(configuration["AuthServer:SwaggerClientSecret"]);` from `HttpApi.Host` project. * AuthServer no longer requires `JWT bearer authentication`. Please remove it. eg `AddJwtBearer` and `UseJwtTokenMiddleware`. diff --git a/docs/en/release-info/migration-guides/identityserver4-step-by-step.md b/docs/en/release-info/migration-guides/identityserver4-step-by-step.md index 844d5ae071..da617cd237 100644 --- a/docs/en/release-info/migration-guides/identityserver4-step-by-step.md +++ b/docs/en/release-info/migration-guides/identityserver4-step-by-step.md @@ -7,11 +7,13 @@ # Migrating from OpenIddict to IdentityServer4 Step by Step Guide -ABP startup templates use `OpenIddict` OpenID provider from v6.0.0 by default and `IdentityServer` projects are renamed to `AuthServer` in tiered/separated solutions. Since OpenIddict is the default OpenID provider library for ABP templates since v6.0, you may want to keep using [IdentityServer4](https://github.com/IdentityServer/IdentityServer4) library, even it is **archived and no longer maintained by the owners**. ABP doesn't provide support for newer versions of IdentityServer. This guide provides layer-by-layer guidance for migrating your existing [OpenIddict](https://github.com/openiddict/openiddict-core) application to IdentityServer4. +ABP startup templates use the `OpenIddict` OpenID provider from v6.0.0 by default, and `IdentityServer` projects were renamed to `AuthServer` in tiered/separated solutions. This guide provides the v6.0-era, layer-by-layer steps for migrating an existing [OpenIddict](https://github.com/openiddict/openiddict-core) application to IdentityServer4. + +> IdentityServer4 is archived and no longer maintained by its owners. ABP doesn't support newer IdentityServer or Duende IdentityServer versions through this module. Use this guide only when an existing system has a compatibility requirement that prevents migration to OpenIddict; new applications should remain on OpenIddict. ## IdentityServer4 Migration Steps -Use the `abp update` command to update your existing application. See [Upgrading docs](../upgrading.md) for more info. Apply required migrations by following the [Migration Guides](../migration-guides) based on your application version. +These steps target an application that is already on ABP 6.0.x. Keep every `Volo.Abp.*` package on the exact 6.0.x patch version used by the application. Complete any version update separately with the CLI and migration guides for that release line before applying these provider-replacement steps. ### Domain.Shared Layer @@ -138,8 +140,6 @@ typeof(AbpIdentityServerEntityFrameworkCoreModule), ```csharp using Volo.Abp.IdentityServer.EntityFrameworkCore; ... - using Volo.Abp.OpenIddict.EntityFrameworkCore; - ... protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -150,7 +150,7 @@ typeof(AbpIdentityServerEntityFrameworkCoreModule), builder.ConfigureIdentityServer(); ``` -> Not: You need to create new migration after updating the fluent api. Navigate to *EntityFrameworkCore* folder and add a new migration. Ex, `dotnet ef migrations add Updated_To_IdentityServer ` +> Note: Create a new migration after updating the fluent API. Navigate to the *EntityFrameworkCore* folder and run, for example, `dotnet ef migrations add Updated_To_IdentityServer`. ### MongoDB Layer @@ -186,14 +186,14 @@ typeof(AbpIdentityServerMongoDbModule), ### DbMigrator Project -- In `appsettings.json` **replace OpenIddict section with IdentityServer** since IdentityServerDataSeeder will be using these information for initial data seeding: +- In `appsettings.json` **replace the OpenIddict section with IdentityServer** because IdentityServerDataSeeder uses this configuration for initial data seeding. Rename the `Applications` property to `Clients` and preserve its existing child entries. The minimal valid structure is: ```json - "IdentityServer": { // Rename OpenIddict to IdentityServer - "Clients ": { // Rename Applications to Clients - ... - } + { + "IdentityServer": { + "Clients": {} } + } ``` @@ -223,7 +223,7 @@ typeof(AbpIdentityServerMongoDbModule), ### UI Layer -You can follow the migrations guides from IdentityServer to OpenIddict in **reverse order** to update your UIs. You can also check the source-code for [Index.cshtml.cs](https://github.com/abpframework/abp-samples/blob/master/OpenId2Ids/src/OpenId2Ids.AuthServer/Pages/Index.cshtml) and [Index.cshtml](https://github.com/abpframework/abp-samples/blob/master/OpenId2Ids/src/OpenId2Ids.AuthServer/Pages/Index.cshtml.cs) files for **AuthServer** project. +You can follow the migration guides from IdentityServer to OpenIddict in reverse order to update your UIs. You can also check the sample source for [Index.cshtml.cs](https://github.com/abpframework/abp-samples/blob/master/OpenId2Ids/src/OpenId2Ids.AuthServer/Pages/Index.cshtml.cs) and [Index.cshtml](https://github.com/abpframework/abp-samples/blob/master/OpenId2Ids/src/OpenId2Ids.AuthServer/Pages/Index.cshtml) in the **AuthServer** project. - [Angular UI Migration](openiddict-angular.md) - [MVC/Razor UI Migration](openiddict-mvc.md) diff --git a/docs/en/release-info/migration-guides/index.md b/docs/en/release-info/migration-guides/index.md index 418ec7d586..ec169d8242 100644 --- a/docs/en/release-info/migration-guides/index.md +++ b/docs/en/release-info/migration-guides/index.md @@ -9,6 +9,7 @@ The following documents explain how to migrate your existing ABP applications. We write migration documents only if you need to take an action while upgrading your solution. Otherwise, you can easily upgrade your solution using the [abp update command](../upgrading.md). +- [10.5 to 10.6](abp-10-6.md) - [10.4 to 10.5](abp-10-5.md) - [10.3 to 10.4](abp-10-4.md) - [10.x to 10.3](abp-10-3.md) diff --git a/docs/en/release-info/release-notes.md b/docs/en/release-info/release-notes.md index f7cc4707da..ddf893a60a 100644 --- a/docs/en/release-info/release-notes.md +++ b/docs/en/release-info/release-notes.md @@ -1,7 +1,7 @@ ```json //[doc-seo] { - "Description": "Explore the latest ABP Framework release notes, highlighting major features and enhancements for each version, including migration guidance." + "Description": "Explore the latest ABP Framework release notes, highlighting major features and enhancements for each version, including migration guidance." } ``` @@ -14,9 +14,19 @@ Also see the following notes about ABP releases: - [ABP Studio release notes](../studio/release-notes.md) - [Change logs for ABP pro packages](https://abp.io/pro-releases) -## 10.5 (2026-06-02) +## 10.6 (2026-07-27) -See the detailed **[blog post / announcement](https://abp.io/community/articles/announcing-abp-10-5-release-candidate-k6oxdfle)** for the v10.5 release. +See the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-6-release-candidate-reoq6kzw)** for the v10.6 release. + +- Background Jobs: Dedicated Workers, Parallel Execution, and Successful Job Retention +- API Definition and Proxy Improvements for Content Types and Multipart Uploads +- Angular UI: Angular has been upgraded to version 22. For a complete list of changes, including breaking changes, migration steps, and package updates, see the **[Angular Release Notes for v10.6](./../framework/ui/angular/release-notes/angular-22-typescript-6.md)**. +- Antiforgery and OpenIddict Security Improvements +- OpenIddict: Generate Access Token from the UI + +## 10.5 (2026-06-30) + +See the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-5-stable-release-2u589bsc)** for the v10.5 release. - S3-Compatible Blob Storage Support - OpenIddict: Default Scope Fallback Options @@ -46,7 +56,7 @@ See the detailed **[blog post / announcement](https://abp.io/community/announcem - Event Bus: String-Based Event Publishing with Dynamic Payload - Background Jobs/Workers: String-Based Publishing with Dynamic Payload - API Definition Endpoint: Descriptions and Documentation Support -- Entity Cache: New Batch APIs (`FindMany`* / `GetMany*`) +- Entity Cache: New Batch APIs (`FindMany`_ / `GetMany_`) - Angular: User/Tenant Sharing and Tenant Switch Experience - Angular: Upgrade to 21.2 + TypeScript 5.9 - Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider @@ -457,7 +467,7 @@ See the detailed **blog post / announcement** for the v2.8 release: [https://abp ## 2.7 (2020-05-07) -See the detailed **blog post / announcement** for the v2.7 release: [https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released](https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released) +See the detailed **blog post / announcement** for the v2.7 release: [https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released](https://abp.io/blog/ABP-Framework-v2_7_0-Has-Been-Released) - New module: **Text template management** (with angular and mvc UI - document is [coming](../modules/text-template-management.md)). - **Dynamically add properties** to current entities of the depended modules (see [module entity extensions](../framework/architecture/modularity/extending/module-entity-extensions.md)) @@ -468,9 +478,8 @@ See the detailed **blog post / announcement** for the v2.7 release: [https://ab - **Optimize database migrations** & seed code for multi-tenant multi-database systems. - ABP Suite: Make **menu item active** on navigation menu when selected. - ABP Suite: Improve **enum usage** while creating new entities. -- Bug fixes in the [Lepton Theme](https://abp.io/themes), [ABP Suite](https://abp.io/tools/suite) and other modules. +- Bug fixes in the [Lepton Theme](https://abp.io/themes), [ABP Suite](https://abp.io/tools/suite) and other modules. ## See Also - [Road map](road-map.md) - diff --git a/docs/en/release-info/road-map.md b/docs/en/release-info/road-map.md index a0b3f856e7..6df6ca81fa 100644 --- a/docs/en/release-info/road-map.md +++ b/docs/en/release-info/road-map.md @@ -1,7 +1,7 @@ ```json //[doc-seo] { - "Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.5, planned for June 2026." + "Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.7, planned for August 2026." } ``` @@ -11,28 +11,29 @@ This document provides a road map, release schedule, and planned features for th ## Next Versions -### v10.6 +### v10.7 -The next planned version will be 10.6, which is scheduled to be released as a stable version in July 2026. We will be mostly working on the following topics: +The next planned version will be 10.7, which is scheduled to be released as a stable version in August 2026. Based on the currently open issues and pull requests across the ABP ecosystem, we will be mostly working on the following topics: * Framework - * Token verification improvements with refresh token support and distributed locking - * Angular UI fixes and proxy generation improvements - * Better handling for extra properties, object mapping and auditing edge cases - * Hybrid UI / page embedding infrastructure - * Upgrading 3rd-party dependencies and evaluating replacements where needed - * General bug fixing and improvements in core framework packages + * Cookie authentication: refresh token support and distributed locking ([#25011](https://github.com/abpframework/abp/issues/25011)) + * jQuery 4.x upgrade, or removing jQuery as a dependency ([#25123](https://github.com/abpframework/abp/issues/25123)) + * AI agent skills distributed as versioned plugins ([#25712](https://github.com/abpframework/abp/issues/25712)) + * Hybrid UI / page embedding infrastructure ([#23102](https://github.com/abpframework/abp/issues/23102), [#23161](https://github.com/abpframework/abp/issues/23161)) + * Microsoft Agent Framework migration and native agent skills support ([#24310](https://github.com/abpframework/abp/issues/24310), [#25194](https://github.com/abpframework/abp/issues/25194)) + * Better ExtraProperties mapping for EF Core ([#23546](https://github.com/abpframework/abp/issues/23546)) + * Upgrading 3rd-party dependencies and general bug fixing in core framework packages * ABP Suite - * Improvements on generated codes for nullability - * Improvements on master-detail page design (making it more compact) + * Replace the templating system with Scriban while preserving backward compatibility + * Support for additional property types like `DateTimeOffset`, `TimeSpan` and numeric enums + * Display names and ordering for properties and navigation properties + * Filter on inherited properties and namespace-based UI foldering * Low-Code system integration - * Better support for additional property types like `DateTimeOffset`, `TimeSpan` and numeric enum scenarios - * Improvements for generated file upload, navigation property and display-name experiences + * Improvements on generated code nullability, master-detail pages and file upload experiences * ABP Studio - * AI Coding Agent and MCP integration - * Modern solution wizard improvements and low-code support + * Low-Code platform integration * Theme Builder: live preview, project integration and import/export * Linux support and packaging improvements * Better React / React Native / Thin UI template experience @@ -41,11 +42,12 @@ The next planned version will be 10.6, which is scheduled to be released as a st * Terminal, browser and built-in developer productivity enhancements * Application Modules - * AI Management: chat history, multi-tenancy and tenant-scoped workspace capabilities + * AI Management: chat history + * AI Management: multi-tenancy and tenant-scoped workspace capabilities + * RAG: Cloudflare `/crawl` endpoint as a data source * New module: Chat with your data - * Low-Code designer and low-code platform integrations - * CMS Kit and public website improvements * Payment module e-mail notification improvements + * CMS Kit and public website improvements * UI/UX improvements on existing application modules * Updating existing tutorials & documents (with other UI & DB options) @@ -61,42 +63,46 @@ The *Next Versions* section above shows the main focus of the planned versions. The ABP framework is [open source](https://github.com/abpframework/abp) and free for everyone. You can see its [public backlog](https://github.com/abpframework/abp/milestone/2). Here are some of the selected backlog items and longer-term topics: * [#23102](https://github.com/abpframework/abp/issues/23102) / ABP Hybrid UI System: Re-using module UIs in different technologies +* [#23161](https://github.com/abpframework/abp/issues/23161) / Page Embedding Feature (aka Hybrid UI) * [#25123](https://github.com/abpframework/abp/issues/25123) / Upgrade jQuery to 4.x, or consider removing it as dependency -* [#24742](https://github.com/abpframework/abp/issues/24742) / Add Support for LiteDB as a Database Provider -* [#24442](https://github.com/abpframework/abp/issues/24442) / Add Couchbase EF Core Provider Integration +* [#17093](https://github.com/abpframework/abp/issues/17093) / MVC UI: decouple jQuery * [#24310](https://github.com/abpframework/abp/issues/24310) / Migrate Volo.Abp.AI Semantic Kernel to Microsoft Agent Framework +* [#25194](https://github.com/abpframework/abp/issues/25194) / Integrate Microsoft.Agents.AI for native Agent Skills support * [#23575](https://github.com/abpframework/abp/issues/23575) / Support list/enumerable of complex types for ABP dynamic/static C# proxies on GET requests * [#23546](https://github.com/abpframework/abp/issues/23546) / Better ExtraProperties mapping for EF Core * [#23935](https://github.com/abpframework/abp/issues/23935) / Hybrid Cache Support for EntityCache * [#22931](https://github.com/abpframework/abp/issues/22931) / Angular - Support dynamic URLs for breadcrumbs -* [#25032](https://github.com/abpframework/abp/issues/25032) / Guidance and infrastructure considerations for gRPC-based scenarios -* [#2882](https://github.com/abpframework/abp/issues/2882) / Providing a gRPC integration infrastructure -* [#57](https://github.com/abpframework/abp/issues/57) / Built-in CQRS infrastructure +* [#24742](https://github.com/abpframework/abp/issues/24742) / Add Support for LiteDB as a Database Provider +* [#24442](https://github.com/abpframework/abp/issues/24442) / Add Couchbase EF Core Provider Integration +* [#2882](https://github.com/abpframework/abp/issues/2882) / ABP gRPC Integration +* [#57](https://github.com/abpframework/abp/issues/57) / CQRS infrastructure * [#58](https://github.com/abpframework/abp/issues/58) / Content localization system (multilingual entities) -* [#4223](https://github.com/abpframework/abp/issues/4223) / WebHook system -* [#162](https://github.com/abpframework/abp/issues/162) / Azure ElasticDB integration for multitenancy -* [#2296](https://github.com/abpframework/abp/issues/2296) / Feature toggling infrastructure +* [#4223](https://github.com/abpframework/abp/issues/4223) / WebHook System +* [#162](https://github.com/abpframework/abp/issues/162) / Azure ElasticDB Integration for multitenancy +* [#2296](https://github.com/abpframework/abp/issues/2296) / Implementing Feature Toggle * [#15932](https://github.com/abpframework/abp/issues/15932) / Introduce ABP Diagnostics Module * [#16744](https://github.com/abpframework/abp/issues/16744) / State Management API -* [#119](https://github.com/abpframework/abp/issues/119) / REST API versioning improvements -* [#2087](https://github.com/abpframework/abp/issues/2087) / RavenDB database support +* [#119](https://github.com/abpframework/abp/issues/119) / REST API Versioning Improvements +* [#2087](https://github.com/abpframework/abp/issues/2087) / Add RavenDB Database support ### Application Modules / UI Themes ABP Platform provides many (free and commercial) [pre-built application modules](../modules/index.md) and modern [UI themes](../ui-themes/index.md). In every release, many enhancements and bugfixes are delivered for these modules and themes. Important backlog topics currently include: * AI Management module: chat history, multi-tenancy, tenant workspaces and operational hardening -* CMS Kit module: media gallery and richer public website capabilities +* New module: Chat with your data +* RAG with external data sources such as website crawling * Payment module: richer notifications and invoice-oriented scenarios +* CMS Kit: Meta information for SEO * Audit logging UI: filter redesign and UX improvements * Identity Pro: richer filtering and organization-unit UX improvements * LeptonX and existing UIs: new layouts, styles and usability refinements -* New module ideas: Chat with your data, AI Search, user notification and dynamic dashboard ### ABP Studio [ABP Studio](../studio/index.md) is a cross-platform desktop application for ABP and .NET developers to simplify and automate daily tasks of developers. It has a community (free) edition as well as commercial capabilities. Here are some of the important planned features and active backlog topics for the next ABP Studio versions: +* Low-Code: ABP Studio Integration * Theme builder for LeptonX, including live preview, management UI and project integration * Analyze user solutions to explore entities, domain services, application services, pages and other fundamental objects * AI agent/browser capabilities and developer-assistant experiences @@ -107,16 +113,14 @@ ABP Platform provides many (free and commercial) [pre-built application modules] * More options while creating new solutions, modules and services * Better environment-variable, deployment and Kubernetes experiences * Compare changes on startup templates when a new ABP version is published -* Rapid application development and low-code oriented features * ABP support integration and better diagnostics/error experiences ### ABP Suite [ABP Suite](../suite/index.md) is a GUI application that is mainly used to generate CRUD-style pages in your application. You define your entity and it can generate all the code from the database to the UI. Here are some of the important planned features for the next ABP Suite versions: +* Replace the current templating system with Scriban while preserving backward compatibility * Better nullability support in generated code -* MudBlazor support -* Replacing the current templating system with a text engine while preserving backward compatibility * Support for additional property types like `DateTimeOffset` and `TimeSpan` * Handle image properties for entities (in addition to file properties, which are already supported) * Allow to define display names and better ordering for properties and navigation properties diff --git a/docs/en/solution-templates/layered-web-application/deployment/identityserver-deployment.md b/docs/en/solution-templates/layered-web-application/deployment/identityserver-deployment.md index db6975e04f..48f62b9c70 100644 --- a/docs/en/solution-templates/layered-web-application/deployment/identityserver-deployment.md +++ b/docs/en/solution-templates/layered-web-application/deployment/identityserver-deployment.md @@ -7,19 +7,21 @@ # IdentityServer Deployment -IdentityServer configuration may be different based on deployment configurations. Basically, you need update identityserver client related data and update your hosting preferences based on your deployment environment. +> This page applies only to applications that still use the legacy [IdentityServer module](../../../modules/identity-server.md). Current startup templates use OpenIddict; see [Configuring OpenIddict](../../../deployment/configuring-openIddict.md) for current applications. -## Update Cors Origins +IdentityServer configuration changes between deployment environments. Update the IdentityServer client data and the host settings for the deployed URLs before releasing the application. -Cors origins configuration for **gateways**, **microservices** swagger authorization and **Angular/Blazor** (web assembly) must be updated for deployment. This can be found under **App** configuration in *appsettings.json* +## Update CORS Origins + +CORS origin configuration for **gateways**, **microservices** Swagger authorization and **Angular/Blazor WebAssembly** applications must be updated for deployment. It is under the **App** section in *appsettings.json*. ```json "CorsOrigins": "https://*.MyProjectName.com,http://localhost:4200,https://localhost:44307,https://localhost:44325,https://localhost:44353,https://localhost:44367,https://localhost:44388,https://localhost:44381,https://localhost:44361", ``` -## Update Redirect Allowed Urls +## Update Redirect Allowed URLs -This configuration must be done if **Angular** or **Blazor** (web assembly) is used as back-office web application. It is found under **App** configuration in appsettings.json +Update this configuration when an **Angular** or **Blazor WebAssembly** application is used as the back-office application. It is under the **App** section in *appsettings.json*. ```json "RedirectAllowedUrls": "http://localhost:4200,https://localhost:44307" @@ -29,90 +31,50 @@ This configuration must be done if **Angular** or **Blazor** (web assembly) is u `IdentityServerDataSeedContributor` uses **IdentityServer.Clients** section of `appsettings.json` for `ClientId`, `RedirectUri`, `PostLogoutRedirectUri`, `CorsOrigins`. -Update DbMigrator project `appsettings.json` **IdentityServer.Clients.RootUrls** with production values: +Update the DbMigrator project's `appsettings.json` **IdentityServer.Clients.RootUrls** values for production: ![db-migrator-appsettings](../../../images/db-migrator-appsettings.png) Or, manually add production values to `IdentityServerClientRedirectUris`, `IdentityServerClientPostLogoutRedirectUris`, `IdentityServerClientCorsOrigins` tables in your database. -> If you are using microservice template on-the-fly migration and not using dbmigrator project, update **IdentityService** appsettings. +> If you use the microservice template's on-the-fly migration instead of a DbMigrator project, update the **IdentityService** settings. -Eventually, you shouldn't have `localhost` related data. +Remove all `localhost` values from the production client data. ## Update IdentityServer -You need to update token signing certificate and identityserver midware based on your hosting environment. +Update the token-signing certificate and IdentityServer middleware for your hosting environment. ### Signing Certificate -Default development environment uses [developer signing certificates option](https://github.com/abpframework/abp/blob/dev/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AbpIdentityServerBuilderOptions.cs#L29). Using developer signing certificates may cause *IDX10501: Signature validation failed* error on production. +The default `AbpIdentityServerBuilderOptions.AddDeveloperSigningCredential` value enables a developer signing credential. Don't use a developer signing credential in production; configure a real certificate through `IIdentityServerBuilder` pre-configuration. Otherwise, signing keys can change between deployments and cause errors such as *IDX10501: Signature validation failed*. -Update **IdentityServerModule** with using real certificate on `IIdentityServerBuilder` pre-configuration. +Update **IdentityServerModule** to use a real certificate in `IIdentityServerBuilder` pre-configuration. ![idsrv-certificate](../../../images/idsrv-certificate.png) -You can also [create self-signed certificate](https://docs.abp.io/en/commercial/5.0/startup-templates/microservice/tye-integration#create-developer-certificates) and use it. - -> If you are using self signed certificate, do not forget to set the certificate (.pfx file) as `EmbeddedResource` and set `CopyToOutputDirectory`. File needs to exist physically. +Load the production certificate from your deployment platform's certificate or secret store. Don't embed the production private key in the application assembly or commit it to the source repository. ### Use HTTPS -Update **IdentityServerModule** to [enfcore https](https://docs.microsoft.com/en-us/aspnet/core/security/enforcing-ssl?view=aspnetcore-6.0&tabs=visual-studio). Add `UseHsts` to add hsts headers to clients, add `UseHttpsRedirection` to redirect http requests to https. +Update **IdentityServerModule** to [enforce HTTPS](https://learn.microsoft.com/aspnet/core/security/enforcing-ssl). Add `UseHsts` to send HSTS headers and `UseHttpsRedirection` to redirect HTTP requests to HTTPS. ![use-https](../../../images/use-https.png) ### Behind Load Balancer -To redirect http requests to https from load balancer, update `OnApplicationInitialization` method of the **IdentityServerModule** with the midware below: +When TLS terminates at a reverse proxy or load balancer, use ASP.NET Core Forwarded Headers Middleware so IdentityServer receives the original scheme and host. Follow the [Forwarded Headers](../../../deployment/forwarded-headers.md) guide and the [ASP.NET Core proxy and load balancer guidance](https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer). -```csharp -app.Use((httpContext, next) => -{ - httpContext.Request.Scheme = "https"; - return next(); -}); -``` +Configure the proxy addresses or networks in `ForwardedHeadersOptions.KnownProxies` or `KnownNetworks`, and call `UseForwardedHeaders` before authentication, IdentityServer, HTTPS redirection and HSTS middleware. If you enable `X-Forwarded-Host`, restrict `AllowedHosts` and configure the proxy to overwrite incoming forwarded headers. + +Don't set `HttpContext.Request.Scheme` unconditionally. Don't derive the IdentityServer origin from a custom request header unless the application first verifies that the request came through a trusted proxy; internet clients can forge ordinary request headers. ### Kubernetes -A common scenario is running applications in kubernetes environment. While IdentityServer needs to face internet on https, internal requests can be done using http. +A common scenario is running applications in Kubernetes. While IdentityServer must be exposed to the internet over HTTPS, internal requests can use HTTP. ![idsrv-k8s](../../../images/idsrv-k8s.png) -**HttpApi.Host** and **Web** applications authority should be set to http since token validations will done using http request. - -![api-resource-internal-idsrv](../../../images/api-resource-internal-idsrv.png) - -> You can use different appsettings files like *appsettings.production.json* to override these values or directly override environment values from kubernetes. +Keep the externally advertised IdentityServer authority and origin on the public HTTPS URL. If services use cluster-local routing, configure internal DNS or the ingress so that the public authority resolves through the trusted internal route without changing the issuer or accepting a client-controlled origin. -To isolate internal identityserver requests from external network (internet), append extra header instead of overwriting. -For ingress, you can use `nginx.ingress.kubernetes.io/configuration-snippet`: - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: myidentityserver-ingress - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/proxy-buffer-size: "32k" - nginx.ingress.kubernetes.io/proxy-buffers-number: "8" - nginx.ingress.kubernetes.io/configuration-snippet: | - more_set_input_headers "from-ingress: true"; -spec: -``` - -You need to set the IdentityServer origin based on header. Update `OnApplicationInitialization` method of the **IdentityServerModule** with the midware below: - -```csharp -app.Use(async (ctx, next) => -{ - if (ctx.Request.Headers.ContainsKey("from-ingress")) - { - ctx.SetIdentityServerOrigin("https://myidentityserver.com"); - } - - await next(); -}); -``` +> You can use environment-specific files such as *appsettings.Production.json* or environment variables to override these values in Kubernetes. diff --git a/docs/en/studio/release-notes.md b/docs/en/studio/release-notes.md index 7d59bb0ddc..f71842c9dc 100644 --- a/docs/en/studio/release-notes.md +++ b/docs/en/studio/release-notes.md @@ -9,7 +9,18 @@ This document contains **brief release notes** for each ABP Studio release. Release notes only include **major features** and **visible enhancements**. Therefore, they don't include all the development done in the related version. -## 3.0.7 (2026-07-13) Latest +## 3.0.8 (2026-07-28) Latest + +* Add BLOB storage provider options +* Improve custom LLM provider connection UX +* Upgrade vulnerable NuGet packages to patched versions +* ABP Agent: Support Custom LLM/BYOK for Enterprise users +* Fix oversized images and card layout on MudBlazor home page +* ABP Agent: ABP Suite integration for crud page generation +* Monitoring: Add option to send exception details to ABP Agent +* Implement true same-run AI agent steering + +## 3.0.7 (2026-07-13) * Upgrade Blazorise packages to version 2.1.3 * Skip extension check for install-libs and wait for restarted CLI diff --git a/docs/en/studio/version-mapping.md b/docs/en/studio/version-mapping.md index 9549fc62ee..6f6a3551b6 100644 --- a/docs/en/studio/version-mapping.md +++ b/docs/en/studio/version-mapping.md @@ -11,8 +11,8 @@ This document provides a general overview of the relationship between various ve | **ABP Studio Version** | **ABP Version of Startup Template** | |------------------------|---------------------------| -| 3.0.7 | 10.5.0 | -| 3.0.4 - 3.0.6 | 10.4.1 | +| 3.0.6 - 3.0.8 | 10.5.0 | +| 3.0.4 - 3.0.5 | 10.4.1 | | 3.0.3 | 10.4.0 | | 2.2.7 - 3.0.2 | 10.3.0 | | 2.2.5 - 2.2.6 | 10.2.0 | diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/Configuration/BlazorServerCurrentApplicationConfigurationCacheResetService.cs b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/Configuration/BlazorServerCurrentApplicationConfigurationCacheResetService.cs index 144d975cb5..e550d75858 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/Configuration/BlazorServerCurrentApplicationConfigurationCacheResetService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/Configuration/BlazorServerCurrentApplicationConfigurationCacheResetService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Components.Web.Configuration; +using Volo.Abp.AspNetCore.Components.Web.Security; using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus.Local; @@ -13,15 +14,20 @@ public class BlazorServerCurrentApplicationConfigurationCacheResetService : ITransientDependency { private readonly ILocalEventBus _localEventBus; + private readonly ApplicationConfigurationChangedService _applicationConfigurationChangedService; public BlazorServerCurrentApplicationConfigurationCacheResetService( - ILocalEventBus localEventBus) + ILocalEventBus localEventBus, + ApplicationConfigurationChangedService applicationConfigurationChangedService) { _localEventBus = localEventBus; + _applicationConfigurationChangedService = applicationConfigurationChangedService; } public async Task ResetAsync(Guid? userId = null) { await _localEventBus.PublishAsync(new CurrentApplicationConfigurationCacheResetEventData(userId)); + + _applicationConfigurationChangedService.NotifyChanged(); } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs index 9da7e7454f..0f80fe219d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.AspNetCore.Mvc.Filters; @@ -216,6 +217,27 @@ public class AbpAspNetCoreMvcModule : AbpModule }); ConfigureRouteBasedCulture(context); + DecorateAntiforgery(context); + } + + protected virtual void DecorateAntiforgery(ServiceConfigurationContext context) + { + // Wrap the registered IAntiforgery (DefaultAntiforgery from AddAntiforgery by default) with + // AbpAntiforgery so every antiforgery entry point goes through the claim normalization. + // Only an implementation-type registration is wrapped; a custom factory/instance registration of + // IAntiforgery is left as-is. + var descriptor = context.Services.LastOrDefault(d => d.ServiceType == typeof(IAntiforgery)); + if (descriptor?.ImplementationType == null) + { + return; + } + + context.Services.Replace(ServiceDescriptor.Describe( + typeof(IAntiforgery), + sp => new AbpAntiforgery( + (IAntiforgery)ActivatorUtilities.CreateInstance(sp, descriptor.ImplementationType), + sp.GetRequiredService>()), + descriptor.Lifetime)); } protected virtual void ConfigureRouteBasedCulture(ServiceConfigurationContext context) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs new file mode 100644 index 0000000000..9275737245 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Security.Claims; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Claims; + +namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; + +public class AbpAntiForgeryClaimsPrincipalNormalizer : IAbpAntiForgeryClaimsPrincipalNormalizer, ITransientDependency +{ + public const string UserIdClaimIssuer = "AbpAntiForgery"; + + protected virtual string NormalizedIssuer => UserIdClaimIssuer; + + public virtual ClaimsPrincipal Normalize(ClaimsPrincipal principal) + { + var normalized = new ClaimsPrincipal(); + + foreach (var identity in principal.Identities) + { + normalized.AddIdentity(NormalizeIdentity(identity)); + } + + return normalized; + } + + protected virtual ClaimsIdentity NormalizeIdentity(ClaimsIdentity identity) + { + return new ClaimsIdentity( + identity.Claims.Select(NormalizeClaim), + identity.AuthenticationType, + identity.NameClaimType, + identity.RoleClaimType) + { + Actor = identity.Actor, + BootstrapContext = identity.BootstrapContext, + Label = identity.Label + }; + } + + protected virtual Claim NormalizeClaim(Claim claim) + { + var newClaim = new Claim( + claim.Type, + claim.Value, + claim.ValueType, + IsUserIdentifierClaim(claim.Type) ? NormalizedIssuer : claim.Issuer, + claim.OriginalIssuer); + + foreach (var property in claim.Properties) + { + newClaim.Properties[property.Key] = property.Value; + } + + return newClaim; + } + + // The claim types DefaultClaimUidExtractor inspects, in priority order, to build the antiforgery user id. + protected virtual bool IsUserIdentifierClaim(string claimType) + { + return string.Equals(claimType, AbpClaimTypes.UserId, StringComparison.Ordinal) || + string.Equals(claimType, "sub", StringComparison.Ordinal) || + string.Equals(claimType, ClaimTypes.NameIdentifier, StringComparison.Ordinal) || + string.Equals(claimType, ClaimTypes.Upn, StringComparison.Ordinal); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs index 0c2cf8884c..f57a38d57a 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs @@ -24,6 +24,13 @@ public class AbpAntiForgeryOptions /// public bool AutoValidate { get; set; } = true; + /// + /// Normalizes the user id claim issuer before generating/validating the antiforgery token, so the + /// same user produces the same token identifier under both cookie and bearer authentication. + /// Default value: true. + /// + public bool NormalizeUserIdClaimIssuer { get; set; } = true; + /// /// A predicate to filter types to auto-validate. /// Return true to select the type to validate. diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs new file mode 100644 index 0000000000..289920d704 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs @@ -0,0 +1,126 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; + +// Wraps the framework IAntiforgery so the antiforgery token's per-user identifier is computed against a +// normalized principal on every entry point (generation and validation, controllers and Razor Pages, +// ABP and built-in filters, cookie and bearer). This keeps the same user consistent across schemes whose +// user id claim carries a different issuer (e.g. "LOCAL AUTHORITY" for the Identity cookie vs. the token +// authority for a validated JWT or an OIDC cookie). +public class AbpAntiforgery : IAntiforgery +{ + protected IAntiforgery Inner { get; } + + protected AbpAntiForgeryOptions Options { get; } + + public AbpAntiforgery( + IAntiforgery inner, + IOptions options) + { + Inner = inner; + Options = options.Value; + } + + public virtual AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) + { + return WithNormalizedUser(httpContext, () => Inner.GetAndStoreTokens(httpContext)); + } + + public virtual AntiforgeryTokenSet GetTokens(HttpContext httpContext) + { + return WithNormalizedUser(httpContext, () => Inner.GetTokens(httpContext)); + } + + public virtual Task IsRequestValidAsync(HttpContext httpContext) + { + return WithNormalizedUserAsync(httpContext, () => Inner.IsRequestValidAsync(httpContext)); + } + + public virtual Task ValidateRequestAsync(HttpContext httpContext) + { + return WithNormalizedUserAsync(httpContext, async () => + { + await Inner.ValidateRequestAsync(httpContext); + return true; + }); + } + + public virtual void SetCookieTokenAndHeader(HttpContext httpContext) + { + WithNormalizedUser(httpContext, () => + { + Inner.SetCookieTokenAndHeader(httpContext); + return true; + }); + } + + protected virtual T WithNormalizedUser(HttpContext httpContext, Func action) + { + if (!Options.NormalizeUserIdClaimIssuer) + { + return action(); + } + + var normalizer = httpContext.RequestServices.GetRequiredService(); + var authenticateResultFeature = httpContext.Features.Get(); + var originalResult = authenticateResultFeature?.AuthenticateResult; + var originalPrincipal = httpContext.User; + httpContext.User = normalizer.Normalize(originalPrincipal); + try + { + return action(); + } + finally + { + RestoreAuthenticationState(httpContext, authenticateResultFeature, originalResult, originalPrincipal); + } + } + + protected virtual async Task WithNormalizedUserAsync(HttpContext httpContext, Func> action) + { + if (!Options.NormalizeUserIdClaimIssuer) + { + return await action(); + } + + var normalizer = httpContext.RequestServices.GetRequiredService(); + var authenticateResultFeature = httpContext.Features.Get(); + var originalResult = authenticateResultFeature?.AuthenticateResult; + var originalPrincipal = httpContext.User; + httpContext.User = normalizer.Normalize(originalPrincipal); + try + { + return await action(); + } + finally + { + RestoreAuthenticationState(httpContext, authenticateResultFeature, originalResult, originalPrincipal); + } + } + + protected virtual void RestoreAuthenticationState( + HttpContext httpContext, + IAuthenticateResultFeature? authenticateResultFeature, + AuthenticateResult? originalResult, + ClaimsPrincipal originalPrincipal) + { + // Assigning HttpContext.User drops the AuthenticateResult on the built-in feature, so restoring the + // principal alone would leave downstream consumers (SignalR connection expiration, idle checks, ...) + // without the authentication properties. IAuthenticateResultFeature does not require an implementation + // to keep the two in sync, so restore both: the principal first, then the result. + httpContext.User = originalPrincipal; + + if (originalResult != null && authenticateResultFeature != null) + { + authenticateResultFeature.AuthenticateResult = originalResult; + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs new file mode 100644 index 0000000000..bd2a581b5f --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; + +namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; + +public interface IAbpAntiForgeryClaimsPrincipalNormalizer +{ + // Returns a copy of the principal whose user identifier claims carry a stable issuer, so the + // antiforgery token's per-user identifier is the same across authentication schemes. + ClaimsPrincipal Normalize(ClaimsPrincipal principal); +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 464981cb3c..bd5e1e82a0 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -175,7 +175,8 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide GetSupportedVersions(controllerType, method, setting), allowAnonymous, authorizeModels, - implementFrom + implementFrom, + GetReturnValueContentTypes(apiDescription) ) ); @@ -199,6 +200,27 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide } } + private static List? GetReturnValueContentTypes(ApiDescription apiDescription) + { + var preferred = apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode == 200 && x.ApiResponseFormats.Any()) + ?? apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode is >= 200 and < 300 && x.ApiResponseFormats.Any()); + + if (preferred == null) + { + return null; + } + + var contentTypes = preferred.ApiResponseFormats + .Select(f => f.MediaType) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Distinct() + .ToList(); + + return contentTypes.Count > 0 ? contentTypes : null; + } + private static List GetSupportedVersions(Type controllerType, MethodInfo method, ConventionalControllerSetting? setting) { diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter.cs index 5bba1b386a..8ae0500f3d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter.cs @@ -34,9 +34,20 @@ public class RemoteStreamContentOutputFormatter : OutputFormatter context.HttpContext.Response.Headers[HeaderNames.ContentDisposition] = contentDisposition.ToString(); } + var cancellationToken = context.HttpContext.RequestAborted; + using (remoteStream) { - await remoteStream.GetStream().CopyToAsync(context.HttpContext.Response.Body); + var stream = remoteStream.GetStream(); + + try + { + await stream.CopyToAsync(context.HttpContext.Response.Body, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The request was aborted, nothing can be written to the response anymore. + } } } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs index ac6e44ed6c..35c62a626f 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs @@ -42,14 +42,14 @@ public class AbpDateTimeModelBinder : IModelBinder _clock.SupportsMultipleTimezone && !_currentTimezoneProvider.TimeZone.IsNullOrWhiteSpace()) { + var timeZone = _currentTimezoneProvider.TimeZone; try { - var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; + dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - _logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone); + _logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } } diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs index b339508028..f0dc0aa500 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerOptions.cs @@ -1,4 +1,8 @@ -namespace Volo.Abp.BackgroundJobs; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.BackgroundJobs; public class AbpBackgroundJobWorkerOptions { @@ -15,6 +19,7 @@ public class AbpBackgroundJobWorkerOptions /// /// Maximum count of jobs to fetch from data store in one loop. + /// Also used as the batch size for the retention cleanup deletions (see ). /// Default: 1000. /// public int MaxJobFetchCount { get; set; } @@ -42,7 +47,61 @@ public class AbpBackgroundJobWorkerOptions /// Distributed lock name for the worker. /// Default value: "AbpBackgroundJobWorker". /// - public string DistributedLockName { get; set; } + public string DistributedLockName { get; set; } + + /// + /// When set to true, a successfully completed job is kept (its is set) + /// instead of being deleted, so it can be used for auditing/history. Completed jobs are excluded from the + /// waiting jobs query and are removed by the retention cleanup (see ). + /// Default value: false (successful jobs are deleted). + /// + public bool StoreSuccessfulJobs { get; set; } + + /// + /// How long a kept (successfully completed) job is retained before the cleanup deletes it. + /// Only relevant when is true. Set to null to keep them forever (no automatic cleanup). + /// Default value: 7 days. + /// + public TimeSpan? SuccessfulJobRetentionTime { get; set; } + + /// + /// Interval (as milliseconds) between cleanup runs that delete retained successful jobs older than . + /// Default value: 3,600,000 (1 hour). + /// + public int CleanSuccessfulJobsPeriod { get; set; } + + /// + /// Distributed lock name for the cleanup worker. + /// Default value: "AbpBackgroundJobCleanup". + /// + public string CleanupDistributedLockName { get; set; } + + /// + /// Dedicated workers, each processing only the configured job types with its own distributed lock. + /// When this list is not empty, an additional default worker is started that processes all + /// other job types. When it is empty, a single default worker processes all job types (the default behavior). + /// Use to add a dedicated worker. + /// + public List WorkerConfigurations { get; } + + /// + /// Maximum number of jobs that a single worker executes in parallel within one poll cycle. + /// When it is 1 (default), jobs are executed sequentially under a single worker distributed lock + /// (the default behavior). When greater than 1, the worker distributed lock is not used; instead + /// each job is claimed with its own distributed lock so multiple application instances can execute + /// different jobs concurrently. + /// This value should be configured consistently across all application instances: mixing sequential + /// (worker lock) and parallel (per-job lock) instances removes the common mutual exclusion and may + /// let the same job run on more than one instance. + /// Default value: 1. + /// + public int MaxParallelJobExecutionCount { get; set; } + + /// + /// Prefix of the per-job distributed lock name used when is greater than 1. + /// Default value: "AbpBackgroundJob:". + /// + public string PerJobDistributedLockPrefix { get; set; } public AbpBackgroundJobWorkerOptions() { @@ -52,5 +111,97 @@ public class AbpBackgroundJobWorkerOptions DefaultTimeout = 172800; DefaultWaitFactor = 2.0; DistributedLockName = "AbpBackgroundJobWorker"; + WorkerConfigurations = new List(); + MaxParallelJobExecutionCount = 1; + PerJobDistributedLockPrefix = "AbpBackgroundJob:"; + SuccessfulJobRetentionTime = TimeSpan.FromDays(7); + CleanSuccessfulJobsPeriod = 3600000; + CleanupDistributedLockName = "AbpBackgroundJobCleanup"; + } + + /// + /// Adds a dedicated worker that processes only the given job argument types. + /// + /// A unique distributed lock name for this worker. + /// The job argument types handled exclusively by this worker. + public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName, params Type[] jobArgsTypes) + { + Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes)); + + var configuration = new BackgroundJobWorkerConfiguration(lockName, jobArgsTypes.Distinct().ToArray()); + + var duplicateType = configuration.JobArgsTypes.FirstOrDefault( + type => WorkerConfigurations.Any(c => c.JobArgsTypes.Contains(type))); + if (duplicateType != null) + { + throw new AbpException( + $"The background job args type '{duplicateType.FullName}' is already assigned to a dedicated worker. " + + $"Each job type can be handled by only one dedicated worker."); + } + + if (lockName == DistributedLockName || WorkerConfigurations.Any(c => c.LockName == lockName)) + { + throw new AbpException( + $"The distributed lock name '{lockName}' is already used by another background job worker. " + + $"Each worker must have a unique lock name."); + } + + WorkerConfigurations.Add(configuration); + return this; + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName) + { + return AddDedicatedWorker(lockName, typeof(TArgs)); + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName) + { + return AddDedicatedWorker(lockName, typeof(TArgs1), typeof(TArgs2)); + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker(string lockName) + { + return AddDedicatedWorker(lockName, typeof(TArgs1), typeof(TArgs2), typeof(TArgs3)); + } + + /// + /// Adds a dedicated worker with a lock name derived from the given job argument type names. + /// Use the AddDedicatedWorker(string lockName, ...) overloads to set an explicit lock name. + /// + /// The job argument types handled exclusively by this worker. + public AbpBackgroundJobWorkerOptions AddDedicatedWorker(params Type[] jobArgsTypes) + { + return AddDedicatedWorker(GetDedicatedWorkerLockName(jobArgsTypes), jobArgsTypes); + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker() + { + return AddDedicatedWorker(typeof(TArgs)); + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker() + { + return AddDedicatedWorker(typeof(TArgs1), typeof(TArgs2)); + } + + public AbpBackgroundJobWorkerOptions AddDedicatedWorker() + { + return AddDedicatedWorker(typeof(TArgs1), typeof(TArgs2), typeof(TArgs3)); + } + + protected virtual string GetDedicatedWorkerLockName(Type[] jobArgsTypes) + { + Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes)); + + if (jobArgsTypes.Any(t => t == null)) + { + throw new ArgumentException("Job args types cannot contain null.", nameof(jobArgsTypes)); + } + + // Hash the (stable, sorted) full type names so the derived lock name stays short and within the + // length limits of distributed lock providers (e.g. SQL Server sp_getapplock is limited to 255 chars). + var key = string.Join(",", jobArgsTypes.Select(t => t.FullName).Distinct().OrderBy(n => n, StringComparer.Ordinal)); + return "AbpBackgroundJobDedicatedWorker:" + key.ToMd5(); } } diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs index b3601ad1be..0f4b20cd65 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/AbpBackgroundJobsModule.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Volo.Abp.BackgroundWorkers; @@ -23,9 +23,15 @@ public class AbpBackgroundJobsModule : AbpModule { public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) { - if (context.ServiceProvider.GetRequiredService>().Value.IsJobExecutionEnabled) + // The manager decides (based on options) whether and which workers to run. + await context.AddBackgroundWorkerAsync(); + + // Only register the cleanup worker when it has something to do (retained successful jobs). + var jobOptions = context.ServiceProvider.GetRequiredService>().Value; + var workerOptions = context.ServiceProvider.GetRequiredService>().Value; + if (jobOptions.IsJobExecutionEnabled && workerOptions.StoreSuccessfulJobs && workerOptions.SuccessfulJobRetentionTime != null) { - await context.AddBackgroundWorkerAsync(); + await context.AddBackgroundWorkerAsync(); } } diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs new file mode 100644 index 0000000000..72c36b8ffb --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Threading; +using Volo.Abp.Timing; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Periodically deletes retained successfully completed jobs older than +/// . +/// Only relevant when is enabled. +/// +public class BackgroundJobCleanupWorker : AsyncPeriodicBackgroundWorkerBase +{ + protected AbpBackgroundJobOptions JobOptions { get; } + + protected AbpBackgroundJobWorkerOptions WorkerOptions { get; } + + protected IAbpDistributedLock DistributedLock { get; } + + public BackgroundJobCleanupWorker( + AbpAsyncTimer timer, + IServiceScopeFactory serviceScopeFactory, + IOptions jobOptions, + IOptions workerOptions, + IAbpDistributedLock distributedLock) + : base(timer, serviceScopeFactory) + { + JobOptions = jobOptions.Value; + WorkerOptions = workerOptions.Value; + DistributedLock = distributedLock; + Timer.Period = WorkerOptions.CleanSuccessfulJobsPeriod; + } + + protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext) + { + if (!JobOptions.IsJobExecutionEnabled || + !WorkerOptions.StoreSuccessfulJobs || + WorkerOptions.SuccessfulJobRetentionTime == null) + { + return; + } + + var store = workerContext.ServiceProvider.GetRequiredService(); + var clock = workerContext.ServiceProvider.GetRequiredService(); + var completedBefore = clock.Now.Subtract(WorkerOptions.SuccessfulJobRetentionTime.Value); + + await using (var handle = await DistributedLock.TryAcquireAsync(WorkerOptions.CleanupDistributedLockName, cancellationToken: StoppingToken)) + { + if (handle == null) + { + return; + } + + int deletedCount; + do + { + deletedCount = await store.DeleteAsync(WorkerOptions.ApplicationName, completedBefore, WorkerOptions.MaxJobFetchCount, StoppingToken); + } + while (deletedCount > 0 && deletedCount >= WorkerOptions.MaxJobFetchCount && !StoppingToken.IsCancellationRequested); + } + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs index 4a9cec922c..51f73ae7b8 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobInfo.cs @@ -50,6 +50,14 @@ public class BackgroundJobInfo /// public virtual bool IsAbandoned { get; set; } + /// + /// The time this job was completed successfully. + /// When set, the job is kept as history and excluded from the waiting jobs query. + /// It is only set when is enabled; + /// otherwise successfully completed jobs are deleted. + /// + public virtual DateTime? CompletionTime { get; set; } + /// /// Priority of this job. /// diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs new file mode 100644 index 0000000000..06c0ebf398 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilter.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Filters the waiting jobs of a background job worker by job name. +/// A worker is exactly one of: no filter (), include-only (a dedicated worker) or +/// exclude-only (the default worker in a multi-worker setup) — the two can never be combined. +/// +public class BackgroundJobNameFilter +{ + /// + /// A filter that matches every job name. + /// + public static BackgroundJobNameFilter None { get; } = new(BackgroundJobNameFilterMode.None); + + public BackgroundJobNameFilterMode Mode { get; } + + public IReadOnlyList JobNames { get; } + + public BackgroundJobNameFilter(BackgroundJobNameFilterMode mode, IReadOnlyList? jobNames = null) + { + if (!Enum.IsDefined(typeof(BackgroundJobNameFilterMode), mode)) + { + throw new ArgumentException($"Invalid background job name filter mode: {mode}", nameof(mode)); + } + + var names = jobNames?.Where(x => !x.IsNullOrWhiteSpace()).Distinct(StringComparer.Ordinal).ToList() ?? new List(); + + if (mode == BackgroundJobNameFilterMode.None && names.Count > 0) + { + throw new ArgumentException("Job names must be empty when the filter mode is None.", nameof(jobNames)); + } + + if (mode != BackgroundJobNameFilterMode.None && names.Count == 0) + { + throw new ArgumentException("Job names cannot be empty when the filter mode is Include or Exclude.", nameof(jobNames)); + } + + Mode = mode; + JobNames = names.AsReadOnly(); + } + + public static BackgroundJobNameFilter Include(IReadOnlyList jobNames) + { + return new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Include, jobNames); + } + + public static BackgroundJobNameFilter Exclude(IReadOnlyList jobNames) + { + return new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Exclude, jobNames); + } + + /// + /// Whether the given job name passes this filter, using an ordinal (case-sensitive) comparison for the + /// in-memory eligibility re-check. The persistent stores translate and + /// into a database query instead, so their filtering follows the database collation. + /// Job names are expected to be unique beyond case (they are derived from the type name by default). + /// + public virtual bool IsMatch(string jobName) + { + return Mode switch + { + BackgroundJobNameFilterMode.Include => JobNames.Contains(jobName, StringComparer.Ordinal), + BackgroundJobNameFilterMode.Exclude => !JobNames.Contains(jobName, StringComparer.Ordinal), + _ => true + }; + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs new file mode 100644 index 0000000000..6ed2801b44 --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobNameFilterMode.cs @@ -0,0 +1,19 @@ +namespace Volo.Abp.BackgroundJobs; + +public enum BackgroundJobNameFilterMode : byte +{ + /// + /// No filter; all job names match. + /// + None = 0, + + /// + /// Only the job names in the filter match. + /// + Include = 1, + + /// + /// All job names except those in the filter match. + /// + Exclude = 2 +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs index a015e32d66..3053bb426f 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorker.cs @@ -1,17 +1,22 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DependencyInjection; using Volo.Abp.DistributedLocking; +using Volo.Abp.ExceptionHandling; using Volo.Abp.Threading; using Volo.Abp.Timing; namespace Volo.Abp.BackgroundJobs; -public class BackgroundJobWorker : AsyncPeriodicBackgroundWorkerBase, IBackgroundJobWorker +public class BackgroundJobWorker : IBackgroundJobWorker, ITransientDependency { protected AbpBackgroundJobOptions JobOptions { get; } @@ -19,95 +24,321 @@ public class BackgroundJobWorker : AsyncPeriodicBackgroundWorkerBase, IBackgroun protected IAbpDistributedLock DistributedLock { get; } + protected IServiceScopeFactory ServiceScopeFactory { get; } + + protected AbpAsyncTimer Timer { get; } + + public ILogger Logger { get; set; } + + protected string DistributedLockName { get; set; } = default!; + + protected BackgroundJobNameFilter JobNameFilter { get; set; } = BackgroundJobNameFilter.None; + + protected CancellationTokenSource StoppingTokenSource { get; } + + protected CancellationToken StoppingToken { get; } + public BackgroundJobWorker( AbpAsyncTimer timer, IOptions jobOptions, IOptions workerOptions, IServiceScopeFactory serviceScopeFactory, IAbpDistributedLock distributedLock) - : base( - timer, - serviceScopeFactory) { + Timer = timer; DistributedLock = distributedLock; + ServiceScopeFactory = serviceScopeFactory; WorkerOptions = workerOptions.Value; JobOptions = jobOptions.Value; + Logger = NullLogger.Instance; + Timer.Period = WorkerOptions.JobPollPeriod; + Timer.Elapsed = TimerOnElapsed; + + StoppingTokenSource = new CancellationTokenSource(); + StoppingToken = StoppingTokenSource.Token; + } + + public virtual Task StartAsync( + string? distributedLockName = null, + BackgroundJobNameFilter? jobNameFilter = null, + CancellationToken cancellationToken = default) + { + DistributedLockName = distributedLockName ?? WorkerOptions.DistributedLockName; + JobNameFilter = jobNameFilter ?? BackgroundJobNameFilter.None; + + Timer.Start(cancellationToken); + + return Task.CompletedTask; + } + + public virtual Task StopAsync(CancellationToken cancellationToken = default) + { + StoppingTokenSource.Cancel(); + Timer.Stop(cancellationToken); + StoppingTokenSource.Dispose(); + + return Task.CompletedTask; + } + + private async Task TimerOnElapsed(AbpAsyncTimer timer) + { + await RunAsync(); + } + + protected virtual async Task RunAsync() + { + using var scope = ServiceScopeFactory.CreateScope(); + + try + { + var workerContext = new PeriodicBackgroundWorkerContext(scope.ServiceProvider, StoppingToken); + + if (WorkerOptions.MaxParallelJobExecutionCount > 1) + { + await ExecuteJobsInParallelAsync(workerContext); + } + else + { + await ExecuteJobsWithWorkerLockAsync(workerContext); + } + } + catch (Exception ex) + { + await scope.ServiceProvider + .GetRequiredService() + .NotifyAsync(new ExceptionNotificationContext(ex)); + + Logger.LogException(ex); + } } - protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext) + protected virtual async Task ExecuteJobsWithWorkerLockAsync(PeriodicBackgroundWorkerContext workerContext) { - await using (var handler = await DistributedLock.TryAcquireAsync(WorkerOptions.DistributedLockName, cancellationToken: StoppingToken)) + await using (var handler = await DistributedLock.TryAcquireAsync(DistributedLockName, cancellationToken: StoppingToken)) { if (handler != null) { - var store = workerContext.ServiceProvider.GetRequiredService(); + await ExecuteWaitingJobsAsync(workerContext); + } + else + { + await WaitForNextTryAsync(); + } + } + } + + protected virtual async Task ExecuteWaitingJobsAsync(PeriodicBackgroundWorkerContext workerContext) + { + var store = workerContext.ServiceProvider.GetRequiredService(); + + var waitingJobs = await GetWaitingJobsAsync(workerContext, store); - var waitingJobs = await store.GetWaitingJobsAsync(WorkerOptions.ApplicationName, WorkerOptions.MaxJobFetchCount); + if (!waitingJobs.Any()) + { + return; + } - if (!waitingJobs.Any()) + var jobExecuter = workerContext.ServiceProvider.GetRequiredService(); + var clock = workerContext.ServiceProvider.GetRequiredService(); + var serializer = workerContext.ServiceProvider.GetRequiredService(); + + foreach (var jobInfo in waitingJobs) + { + await TryExecuteJobAsync(workerContext, store, jobInfo, jobExecuter, clock, serializer); + } + } + + /// + /// Executes waiting jobs in parallel across application instances, up to + /// jobs per cycle. + /// + protected virtual async Task ExecuteJobsInParallelAsync(PeriodicBackgroundWorkerContext workerContext) + { + var store = workerContext.ServiceProvider.GetRequiredService(); + + var waitingJobs = await GetWaitingJobsAsync(workerContext, store); + + if (!waitingJobs.Any()) + { + return; + } + + var runningTasks = new List(); + + // Await already-started jobs even if acquiring a lock for a later job throws, + // so no claimed job is left running detached from this cycle. + try + { + foreach (var jobInfo in waitingJobs) + { + if (runningTasks.Count >= WorkerOptions.MaxParallelJobExecutionCount || StoppingToken.IsCancellationRequested) { - return; + break; } - var jobExecuter = workerContext.ServiceProvider.GetRequiredService(); - var clock = workerContext.ServiceProvider.GetRequiredService(); - var serializer = workerContext.ServiceProvider.GetRequiredService(); - - foreach (var jobInfo in waitingJobs) + var handle = await DistributedLock.TryAcquireAsync(GetPerJobDistributedLockName(jobInfo), cancellationToken: StoppingToken); + if (handle == null) { - jobInfo.TryCount++; - jobInfo.LastTryTime = clock.Now; - - try - { - var jobConfiguration = JobOptions.GetJob(jobInfo.JobName); - var jobArgs = serializer.Deserialize(jobInfo.JobArgs, jobConfiguration.ArgsType); - var context = new JobExecutionContext( - workerContext.ServiceProvider, - jobConfiguration.JobType, - jobArgs, - workerContext.CancellationToken); - - try - { - await jobExecuter.ExecuteAsync(context); - - await store.DeleteAsync(jobInfo.Id); - } - catch (BackgroundJobExecutionException) - { - var nextTryTime = CalculateNextTryTime(jobInfo, clock); - - if (nextTryTime.HasValue) - { - jobInfo.NextTryTime = nextTryTime.Value; - } - else - { - jobInfo.IsAbandoned = true; - } - - await TryUpdateAsync(store, jobInfo); - } - } - catch (Exception ex) - { - Logger.LogException(ex); - jobInfo.IsAbandoned = true; - await TryUpdateAsync(store, jobInfo); - } + // Another instance is already processing this job. + continue; } + + runningTasks.Add(ExecuteClaimedJobAsync(jobInfo, handle)); } - else + } + finally + { + await Task.WhenAll(runningTasks); + } + } + + protected virtual async Task ExecuteClaimedJobAsync(BackgroundJobInfo jobInfo, IAbpDistributedLockHandle handle) + { + await using (handle) + { + // Each concurrently executed job runs in its own service scope so that scoped services + // (e.g. the DbContext and the unit of work) are not shared across parallel jobs. + using var scope = ServiceScopeFactory.CreateScope(); + + try { - try + var workerContext = new PeriodicBackgroundWorkerContext(scope.ServiceProvider, StoppingToken); + var store = scope.ServiceProvider.GetRequiredService(); + var clock = scope.ServiceProvider.GetRequiredService(); + + // Re-read under the lock: another instance may have completed, abandoned or rescheduled this job + // between fetching the waiting list and acquiring the per-job lock. + var currentJobInfo = await store.FindAsync(jobInfo.Id); + if (!IsJobEligible(currentJobInfo, clock)) { - await Task.Delay(WorkerOptions.JobPollPeriod * 12, StoppingToken); + return; } - catch (TaskCanceledException) { } + + var jobExecuter = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + + await TryExecuteJobAsync(workerContext, store, currentJobInfo, jobExecuter, clock, serializer); + } + catch (Exception ex) + { + await scope.ServiceProvider + .GetRequiredService() + .NotifyAsync(new ExceptionNotificationContext(ex)); + + Logger.LogException(ex); + } + } + } + + protected virtual bool IsJobEligible(BackgroundJobInfo? jobInfo, IClock clock) + { + return jobInfo != null && + jobInfo.ApplicationName == WorkerOptions.ApplicationName && + !jobInfo.IsAbandoned && + jobInfo.CompletionTime == null && + jobInfo.NextTryTime <= clock.Now && + JobNameFilter.IsMatch(jobInfo.JobName); + } + + protected virtual string GetPerJobDistributedLockName(BackgroundJobInfo jobInfo) + { + return WorkerOptions.PerJobDistributedLockPrefix + jobInfo.Id; + } + + protected virtual async Task> GetWaitingJobsAsync( + PeriodicBackgroundWorkerContext workerContext, + IBackgroundJobStore store) + { + return await store.GetWaitingJobsAsync( + WorkerOptions.ApplicationName, + WorkerOptions.MaxJobFetchCount, + JobNameFilter); + } + + protected virtual async Task TryExecuteJobAsync( + PeriodicBackgroundWorkerContext workerContext, + IBackgroundJobStore store, + BackgroundJobInfo jobInfo, + IBackgroundJobExecuter jobExecuter, + IClock clock, + IBackgroundJobSerializer serializer) + { + jobInfo.TryCount++; + jobInfo.LastTryTime = clock.Now; + + try + { + var jobConfiguration = JobOptions.GetJob(jobInfo.JobName); + var jobArgs = serializer.Deserialize(jobInfo.JobArgs, jobConfiguration.ArgsType); + var context = new JobExecutionContext( + workerContext.ServiceProvider, + jobConfiguration.JobType, + jobArgs, + workerContext.CancellationToken); + + try + { + await jobExecuter.ExecuteAsync(context); + + await HandleJobSuccessAsync(store, jobInfo, clock); + } + catch (BackgroundJobExecutionException) + { + await HandleJobFailureAsync(store, jobInfo, clock); } } + catch (Exception ex) + { + await HandleJobErrorAsync(store, jobInfo, ex); + } + } + + protected virtual async Task HandleJobSuccessAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock) + { + if (WorkerOptions.StoreSuccessfulJobs) + { + // Keep the job as history: mark it completed instead of deleting. It is then excluded from the + // waiting jobs query and removed later by the retention cleanup. + jobInfo.CompletionTime = clock.Now; + await store.UpdateAsync(jobInfo); + } + else + { + await store.DeleteAsync(jobInfo.Id); + } + } + + protected virtual async Task HandleJobFailureAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock) + { + var nextTryTime = CalculateNextTryTime(jobInfo, clock); + + if (nextTryTime.HasValue) + { + jobInfo.NextTryTime = nextTryTime.Value; + } + else + { + jobInfo.IsAbandoned = true; + } + + await TryUpdateAsync(store, jobInfo); + } + + protected virtual async Task HandleJobErrorAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, Exception ex) + { + Logger.LogException(ex); + jobInfo.IsAbandoned = true; + await TryUpdateAsync(store, jobInfo); + } + + protected virtual async Task WaitForNextTryAsync() + { + try + { + await Task.Delay(WorkerOptions.JobPollPeriod * 12, StoppingToken); + } + catch (TaskCanceledException) { } } protected virtual async Task TryUpdateAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo) diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs new file mode 100644 index 0000000000..848a2a6a1c --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerConfiguration.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Configuration of a dedicated that processes only specific job types. +/// +public class BackgroundJobWorkerConfiguration +{ + /// + /// A unique distributed lock name for this worker. It must be different from the names used by other workers. + /// It is used to serialize the worker across application instances when + /// is 1; in parallel mode + /// (greater than 1) jobs are claimed with per-job locks instead and this lock is not acquired. + /// + public string LockName { get; } + + /// + /// The job argument types that are processed exclusively by this worker. + /// + public IReadOnlyList JobArgsTypes { get; } + + public BackgroundJobWorkerConfiguration(string lockName, params Type[] jobArgsTypes) + { + LockName = Check.NotNullOrWhiteSpace(lockName, nameof(lockName)); + Check.NotNullOrEmpty(jobArgsTypes, nameof(jobArgsTypes)); + + if (jobArgsTypes.Any(t => t == null)) + { + throw new ArgumentException("Job args types cannot contain null.", nameof(jobArgsTypes)); + } + + JobArgsTypes = jobArgsTypes.ToList(); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs new file mode 100644 index 0000000000..929c4803da --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/BackgroundJobWorkerManager.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.BackgroundWorkers; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Owns and controls the background job workers. +/// When no is configured, a single +/// default worker processes all jobs. Otherwise, one dedicated worker is started per configuration +/// (each with its own distributed lock and job-type filter) plus a default worker for the remaining jobs. +/// The workers are resolved from DI, so a replaced is respected. +/// +public class BackgroundJobWorkerManager : IBackgroundWorker +{ + protected AbpBackgroundJobOptions JobOptions { get; } + + protected AbpBackgroundJobWorkerOptions WorkerOptions { get; } + + protected IServiceProvider ServiceProvider { get; } + + protected List Workers { get; } + + public BackgroundJobWorkerManager( + IOptions jobOptions, + IOptions workerOptions, + IServiceProvider serviceProvider) + { + JobOptions = jobOptions.Value; + WorkerOptions = workerOptions.Value; + ServiceProvider = serviceProvider; + Workers = new List(); + } + + public virtual async Task StartAsync(CancellationToken cancellationToken = default) + { + if (!JobOptions.IsJobExecutionEnabled) + { + return; + } + + if (!WorkerOptions.WorkerConfigurations.Any()) + { + await StartWorkerAsync(cancellationToken: cancellationToken); + return; + } + + // AddDedicatedWorker already rejects duplicate job types and lock names eagerly. This is the backstop + // for what can only be known here: two different args types that resolve to the same job name. + // Validate all configurations first, so a misconfiguration does not leave already-started workers running. + var dedicatedWorkers = new List(); + var allDedicatedJobNames = new List(); + + // The default worker uses WorkerOptions.DistributedLockName, so dedicated workers must not reuse it. + var lockNames = new List { WorkerOptions.DistributedLockName }; + + foreach (var configuration in WorkerOptions.WorkerConfigurations) + { + var jobNames = configuration.JobArgsTypes + .Select(GetJobName) + .Distinct() + .ToList(); + + var alreadyConfigured = jobNames.Intersect(allDedicatedJobNames).ToList(); + if (alreadyConfigured.Any()) + { + throw new AbpException( + $"The following background job(s) are configured for more than one dedicated worker: {string.Join(", ", alreadyConfigured)}. " + + $"Each job type can be handled by only one dedicated worker."); + } + + if (lockNames.Contains(configuration.LockName)) + { + throw new AbpException( + $"The distributed lock name '{configuration.LockName}' is used by more than one background job worker " + + $"(the default worker uses '{WorkerOptions.DistributedLockName}'). Each worker must have a unique lock name to run independently."); + } + + lockNames.Add(configuration.LockName); + allDedicatedJobNames.AddRange(jobNames); + dedicatedWorkers.Add(new DedicatedWorkerDefinition(configuration.LockName, jobNames)); + } + + foreach (var dedicatedWorker in dedicatedWorkers) + { + await StartWorkerAsync(dedicatedWorker.LockName, BackgroundJobNameFilter.Include(dedicatedWorker.JobNames), cancellationToken); + } + + // Default worker processes every job that is not handled by a dedicated worker. + await StartWorkerAsync(null, BackgroundJobNameFilter.Exclude(allDedicatedJobNames), cancellationToken); + } + + protected virtual string GetJobName(Type argsType) + { + try + { + return JobOptions.GetJob(argsType).JobName; + } + catch (AbpException ex) + { + throw new AbpException( + $"No background job is registered for the args type '{argsType.FullName}' configured via AddDedicatedWorker. " + + $"Register the job before configuring a dedicated worker for it.", ex); + } + } + + protected virtual async Task StartWorkerAsync( + string? distributedLockName = null, + BackgroundJobNameFilter? jobNameFilter = null, + CancellationToken cancellationToken = default) + { + var worker = ServiceProvider.GetRequiredService(); + await worker.StartAsync(distributedLockName, jobNameFilter, cancellationToken); + Workers.Add(worker); + } + + public virtual async Task StopAsync(CancellationToken cancellationToken = default) + { + foreach (var worker in Workers) + { + await worker.StopAsync(cancellationToken); + } + + Workers.Clear(); + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs new file mode 100644 index 0000000000..ba98c2c1dc --- /dev/null +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/DedicatedWorkerDefinition.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// A validated, ready-to-start dedicated worker: the distributed lock name it runs under and the +/// resolved job names it is responsible for. Built by from a +/// after all configurations have been validated. +/// +public class DedicatedWorkerDefinition +{ + /// + /// The distributed lock name this worker runs under. + /// + public string LockName { get; } + + /// + /// The resolved job names this worker is responsible for. + /// + public IReadOnlyList JobNames { get; } + + public DedicatedWorkerDefinition(string lockName, IReadOnlyList jobNames) + { + LockName = lockName; + JobNames = jobNames; + } +} diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs index 7fcf8cc5b6..421ec55434 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobStore.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Volo.Abp.BackgroundJobs; @@ -24,7 +25,7 @@ public interface IBackgroundJobStore /// /// Gets waiting jobs. It should get jobs based on these: - /// Conditions: ApplicationName is applicationName And !IsAbandoned And NextTryTime <= Clock.Now. + /// Conditions: ApplicationName is applicationName And !IsAbandoned And CompletionTime == null And NextTryTime <= Clock.Now. /// Order by: Priority DESC, TryCount ASC, NextTryTime ASC. /// Maximum result: . /// @@ -32,12 +33,35 @@ public interface IBackgroundJobStore /// Maximum result count. Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount); + /// + /// Gets waiting jobs (see ), additionally filtered by job name + /// according to . + /// + /// Application name. + /// Maximum result count. + /// Job name filter. When null, no job name filter is applied. + Task> GetWaitingJobsAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter); + /// /// Deletes a job. /// /// The Job Unique Identifier. Task DeleteAsync(Guid jobId); + /// + /// Deletes successfully completed jobs ( is set) of the given + /// application that completed before . Used by the retention cleanup. + /// + /// The number of deleted jobs. + Task DeleteAsync( + string? applicationName, + DateTime completedBefore, + int maxResultCount, + CancellationToken cancellationToken = default); + /// /// Updates a job. /// diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs index ecae732b8d..9aa4ac25a8 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/IBackgroundJobWorker.cs @@ -1,8 +1,27 @@ -using Volo.Abp.BackgroundWorkers; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace Volo.Abp.BackgroundJobs; -public interface IBackgroundJobWorker : IBackgroundWorker +/// +/// A background job worker that polls and executes waiting jobs. +/// Instances are created, configured and started by . +/// +public interface IBackgroundJobWorker { + /// + /// Starts this worker. + /// + /// + /// Distributed lock name for this worker. When null, is used. + /// + /// Filters the jobs this worker processes by name. When null, all jobs are processed. + /// Cancellation token. + Task StartAsync( + string? distributedLockName = null, + BackgroundJobNameFilter? jobNameFilter = null, + CancellationToken cancellationToken = default); + Task StopAsync(CancellationToken cancellationToken = default); } diff --git a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs index 85916e8c37..d0fa34e7e8 100644 --- a/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs +++ b/framework/src/Volo.Abp.BackgroundJobs/Volo/Abp/BackgroundJobs/InMemoryBackgroundJobStore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; using Volo.Abp.Timing; @@ -37,9 +38,20 @@ public class InMemoryBackgroundJobStore : IBackgroundJobStore, ISingletonDepende public virtual Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount) { + return GetWaitingJobsAsync(applicationName, maxResultCount, null); + } + + public virtual Task> GetWaitingJobsAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter) + { + var filter = jobNameFilter ?? BackgroundJobNameFilter.None; + var waitingJobs = _jobs.Values .Where(t => t.ApplicationName == applicationName) - .Where(t => !t.IsAbandoned && t.NextTryTime <= Clock.Now) + .Where(t => !t.IsAbandoned && t.CompletionTime == null && t.NextTryTime <= Clock.Now) + .Where(t => filter.IsMatch(t.JobName)) .OrderByDescending(t => t.Priority) .ThenBy(t => t.TryCount) .ThenBy(t => t.NextTryTime) @@ -57,6 +69,28 @@ public class InMemoryBackgroundJobStore : IBackgroundJobStore, ISingletonDepende return Task.CompletedTask; } + public virtual Task DeleteAsync( + string? applicationName, + DateTime completedBefore, + int maxResultCount, + CancellationToken cancellationToken = default) + { + var idsToDelete = _jobs.Values + .Where(t => t.ApplicationName == applicationName) + .Where(t => t.CompletionTime != null && t.CompletionTime < completedBefore) + .OrderBy(t => t.CompletionTime) + .Take(maxResultCount) + .Select(t => t.Id) + .ToList(); + + foreach (var id in idsToDelete) + { + _jobs.TryRemove(id, out _); + } + + return Task.FromResult(idsToDelete.Count); + } + public virtual Task UpdateAsync(BackgroundJobInfo jobInfo) { if (jobInfo.IsAbandoned) diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs index 107008deea..0b7a21c71e 100644 --- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs +++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs @@ -108,8 +108,7 @@ public class ClientProxyBase : ITransientDependency { var responseContent = await RequestAsync(requestContext); - if (typeof(T) == typeof(IRemoteStreamContent) || - typeof(T) == typeof(RemoteStreamContent)) + if (typeof(T) == typeof(IRemoteStreamContent) || typeof(T) == typeof(RemoteStreamContent)) { /* returning a class that holds a reference to response * content just to be sure that GC does not dispose of @@ -127,7 +126,8 @@ public class ClientProxyBase : ITransientDependency var stringContent = await responseContent.ReadAsStringAsync(); if (typeof(T) == typeof(string)) { - return (T)(object)stringContent; + var unwrapped = UnwrapStringResponse(stringContent, responseContent.Headers?.ContentType?.MediaType); + return (T)(object)unwrapped!; } if (stringContent.IsNullOrWhiteSpace()) @@ -139,6 +139,39 @@ public class ClientProxyBase : ITransientDependency } } + protected virtual string? UnwrapStringResponse(string body, string? contentType) + { + if (body.IsNullOrEmpty() || contentType.IsNullOrWhiteSpace()) + { + return body; + } + + if (!IsJsonMediaType(NormalizeMediaType(contentType!))) + { + return body; + } + + try + { + var parsed = JsonSerializer.Deserialize(body); + return parsed ?? string.Empty; + } + catch + { + return body; + } + } + + protected static string NormalizeMediaType(string mediaType) + { + if (mediaType.IsNullOrWhiteSpace()) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); + } + protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext) { var clientConfig = ClientOptions.Value.HttpClientProxies.GetOrDefault(requestContext.ServiceType) ?? throw new AbpException($"Could not get HttpClientProxyConfig for {requestContext.ServiceType.FullName}."); @@ -326,14 +359,7 @@ public class ClientProxyBase : ITransientDependency HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) { - //API Version - if (!apiVersion.Version.IsNullOrEmpty()) - { - //TODO: What about other media types? - requestMessage.Headers.Add("accept", $"{MimeTypes.Text.Plain}; v={apiVersion.Version}"); - requestMessage.Headers.Add("accept", $"{MimeTypes.Application.Json}; v={apiVersion.Version}"); - requestMessage.Headers.Add("api-version", apiVersion.Version); - } + AddAcceptHeaders(action, requestMessage, apiVersion); //Header parameters var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray(); @@ -378,6 +404,57 @@ public class ClientProxyBase : ITransientDependency } } + protected virtual void AddAcceptHeaders( + ActionApiDescriptionModel action, + HttpRequestMessage requestMessage, + ApiVersionInfo apiVersion) + { + var acceptForReturn = GetAcceptForActionReturn(action); + var versionSuffix = apiVersion.Version.IsNullOrEmpty() ? string.Empty : $"; v={apiVersion.Version}"; + + if (!acceptForReturn.IsNullOrEmpty()) + { + requestMessage.Headers.Add("accept", acceptForReturn + versionSuffix); + } + else + { + requestMessage.Headers.Add("accept", MimeTypes.Text.Plain + versionSuffix); + requestMessage.Headers.Add("accept", MimeTypes.Application.Json + versionSuffix); + } + + if (!apiVersion.Version.IsNullOrEmpty()) + { + requestMessage.Headers.Add("api-version", apiVersion.Version); + } + } + + protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action) + { + if (action.ReturnValue.IsRemoteStream || + action.ReturnValue.Type == typeof(IRemoteStreamContent).FullName || + action.ReturnValue.Type == typeof(RemoteStreamContent).FullName) + { + return MimeTypes.Application.OctetStream; + } + + var contentTypes = action.ReturnValue.ContentTypes; + if (contentTypes == null || contentTypes.Count == 0) + { + return null; + } + + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + return normalized.FirstOrDefault(IsJsonMediaType) ?? normalized[0]; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals(MimeTypes.Application.Json, StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + protected virtual StringSegment RemoveQuotes(StringSegment input) { if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"') diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs index 7650e40f88..e01b39c94d 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs @@ -45,7 +45,7 @@ public class ActionApiDescriptionModel } - public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null) + public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null, IList? returnValueContentTypes = null) { Check.NotNull(uniqueName, nameof(uniqueName)); Check.NotNull(method, nameof(method)); @@ -58,7 +58,7 @@ public class ActionApiDescriptionModel Name = method.Name, Url = url, HttpMethod = httpMethod, - ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType), + ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes), Parameters = new List(), ParametersOnMethod = method .GetParameters() diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs index e77d2f7fea..25b2fb149a 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using Volo.Abp.Content; using Volo.Abp.Reflection; using Volo.Abp.Threading; @@ -13,19 +15,30 @@ public class ReturnValueApiDescriptionModel public string? Summary { get; set; } + public IList? ContentTypes { get; set; } + + public bool IsRemoteStream { get; set; } + public ReturnValueApiDescriptionModel() { } - public static ReturnValueApiDescriptionModel Create(Type type) + public static ReturnValueApiDescriptionModel Create(Type type, IList? contentTypes = null) { var unwrappedType = AsyncHelper.UnwrapTask(type); return new ReturnValueApiDescriptionModel { Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(unwrappedType), - TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType) + TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType), + ContentTypes = contentTypes, + IsRemoteStream = IsRemoteStreamType(unwrappedType) }; } + + private static bool IsRemoteStreamType(Type type) + { + return type == typeof(IRemoteStreamContent) || type == typeof(RemoteStreamContent); + } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index 18af3e0661..8e15d85191 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -135,10 +135,9 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen AddAjaxCallParameters(script, action); - var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); - var dataType = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type - ? "{ dataType: 'text' }, " - : string.Empty; + var hasFormFile = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.FormFile); + var ajaxParamsIsFromForm = !hasFormFile && action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); + var dataType = GetJQueryDataTypeAndAcceptOverride(action); script.AppendLine(ajaxParamsIsFromForm ? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));" : " }, " + dataType + "ajaxParams));"); @@ -146,6 +145,54 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen script.AppendLine(" };"); } + private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action) + { + if (action.ReturnValue.IsRemoteStream) + { + return string.Empty; + } + + var contentTypes = action.ReturnValue.ContentTypes; + var isStringReturn = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type; + + if (contentTypes is { Count: > 0 }) + { + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + var firstJsonShaped = normalized.FirstOrDefault(IsJsonMediaType); + if (firstJsonShaped != null) + { + return "{ dataType: 'json', headers: { Accept: '" + firstJsonShaped + "' } }, "; + } + + if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + { + return "{ dataType: 'text', headers: { Accept: '" + normalized[0] + "' } }, "; + } + + return "{ headers: { Accept: '" + normalized[0] + "' } }, "; + } + + return isStringReturn ? "{ dataType: 'text' }, " : string.Empty; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeMediaType(string mediaType) + { + if (string.IsNullOrWhiteSpace(mediaType)) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); + } + private static string FindBestApiVersion(ActionApiDescriptionModel action) { //var configuredVersion = GetConfiguredApiVersion(); //TODO: Implement @@ -184,6 +231,20 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen script.Append(" headers: " + headers); } + var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile); + if (firstFileParam != null) + { + var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase()); + script.AppendLine(","); + script.Append(" data: " + fileVar + ","); + script.AppendLine(); + script.Append(" processData: false,"); + script.AppendLine(); + script.Append(" contentType: false"); + script.AppendLine(); + return; + } + var body = ProxyScriptingHelper.GenerateBody(action); if (!body.IsNullOrEmpty()) { diff --git a/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs b/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs index 55c270d14e..3b278d5252 100644 --- a/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs +++ b/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs @@ -134,14 +134,14 @@ public class AbpDateTimeConverter : DateTimeConverterBase, ITransientDependency return _skipDateTimeNormalization ? dateTime : _clock.Normalize(dateTime); } + var timeZone = _currentTimezoneProvider.TimeZone; try { - var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; + dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone); + Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } return _skipDateTimeNormalization diff --git a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs index ee39a66678..02aaec00b2 100644 --- a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs +++ b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs @@ -101,14 +101,14 @@ public abstract class AbpDateTimeConverterBase : JsonConverter return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime); } + var timeZone = CurrentTimezoneProvider.TimeZone; try { - var timezoneInfo = TimezoneProvider.GetTimeZoneInfo(CurrentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; + dateTime = TimezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone); + Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime); diff --git a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs index eac0f6644c..80efb49f4c 100644 --- a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs +++ b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs @@ -13,11 +13,11 @@ public interface IAbpMapperlyMapper public interface IAbpReverseMapperlyMapper : IAbpMapperlyMapper { - TSource ReverseMap(TDestination destination); + TSource ReverseMap(TDestination source); - void ReverseMap(TDestination destination, TSource source); + void ReverseMap(TDestination source, TSource destination); - void BeforeReverseMap(TDestination destination); + void BeforeReverseMap(TDestination source); - void AfterReverseMap(TDestination destination, TSource source); + void AfterReverseMap(TDestination source, TSource destination); } diff --git a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs index 39d9dce995..f49b2f14c9 100644 --- a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs +++ b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs @@ -18,15 +18,15 @@ public abstract class MapperBase : IAbpMapperlyMapper : MapperBase, IAbpReverseMapperlyMapper { - public abstract TSource ReverseMap(TDestination destination); + public abstract TSource ReverseMap(TDestination source); - public abstract void ReverseMap(TDestination destination, TSource source); + public abstract void ReverseMap(TDestination source, TSource destination); - public virtual void BeforeReverseMap(TDestination destination) + public virtual void BeforeReverseMap(TDestination source) { } - public virtual void AfterReverseMap(TDestination destination, TSource source) + public virtual void AfterReverseMap(TDestination source, TSource destination) { } } diff --git a/framework/src/Volo.Abp.MudBlazorUI/Components/UiMessageAlert.razor.cs b/framework/src/Volo.Abp.MudBlazorUI/Components/UiMessageAlert.razor.cs index 58ed2fae1b..ccc1f2c085 100644 --- a/framework/src/Volo.Abp.MudBlazorUI/Components/UiMessageAlert.razor.cs +++ b/framework/src/Volo.Abp.MudBlazorUI/Components/UiMessageAlert.razor.cs @@ -88,13 +88,16 @@ public partial class UiMessageAlert : ComponentBase, IDisposable private async void OnMessageReceived(object? sender, UiMessageEventArgs e) { - MessageType = e.MessageType; - Message = e.Message; - Title = e.Title; - Options = e.Options; - Callback = e.Callback; + await InvokeAsync(async () => + { + MessageType = e.MessageType; + Message = e.Message; + Title = e.Title; + Options = e.Options; + Callback = e.Callback; - await ShowMessageAlert(); + await ShowMessageAlert(); + }); } protected virtual async Task ShowMessageAlert() diff --git a/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs b/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs new file mode 100644 index 0000000000..29779a08be --- /dev/null +++ b/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs @@ -0,0 +1,37 @@ +using System; + +namespace Volo.Abp.Timing; + +public static class TimezoneProviderExtensions +{ + /// + /// Interprets as local time in + /// and converts it to its UTC equivalent. The caller is expected to pass a + /// value; the kind is not inspected, so a value is always + /// treated as wall-clock time in the given timezone regardless of its kind. + /// + /// + /// Returns unchanged when applying the timezone offset would move it + /// outside the supported range. This happens for values within the offset + /// distance of /, typically the + /// placeholder that does not represent a real instant. Computing the + /// UTC ticks directly avoids the that + /// new DateTimeOffset(dateTime, offset) would throw for such values. + /// + public static DateTime ConvertUnspecifiedToUtc( + this ITimezoneProvider timezoneProvider, + DateTime dateTime, + string windowsOrIanaTimeZoneId) + { + Check.NotNull(timezoneProvider, nameof(timezoneProvider)); + + var timezoneInfo = timezoneProvider.GetTimeZoneInfo(windowsOrIanaTimeZoneId); + var utcTicks = dateTime.Ticks - timezoneInfo.GetUtcOffset(dateTime).Ticks; + if (utcTicks < DateTime.MinValue.Ticks || utcTicks > DateTime.MaxValue.Ticks) + { + return dateTime; + } + + return new DateTime(utcTicks, DateTimeKind.Utc); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs new file mode 100644 index 0000000000..7a2393586c --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs @@ -0,0 +1,263 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Security.Claims; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; + +public class AbpAntiForgeryClaimsPrincipalNormalizer_Tests +{ + private const string CookieIssuer = "LOCAL AUTHORITY"; + private const string BearerIssuer = "https://localhost:44361/"; + private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666"; + private const string AntiForgeryHeaderName = "RequestVerificationToken"; + private const string AntiForgeryCookieName = "AF"; + + [Fact] + public void Normalize_should_set_a_constant_issuer_on_user_identifier_claims_only() + { + var usernameClaim = new Claim("preferred_username", "admin", ClaimValueTypes.String, CookieIssuer); + usernameClaim.Properties["test-property"] = "test-value"; + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim("sub", UserId, ClaimValueTypes.String, CookieIssuer), + new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, CookieIssuer), + usernameClaim + }, + "Identity.Application")); + + var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal); + + normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); + normalized.FindFirst(ClaimTypes.NameIdentifier)!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); + + // value and OriginalIssuer are kept; only Issuer changes + normalized.FindFirst("sub")!.Value.ShouldBe(UserId); + normalized.FindFirst("sub")!.OriginalIssuer.ShouldBe(CookieIssuer); + + // non-identifier claims and their properties are untouched + var normalizedUsername = normalized.FindFirst("preferred_username")!; + normalizedUsername.Issuer.ShouldBe(CookieIssuer); + normalizedUsername.Properties["test-property"].ShouldBe("test-value"); + + // the original principal is not mutated + principal.FindFirst("sub")!.Issuer.ShouldBe(CookieIssuer); + } + + [Fact] + public void Normalize_should_preserve_identity_metadata() + { + var actor = new ClaimsIdentity(new[] { new Claim(AbpClaimTypes.UserId, "actor-id") }, "Actor"); + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim("sub", UserId, ClaimValueTypes.String, BearerIssuer) }, + "Identity.Application") + { + Actor = actor, + BootstrapContext = "raw-token", + Label = "my-label" + }); + + var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal); + var normalizedIdentity = (ClaimsIdentity)normalized.Identity!; + + // identity metadata that the antiforgery claim uid does not use is still preserved on the copy + normalizedIdentity.Actor.ShouldBeSameAs(actor); + normalizedIdentity.BootstrapContext.ShouldBe("raw-token"); + normalizedIdentity.Label.ShouldBe("my-label"); + // and the user id claim issuer was still normalized + normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); + } + + [Fact] + public async Task Token_should_validate_for_the_same_cookie_principal_through_the_decorator() + { + // The common server-rendered case: a token generated and validated for the same cookie principal. + // Guards that wrapping IAntiforgery does not break the basic flow every page POST relies on. + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); + + var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); + var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); + + var isValid = await ValidateAsync(antiforgery, serviceProvider, cookiePrincipal, cookieToken, requestToken); + + isValid.ShouldBeTrue(); + } + + [Fact] + public async Task Token_issued_under_one_scheme_should_validate_under_another_when_normalization_is_enabled() + { + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); + + var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); + var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); + + var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); + var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); + + isValid.ShouldBeTrue(); + } + + [Fact] + public async Task Token_issued_under_one_scheme_should_fail_under_another_when_normalization_is_disabled() + { + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false); + + var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer); + var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); + + var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); + var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); + + isValid.ShouldBeFalse(); + } + + [Fact] + public async Task Token_should_validate_when_the_cookie_principal_user_id_issuer_is_not_local_authority() + { + // Tiered/OIDC templates back the cookie with an OIDC principal whose user id issuer is the token + // authority. Because the decorator normalizes both generation and validation (Razor Pages validate + // through the same decorated IAntiforgery), the per-user identifier still matches. + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); + + var oidcCookiePrincipal = CreatePrincipal("Identity.Application", BearerIssuer); + var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, oidcCookiePrincipal); + + var isValid = await ValidateAsync(antiforgery, serviceProvider, oidcCookiePrincipal, cookieToken, requestToken); + + isValid.ShouldBeTrue(); + } + + [Fact] + public async Task Token_should_validate_across_schemes_when_principal_has_both_sub_and_name_identifier() + { + // The extractor picks "sub" before NameIdentifier and a principal can carry both, so the + // normalization must cover the claim actually picked. + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); + + var cookiePrincipal = CreatePrincipalWithSubAndNameIdentifier("Identity.Application", CookieIssuer); + var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal); + + var bearerPrincipal = CreatePrincipalWithSubAndNameIdentifier("AuthenticationTypes.Federation", BearerIssuer); + var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken); + + isValid.ShouldBeTrue(); + } + + [Fact] + public async Task Decorator_should_restore_the_original_principal_after_each_call() + { + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true); + + var originalPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer); + var httpContext = new DefaultHttpContext { User = originalPrincipal, RequestServices = serviceProvider }; + + antiforgery.GetAndStoreTokens(httpContext); + + httpContext.User.ShouldBeSameAs(originalPrincipal); + httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); + + httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid"; + await antiforgery.IsRequestValidAsync(httpContext); + + httpContext.User.ShouldBeSameAs(originalPrincipal); + httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); + } + + [Fact] + public async Task Decorator_should_not_normalize_when_disabled() + { + var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false); + + var principal = CreatePrincipal("Identity.Application", CookieIssuer); + var httpContext = new DefaultHttpContext { User = principal, RequestServices = serviceProvider }; + + antiforgery.GetAndStoreTokens(httpContext); + httpContext.User.ShouldBeSameAs(principal); + + httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid"; + await antiforgery.IsRequestValidAsync(httpContext); + httpContext.User.ShouldBeSameAs(principal); + } + + private static ClaimsPrincipal CreatePrincipal(string authenticationType, string userIdClaimIssuer) + { + return new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer), + new Claim("preferred_username", "admin", ClaimValueTypes.String, userIdClaimIssuer) + }, + authenticationType, + "preferred_username", + AbpClaimTypes.Role)); + } + + private static ClaimsPrincipal CreatePrincipalWithSubAndNameIdentifier(string authenticationType, string issuer) + { + return new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim("sub", UserId, ClaimValueTypes.String, issuer), + new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, issuer), + new Claim("preferred_username", "admin", ClaimValueTypes.String, issuer) + }, + authenticationType, + "preferred_username", + AbpClaimTypes.Role)); + } + + private static (IAntiforgery antiforgery, IServiceProvider serviceProvider) CreateDecoratedAntiforgery(bool normalize) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataProtection(); + services.AddAntiforgery(options => + { + options.Cookie.Name = AntiForgeryCookieName; + options.HeaderName = AntiForgeryHeaderName; + }); + services.AddTransient(); + + var serviceProvider = services.BuildServiceProvider(); + + var antiforgery = new AbpAntiforgery( + serviceProvider.GetRequiredService(), + Microsoft.Extensions.Options.Options.Create(new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize })); + + return (antiforgery, serviceProvider); + } + + private static (string cookieToken, string requestToken) GenerateToken( + IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user) + { + var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider }; + var tokenSet = antiforgery.GetAndStoreTokens(httpContext); + return (ExtractCookieToken(httpContext), tokenSet.RequestToken!); + } + + private static async Task ValidateAsync( + IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user, string cookieToken, string requestToken) + { + var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider }; + httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}={cookieToken}"; + httpContext.Request.Headers[AntiForgeryHeaderName] = requestToken; + + return await antiforgery.IsRequestValidAsync(httpContext); + } + + private static string ExtractCookieToken(HttpContext httpContext) + { + var setCookie = httpContext.Response.Headers.SetCookie.ToString(); + var prefix = AntiForgeryCookieName + "="; + var start = setCookie.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length; + var end = setCookie.IndexOf(';', start); + return end < 0 ? setCookie.Substring(start) : setCookie.Substring(start, end - start); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs new file mode 100644 index 0000000000..e204667e33 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs @@ -0,0 +1,382 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features.Authentication; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Security.Claims; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.AntiForgery; + +public class AbpAntiforgery_Tests +{ + private const string BearerIssuer = "https://localhost:44361/"; + private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666"; + private static readonly DateTimeOffset ExpiresUtc = new(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + + [Fact] + public Task GetAndStoreTokens_should_normalize_the_user_and_restore_it() => + Should_normalize_then_restore( + (antiforgery, httpContext) => { antiforgery.GetAndStoreTokens(httpContext); return Task.CompletedTask; }, + inner => inner.UserSeenByGetAndStoreTokens); + + [Fact] + public Task GetTokens_should_normalize_the_user_and_restore_it() => + Should_normalize_then_restore( + (antiforgery, httpContext) => { antiforgery.GetTokens(httpContext); return Task.CompletedTask; }, + inner => inner.UserSeenByGetTokens); + + [Fact] + public Task IsRequestValidAsync_should_normalize_the_user_and_restore_it() => + Should_normalize_then_restore( + (antiforgery, httpContext) => antiforgery.IsRequestValidAsync(httpContext), + inner => inner.UserSeenByIsRequestValid); + + [Fact] + public Task ValidateRequestAsync_should_normalize_the_user_and_restore_it() => + Should_normalize_then_restore( + (antiforgery, httpContext) => antiforgery.ValidateRequestAsync(httpContext), + inner => inner.UserSeenByValidateRequest); + + [Fact] + public Task SetCookieTokenAndHeader_should_normalize_the_user_and_restore_it() => + Should_normalize_then_restore( + (antiforgery, httpContext) => { antiforgery.SetCookieTokenAndHeader(httpContext); return Task.CompletedTask; }, + inner => inner.UserSeenBySetCookieTokenAndHeader); + + [Fact] + public Task GetAndStoreTokens_should_preserve_the_authenticate_result() => + Should_preserve_the_authenticate_result( + (antiforgery, httpContext) => { antiforgery.GetAndStoreTokens(httpContext); return Task.CompletedTask; }); + + [Fact] + public Task GetTokens_should_preserve_the_authenticate_result() => + Should_preserve_the_authenticate_result( + (antiforgery, httpContext) => { antiforgery.GetTokens(httpContext); return Task.CompletedTask; }); + + [Fact] + public Task IsRequestValidAsync_should_preserve_the_authenticate_result() => + Should_preserve_the_authenticate_result( + (antiforgery, httpContext) => antiforgery.IsRequestValidAsync(httpContext)); + + [Fact] + public Task ValidateRequestAsync_should_preserve_the_authenticate_result() => + Should_preserve_the_authenticate_result( + (antiforgery, httpContext) => antiforgery.ValidateRequestAsync(httpContext)); + + [Fact] + public Task SetCookieTokenAndHeader_should_preserve_the_authenticate_result() => + Should_preserve_the_authenticate_result( + (antiforgery, httpContext) => { antiforgery.SetCookieTokenAndHeader(httpContext); return Task.CompletedTask; }); + + [Fact] + public async Task Should_preserve_the_authenticate_result_when_the_inner_antiforgery_throws() + { + var inner = new RecordingAntiforgery { ThrowOnValidateRequest = true }; + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true, CreateSuccessResult(original)); + + await Should.ThrowAsync( + () => antiforgery.ValidateRequestAsync(httpContext)); + + AssertResultPreserved(httpContext, original); + } + + [Fact] + public void Should_restore_both_when_the_feature_does_not_sync_the_user() + { + // IAuthenticateResultFeature does not require an implementation to keep the principal and the result + // in sync; ASP.NET Core itself reuses such a feature. Restoring only the result would leave the + // normalized principal on HttpContext.User. + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var originalResult = CreateSuccessResult(original); + var httpContext = CreateHttpContext(original, withNormalizer: true); + httpContext.Features.Set( + new DecoupledAuthenticateResultFeature { AuthenticateResult = originalResult }); + + antiforgery.GetAndStoreTokens(httpContext); + + httpContext.User.ShouldBeSameAs(original); + httpContext.Features.Get()!.AuthenticateResult.ShouldBeSameAs(originalResult); + } + + [Fact] + public void Should_restore_the_original_authenticate_result_instance() + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var originalResult = CreateSuccessResult(original); + var httpContext = CreateHttpContext(original, withNormalizer: true, originalResult); + + antiforgery.GetAndStoreTokens(httpContext); + + httpContext.Features.Get()!.AuthenticateResult.ShouldBeSameAs(originalResult); + } + + [Fact] + public void Should_restore_the_user_when_there_is_no_authenticate_result_feature() + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true); + + antiforgery.GetAndStoreTokens(httpContext); + + httpContext.Features.Get().ShouldBeNull(); + httpContext.User.ShouldBeSameAs(original); + } + + [Fact] + public void Should_restore_the_user_when_the_authenticate_result_is_null() + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true, CreateSuccessResult(original)); + httpContext.Features.Get()!.AuthenticateResult = null; + httpContext.User = original; + + antiforgery.GetAndStoreTokens(httpContext); + + httpContext.User.ShouldBeSameAs(original); + httpContext.Features.Get()!.AuthenticateResult.ShouldBeNull(); + } + + [Fact] + public void Should_delegate_the_result_to_the_inner_antiforgery() + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: true); + + var tokenSet = antiforgery.GetAndStoreTokens(httpContext); + + tokenSet.RequestToken.ShouldBe(RecordingAntiforgery.RequestToken); + tokenSet.CookieToken.ShouldBe(RecordingAntiforgery.CookieToken); + } + + [Fact] + public void Should_not_normalize_when_disabled() + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true); + + antiforgery.GetAndStoreTokens(httpContext); + + // the inner saw the original (un-normalized) principal + inner.UserSeenByGetAndStoreTokens.ShouldBeSameAs(original); + inner.UserSeenByGetAndStoreTokens!.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer); + httpContext.User.ShouldBeSameAs(original); + } + + [Fact] + public void Should_not_resolve_the_normalizer_service_when_disabled() + { + // the normalizer is intentionally not registered; the disabled fast-path must not touch RequestServices + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false)); + var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: false); + + Should.NotThrow(() => antiforgery.GetAndStoreTokens(httpContext)); + } + + private static async Task Should_normalize_then_restore( + Func invoke, + Func userSeenByInner) + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true); + + await invoke(antiforgery, httpContext); + + // the inner ran against the normalized principal + userSeenByInner(inner)!.FindFirst(AbpClaimTypes.UserId)!.Issuer + .ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer); + // the original principal is restored after the call + httpContext.User.ShouldBeSameAs(original); + } + + private static async Task Should_preserve_the_authenticate_result(Func invoke) + { + var inner = new RecordingAntiforgery(); + var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true)); + var original = CreatePrincipal(BearerIssuer); + var httpContext = CreateHttpContext(original, withNormalizer: true, CreateSuccessResult(original)); + + await invoke(antiforgery, httpContext); + + AssertResultPreserved(httpContext, original); + } + + private static void AssertResultPreserved(HttpContext httpContext, ClaimsPrincipal original) + { + var result = httpContext.Features.Get()!.AuthenticateResult; + result.ShouldNotBeNull(); + result.Properties!.IsPersistent.ShouldBeTrue(); + result.Properties.ExpiresUtc.ShouldBe(ExpiresUtc); + httpContext.User.ShouldBeSameAs(original); + } + + private static AuthenticateResult CreateSuccessResult(ClaimsPrincipal principal) + { + var properties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = ExpiresUtc + }; + + return AuthenticateResult.Success(new AuthenticationTicket(principal, properties, "TestScheme")); + } + + private static Microsoft.Extensions.Options.IOptions CreateOptions(bool normalize) + { + return Microsoft.Extensions.Options.Options.Create( + new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize }); + } + + private static HttpContext CreateHttpContext( + ClaimsPrincipal user, + bool withNormalizer, + AuthenticateResult? authenticateResult = null) + { + var services = new ServiceCollection(); + if (withNormalizer) + { + services.AddTransient(); + } + + var httpContext = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + if (authenticateResult != null) + { + var features = new TestAuthenticationFeatures(authenticateResult); + httpContext.Features.Set(features); + httpContext.Features.Set(features); + } + else + { + httpContext.User = user; + } + + return httpContext; + } + + // An IAuthenticateResultFeature that does not touch HttpContext.User, like the one ASP.NET Core + // reuses in AuthorizationMiddlewareTests. + private sealed class DecoupledAuthenticateResultFeature : IAuthenticateResultFeature + { + public AuthenticateResult? AuthenticateResult { get; set; } + } + + // Mirrors the framework's internal AuthenticationFeatures: assigning User drops the AuthenticateResult, + // which is the behaviour AbpAntiforgery has to compensate for when it restores the principal. + private sealed class TestAuthenticationFeatures : IAuthenticateResultFeature, IHttpAuthenticationFeature + { + private ClaimsPrincipal? _user; + private AuthenticateResult? _result; + + public TestAuthenticationFeatures(AuthenticateResult result) + { + AuthenticateResult = result; + } + + public AuthenticateResult? AuthenticateResult + { + get => _result; + set + { + _result = value; + _user = _result?.Principal; + } + } + + public ClaimsPrincipal? User + { + get => _user; + set + { + _user = value; + _result = null; + } + } + } + + private static ClaimsPrincipal CreatePrincipal(string userIdClaimIssuer) + { + return new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer) + }, + "AuthenticationTypes.Federation")); + } + + private sealed class RecordingAntiforgery : IAntiforgery + { + public const string RequestToken = "test-request-token"; + public const string CookieToken = "test-cookie-token"; + + public ClaimsPrincipal? UserSeenByGetAndStoreTokens { get; private set; } + public ClaimsPrincipal? UserSeenByGetTokens { get; private set; } + public ClaimsPrincipal? UserSeenByIsRequestValid { get; private set; } + public ClaimsPrincipal? UserSeenByValidateRequest { get; private set; } + public ClaimsPrincipal? UserSeenBySetCookieTokenAndHeader { get; private set; } + + public bool ThrowOnValidateRequest { get; set; } + + public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) + { + UserSeenByGetAndStoreTokens = httpContext.User; + return CreateTokenSet(); + } + + public AntiforgeryTokenSet GetTokens(HttpContext httpContext) + { + UserSeenByGetTokens = httpContext.User; + return CreateTokenSet(); + } + + public Task IsRequestValidAsync(HttpContext httpContext) + { + UserSeenByIsRequestValid = httpContext.User; + return Task.FromResult(true); + } + + public Task ValidateRequestAsync(HttpContext httpContext) + { + UserSeenByValidateRequest = httpContext.User; + if (ThrowOnValidateRequest) + { + throw new AntiforgeryValidationException("test"); + } + + return Task.CompletedTask; + } + + public void SetCookieTokenAndHeader(HttpContext httpContext) + { + UserSeenBySetCookieTokenAndHeader = httpContext.User; + } + + private static AntiforgeryTokenSet CreateTokenSet() + { + return new AntiforgeryTokenSet(RequestToken, CookieToken, "RequestVerificationToken", "RequestVerificationToken"); + } + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter_Tests.cs new file mode 100644 index 0000000000..8e89378ac7 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/ContentFormatters/RemoteStreamContentOutputFormatter_Tests.cs @@ -0,0 +1,40 @@ +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; +using Shouldly; +using Volo.Abp.Content; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.ContentFormatters; + +public class RemoteStreamContentOutputFormatter_Tests +{ + [Fact] + public async Task Should_Not_Copy_The_Stream_When_The_Request_Is_Aborted() + { + using (var cancellationTokenSource = new CancellationTokenSource()) + { + await cancellationTokenSource.CancelAsync(); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestAborted = cancellationTokenSource.Token; + httpContext.Response.Body = new MemoryStream(); + + var writeContext = new OutputFormatterWriteContext( + httpContext, + (stream, encoding) => new StreamWriter(stream, encoding), + typeof(IRemoteStreamContent), + new RemoteStreamContent( + new MemoryStream(Encoding.UTF8.GetBytes("DownloadAsync")), + "download.rtf", + "application/rtf")); + + await new RemoteStreamContentOutputFormatter().WriteResponseBodyAsync(writeContext); + + httpContext.Response.Body.Length.ShouldBe(0); + } + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs new file mode 100644 index 0000000000..38b640b84b --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpAutoLockNameWorkerTestModule.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpAutoLockNameWorkerTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Transient()); + + // No lock name is given: it is derived from the job argument type names. + Configure(options => + { + options.AddDedicatedWorker(); + options.AddDedicatedWorker(); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs new file mode 100644 index 0000000000..b29c60784e --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobCleanupTestModule.cs @@ -0,0 +1,23 @@ +using System; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpBackgroundJobCleanupTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // IsJobExecutionEnabled is true by default, which the cleanup worker requires. + Configure(options => + { + options.StoreSuccessfulJobs = true; + options.SuccessfulJobRetentionTime = TimeSpan.FromDays(1); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs new file mode 100644 index 0000000000..db56843a4b --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpBackgroundJobWorkerTestModule.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpBackgroundJobWorkerTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Drive the worker manually in tests; don't run the real periodic worker. + Configure(options => + { + options.IsJobExecutionEnabled = false; + }); + + context.Services.AddSingleton(); + context.Services.AddScoped(); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs new file mode 100644 index 0000000000..6e35c81dfc --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateLockNameTestModule.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpDuplicateLockNameTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Transient()); + + // Two workers with different job types but the same lock name, which must fail at initialization. + Configure(options => + { + options.AddDedicatedWorker("dup-lock"); + options.AddDedicatedWorker("dup-lock"); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs new file mode 100644 index 0000000000..f4d9aae208 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpDuplicateWorkerTestModule.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpDuplicateWorkerTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Transient()); + + // The same job type is assigned to two dedicated workers, which must fail at initialization. + Configure(options => + { + options.AddDedicatedWorker("lock-a"); + options.AddDedicatedWorker("lock-b"); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs new file mode 100644 index 0000000000..d437c9325f --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpMultiWorkerTestModule.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpMultiWorkerTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + + // Replace the real worker with a recording one to assert how the manager resolves and starts workers. + context.Services.Replace(ServiceDescriptor.Transient()); + + Configure(options => + { + options.AddDedicatedWorker("lock-a"); + options.AddDedicatedWorker("lock-b"); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs new file mode 100644 index 0000000000..f4ddbb8be8 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/AbpSameJobNameTestModule.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BackgroundJobs; + +[DependsOn( + typeof(AbpBackgroundJobsModule), + typeof(AbpAutofacModule), + typeof(AbpTestBaseModule) +)] +public class AbpSameJobNameTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Transient()); + + // Two dedicated workers with different args types that both resolve to "shared-job-name". + // AddDedicatedWorker's eager check compares by type (both pass); only the manager's backstop + // (which resolves job names) can catch this. + Configure(options => + { + options.AddDedicatedWorker("lock-a"); + options.AddDedicatedWorker("lock-b"); + }); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs new file mode 100644 index 0000000000..a7a638ed7b --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobCleanupWorker_Tests.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Testing; +using Volo.Abp.Threading; +using Volo.Abp.Timing; +using Xunit; + +namespace Volo.Abp.BackgroundJobs; + +public class BackgroundJobCleanupWorker_Tests : AbpIntegratedTest +{ + private readonly IBackgroundJobStore _store; + private readonly IClock _clock; + private readonly AbpBackgroundJobWorkerOptions _workerOptions; + + public BackgroundJobCleanupWorker_Tests() + { + _store = GetRequiredService(); + _clock = GetRequiredService(); + _workerOptions = GetRequiredService>().Value; + } + + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + private TestableBackgroundJobCleanupWorker CreateWorker() + { + return new TestableBackgroundJobCleanupWorker( + GetRequiredService(), + GetRequiredService(), + GetRequiredService>(), + GetRequiredService>(), + GetRequiredService()); + } + + private Task RunCleanupAsync() + { + return CreateWorker().DoWorkPublicAsync(new PeriodicBackgroundWorkerContext(ServiceProvider)); + } + + private async Task InsertCompletedJobAsync(DateTime completionTime) + { + var id = Guid.NewGuid(); + await _store.InsertAsync(new BackgroundJobInfo + { + Id = id, + JobName = "job-a", + JobArgs = "{}", + CreationTime = _clock.Now, + NextTryTime = _clock.Now, + CompletionTime = completionTime + }); + return id; + } + + [Fact] + public async Task Should_Delete_Completed_Jobs_Older_Than_Retention() + { + // Retention is 1 day (configured in the module). + var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2))); + var recentJobId = await InsertCompletedJobAsync(_clock.Now); + + await RunCleanupAsync(); + + (await _store.FindAsync(oldJobId)).ShouldBeNull(); // older than retention → deleted + (await _store.FindAsync(recentJobId)).ShouldNotBeNull(); // within retention → kept + } + + [Fact] + public async Task Should_Not_Delete_When_StoreSuccessfulJobs_Disabled() + { + _workerOptions.StoreSuccessfulJobs = false; + + var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2))); + + await RunCleanupAsync(); + + (await _store.FindAsync(oldJobId)).ShouldNotBeNull(); + } + + [Fact] + public async Task Should_Not_Delete_When_Retention_Is_Null() + { + _workerOptions.SuccessfulJobRetentionTime = null; + + var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2))); + + await RunCleanupAsync(); + + (await _store.FindAsync(oldJobId)).ShouldNotBeNull(); + } + + [Fact] + public async Task Should_Delete_All_Old_Jobs_In_Batches() + { + _workerOptions.MaxJobFetchCount = 2; + + var ids = new List(); + for (var i = 0; i < 5; i++) + { + ids.Add(await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2)))); + } + + await RunCleanupAsync(); + + foreach (var id in ids) + { + (await _store.FindAsync(id)).ShouldBeNull(); + } + } + + [Fact] + public async Task Should_Delete_Oldest_Completed_Jobs_First_When_Limited_By_MaxResultCount() + { + var oldest = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(5))); + var middle = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(3))); + var newest = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(1))); + + // All three are completed before now, but a single call may delete only two. + var deletedCount = await _store.DeleteAsync(null, _clock.Now, maxResultCount: 2); + + deletedCount.ShouldBe(2); + (await _store.FindAsync(oldest)).ShouldBeNull(); + (await _store.FindAsync(middle)).ShouldBeNull(); + (await _store.FindAsync(newest)).ShouldNotBeNull(); // newest survives, proving oldest-first deletion + } + + [Fact] + public async Task Should_Not_Loop_Forever_When_MaxJobFetchCount_Is_Zero() + { + _workerOptions.MaxJobFetchCount = 0; + + var oldJobId = await InsertCompletedJobAsync(_clock.Now.Subtract(TimeSpan.FromDays(2))); + + // Must return (not hang) even though nothing can be fetched/deleted with a zero page size. + await RunCleanupAsync(); + + (await _store.FindAsync(oldJobId)).ShouldNotBeNull(); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs new file mode 100644 index 0000000000..2824c1a231 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorkerTestBase.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Testing; + +namespace Volo.Abp.BackgroundJobs; + +public abstract class BackgroundJobWorkerTestBase : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs new file mode 100644 index 0000000000..76e4734b05 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_AutoLockName_Tests.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.BackgroundJobs; + +public class BackgroundJobWorker_AutoLockName_Tests : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public void Should_Derive_Bounded_Lock_Name_From_Job_Args_Types() + { + var records = GetRequiredService().Records; + + var dedicated = records.Where(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Include).ToList(); + dedicated.Count.ShouldBe(2); + + // Lock name is derived (prefix + MD5 of the full type name), so it is stable and length-bounded. + var expectedA = "AbpBackgroundJobDedicatedWorker:" + typeof(WorkerJobAArgs).FullName!.ToMd5(); + var expectedB = "AbpBackgroundJobDedicatedWorker:" + typeof(WorkerJobBArgs).FullName!.ToMd5(); + + dedicated.ShouldContain(r => r.DistributedLockName == expectedA); + dedicated.ShouldContain(r => r.DistributedLockName == expectedB); + + // Bounded length regardless of how long the type names are. + dedicated.ShouldAllBe(r => r.DistributedLockName!.Length <= 64); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs new file mode 100644 index 0000000000..bdd80efa7a --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_DuplicateConfiguration_Tests.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Autofac; +using Xunit; + +namespace Volo.Abp.BackgroundJobs; + +public class BackgroundJobWorker_DuplicateConfiguration_Tests +{ + [Fact] + public void Should_Throw_Without_Starting_Any_Worker_When_A_Job_Type_Is_Assigned_To_Multiple_Workers() + { + using var application = AbpApplicationFactory.Create(options => + { + options.UseAutofac(); + }); + + var exception = Record.Exception(() => application.Initialize()); + + exception.ShouldNotBeNull(); + exception.ToString().ShouldContain("dedicated worker"); + + // Validation must happen before any worker is started. + var recorder = application.ServiceProvider.GetRequiredService(); + recorder.Records.ShouldBeEmpty(); + } + + [Fact] + public void Should_Throw_Without_Starting_Any_Worker_When_Two_Workers_Share_A_Lock_Name() + { + using var application = AbpApplicationFactory.Create(options => + { + options.UseAutofac(); + }); + + var exception = Record.Exception(() => application.Initialize()); + + exception.ShouldNotBeNull(); + exception.ToString().ShouldContain("lock name"); + + var recorder = application.ServiceProvider.GetRequiredService(); + recorder.Records.ShouldBeEmpty(); + } + + [Fact] + public void Should_Throw_Without_Starting_Any_Worker_When_Different_Args_Types_Resolve_To_The_Same_Job_Name() + { + using var application = AbpApplicationFactory.Create(options => + { + options.UseAutofac(); + }); + + // The two args types pass the eager (by-type) check but resolve to the same job name, + // so only the manager's backstop validation can reject them. + var exception = Record.Exception(() => application.Initialize()); + + exception.ShouldNotBeNull(); + exception.ToString().ShouldContain("more than one dedicated worker"); + + var recorder = application.ServiceProvider.GetRequiredService(); + recorder.Records.ShouldBeEmpty(); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs new file mode 100644 index 0000000000..8279d2d493 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_MultiWorkerRegistration_Tests.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.BackgroundJobs; + +public class BackgroundJobWorker_MultiWorkerRegistration_Tests : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public void Should_Start_Dedicated_Workers_And_A_Default_Worker() + { + // The workers are resolved from DI (RecordingBackgroundJobWorker replaces the real one), + // proving the manager honors the registered/replaced IBackgroundJobWorker. + var records = GetRequiredService().Records; + + records.Count.ShouldBe(3); + + var jobAName = BackgroundJobNameAttribute.GetName(); + var jobBName = BackgroundJobNameAttribute.GetName(); + + var dedicated = records.Where(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Include).ToList(); + dedicated.Count.ShouldBe(2); + dedicated.ShouldContain(r => r.DistributedLockName == "lock-a" && r.JobNameFilter!.JobNames.Contains(jobAName)); + dedicated.ShouldContain(r => r.DistributedLockName == "lock-b" && r.JobNameFilter!.JobNames.Contains(jobBName)); + + var defaultWorker = records.Single(r => r.JobNameFilter?.Mode == BackgroundJobNameFilterMode.Exclude); + defaultWorker.DistributedLockName.ShouldBeNull(); + defaultWorker.JobNameFilter!.JobNames.ShouldContain(jobAName); + defaultWorker.JobNameFilter!.JobNames.ShouldContain(jobBName); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs new file mode 100644 index 0000000000..4d8d506f19 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/BackgroundJobWorker_Tests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DependencyInjection; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Threading; +using Volo.Abp.Timing; +using Xunit; + +// ReSharper disable PossibleMultipleEnumeration + +namespace Volo.Abp.BackgroundJobs; + +public class BackgroundJobWorker_Tests : BackgroundJobWorkerTestBase +{ + private readonly IBackgroundJobStore _store; + private readonly IClock _clock; + private readonly AbpBackgroundJobWorkerOptions _workerOptions; + + public BackgroundJobWorker_Tests() + { + _store = GetRequiredService(); + _clock = GetRequiredService(); + _workerOptions = GetRequiredService>().Value; + } + + private TestableBackgroundJobWorker CreateWorker() + { + return new TestableBackgroundJobWorker( + GetRequiredService(), + GetRequiredService>(), + GetRequiredService>(), + GetRequiredService(), + GetRequiredService()); + } + + private BackgroundJobInfo NewJob(string jobName) + { + return new BackgroundJobInfo + { + Id = Guid.NewGuid(), + JobName = jobName, + JobArgs = "{}", + CreationTime = _clock.Now, + NextTryTime = _clock.Now.AddMinutes(-1) + }; + } + + private PeriodicBackgroundWorkerContext Context() + { + return new PeriodicBackgroundWorkerContext(ServiceProvider); + } + + // Storing successful jobs + + [Fact] + public async Task Should_Keep_Successful_Job_As_History_When_Enabled() + { + _workerOptions.StoreSuccessfulJobs = true; + + var jobInfo = NewJob("job-a"); + await _store.InsertAsync(jobInfo); + + await CreateWorker().HandleJobSuccessPublicAsync(_store, jobInfo, _clock); + + // The job is kept (marked completed), not deleted. + var kept = await _store.FindAsync(jobInfo.Id); + kept.ShouldNotBeNull(); + kept.CompletionTime.ShouldNotBeNull(); + + // ...but it is excluded from the waiting jobs. + (await _store.GetWaitingJobsAsync(null, 1000)).ShouldNotContain(j => j.Id == jobInfo.Id); + } + + [Fact] + public async Task Should_Delete_Successful_Job_When_Disabled() + { + // StoreSuccessfulJobs is false by default. + var jobInfo = NewJob("job-a"); + await _store.InsertAsync(jobInfo); + + await CreateWorker().HandleJobSuccessPublicAsync(_store, jobInfo, _clock); + + (await _store.FindAsync(jobInfo.Id)).ShouldBeNull(); + } + + // Dedicated workers by job name + + [Fact] + public async Task Should_Return_Only_Included_Jobs() + { + await _store.InsertAsync(NewJob("job-a")); + await _store.InsertAsync(NewJob("job-a")); + await _store.InsertAsync(NewJob("job-b")); + + var worker = CreateWorker(); + worker.ConfigureTest(BackgroundJobNameFilter.Include(new[] { "job-a" })); + + var jobs = await worker.GetWaitingJobsPublicAsync(Context(), _store); + + jobs.Count.ShouldBe(2); + jobs.ShouldAllBe(j => j.JobName == "job-a"); + } + + [Fact] + public async Task Should_Exclude_Given_Jobs() + { + await _store.InsertAsync(NewJob("job-a")); + await _store.InsertAsync(NewJob("job-b")); + + var worker = CreateWorker(); + worker.ConfigureTest(BackgroundJobNameFilter.Exclude(new[] { "job-a" })); + + var jobs = await worker.GetWaitingJobsPublicAsync(Context(), _store); + + jobs.Count.ShouldBe(1); + jobs.Single().JobName.ShouldBe("job-b"); + } + + [Fact] + public void Should_Match_Job_Names_By_Filter() + { + BackgroundJobNameFilter.None.IsMatch("any").ShouldBeTrue(); + + var include = BackgroundJobNameFilter.Include(new[] { "job-a" }); + include.IsMatch("job-a").ShouldBeTrue(); + include.IsMatch("job-b").ShouldBeFalse(); + + var exclude = BackgroundJobNameFilter.Exclude(new[] { "job-a" }); + exclude.IsMatch("job-a").ShouldBeFalse(); + exclude.IsMatch("job-b").ShouldBeTrue(); + } + + [Fact] + public void BackgroundJobNameFilter_Should_Reject_Invalid_Mode_And_Names_Combinations() + { + Should.Throw(() => new BackgroundJobNameFilter(BackgroundJobNameFilterMode.Include)); + Should.Throw(() => new BackgroundJobNameFilter(BackgroundJobNameFilterMode.None, new[] { "job-a" })); + Should.Throw(() => new BackgroundJobNameFilter((BackgroundJobNameFilterMode)99, new[] { "job-a" })); + } + + // Parallel execution / eligibility + + [Fact] + public void Should_Evaluate_Job_Eligibility() + { + var worker = CreateWorker(); + + worker.IsJobEligiblePublic(null, _clock).ShouldBeFalse(); + + var future = NewJob("job-a"); + future.NextTryTime = _clock.Now.AddMinutes(5); + worker.IsJobEligiblePublic(future, _clock).ShouldBeFalse(); + + var abandoned = NewJob("job-a"); + abandoned.IsAbandoned = true; + worker.IsJobEligiblePublic(abandoned, _clock).ShouldBeFalse(); + + var completed = NewJob("job-a"); + completed.CompletionTime = _clock.Now; + worker.IsJobEligiblePublic(completed, _clock).ShouldBeFalse(); + + var eligible = NewJob("job-a"); + worker.IsJobEligiblePublic(eligible, _clock).ShouldBeTrue(); + } + + [Fact] + public void Should_Not_Be_Eligible_When_Job_Name_Filtered_Out() + { + var worker = CreateWorker(); + worker.ConfigureTest(BackgroundJobNameFilter.Include(new[] { "job-a" })); + + var otherJob = NewJob("job-b"); + worker.IsJobEligiblePublic(otherJob, _clock).ShouldBeFalse(); + } + + [Fact] + public void Should_Require_Job_Args_Types_For_A_Dedicated_Worker() + { + Should.Throw(() => new BackgroundJobWorkerConfiguration("lock-a")); + } + + [Fact] + public void AddDedicatedWorker_Should_Throw_At_Registration_When_A_Job_Type_Is_Added_Twice() + { + var options = new AbpBackgroundJobWorkerOptions(); + options.AddDedicatedWorker("lock-a"); + + Should.Throw(() => options.AddDedicatedWorker("lock-b")); + } + + [Fact] + public void AddDedicatedWorker_Should_Throw_At_Registration_When_A_Lock_Name_Is_Reused() + { + var options = new AbpBackgroundJobWorkerOptions(); + options.AddDedicatedWorker("dup-lock"); + + Should.Throw(() => options.AddDedicatedWorker("dup-lock")); + } + + [Fact] + public void AddDedicatedWorker_Should_Throw_At_Registration_When_Lock_Name_Equals_The_Default() + { + var options = new AbpBackgroundJobWorkerOptions(); + + Should.Throw(() => options.AddDedicatedWorker(options.DistributedLockName)); + } + + // Parallel execution + + [Fact] + public async Task Should_Execute_Multiple_Jobs_In_Parallel() + { + _workerOptions.MaxParallelJobExecutionCount = 3; + + var jobManager = GetRequiredService(); + await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "1" }); + await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "2" }); + await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "3" }); + + await CreateWorker().ExecuteJobsInParallelPublicAsync(Context()); + + var tracker = GetRequiredService(); + tracker.Executed.Count.ShouldBe(3); + tracker.Executed.ShouldContain("1"); + tracker.Executed.ShouldContain("2"); + tracker.Executed.ShouldContain("3"); + + // Each job must run in its own service scope (isolated DbContext/UOW). + tracker.ScopeIds.Distinct().Count().ShouldBe(3); + + (await _store.GetWaitingJobsAsync(null, 1000)).ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Execute_At_Most_MaxParallel_Jobs_Per_Cycle() + { + _workerOptions.MaxParallelJobExecutionCount = 2; + + var jobManager = GetRequiredService(); + for (var i = 0; i < 5; i++) + { + await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = i.ToString() }); + } + + await CreateWorker().ExecuteJobsInParallelPublicAsync(Context()); + + var tracker = GetRequiredService(); + tracker.Executed.Count.ShouldBe(2); + (await _store.GetWaitingJobsAsync(null, 1000)).Count.ShouldBe(3); + } + + [Fact] + public async Task Should_Skip_Job_Already_Claimed_By_Another_Instance() + { + _workerOptions.MaxParallelJobExecutionCount = 5; + + var jobManager = GetRequiredService(); + var lockedJobId = Guid.Parse(await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "locked" })); + await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "free" }); + + var distributedLock = GetRequiredService(); + var lockName = _workerOptions.PerJobDistributedLockPrefix + lockedJobId; + + await using (await distributedLock.TryAcquireAsync(lockName)) + { + await CreateWorker().ExecuteJobsInParallelPublicAsync(Context()); + } + + var tracker = GetRequiredService(); + tracker.Executed.ShouldContain("free"); + tracker.Executed.ShouldNotContain("locked"); + (await _store.FindAsync(lockedJobId)).ShouldNotBeNull(); + } + + [Fact] + public async Task Should_Mark_Job_Completed_On_Successful_Execution_When_Storing_Enabled() + { + _workerOptions.StoreSuccessfulJobs = true; + _workerOptions.MaxParallelJobExecutionCount = 2; + + var jobManager = GetRequiredService(); + var jobId = Guid.Parse(await jobManager.EnqueueAsync(new ParallelTestJobArgs { Value = "1" })); + + await CreateWorker().ExecuteJobsInParallelPublicAsync(Context()); + + GetRequiredService().Executed.ShouldContain("1"); + + // The job is kept (marked completed), not deleted, and excluded from the waiting list. + var job = await _store.FindAsync(jobId); + job.ShouldNotBeNull(); + job.CompletionTime.ShouldNotBeNull(); + (await _store.GetWaitingJobsAsync(null, 1000)).ShouldNotContain(j => j.Id == jobId); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs new file mode 100644 index 0000000000..6acb5ca78d --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/RecordingBackgroundJobWorker.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Records every call so multi-worker registration +/// (resolved from DI by ) can be asserted. Does not start any timer. +/// +public class WorkerStartRecorder +{ + public List Records { get; } = new List(); +} + +public class WorkerStartRecord +{ + public string? DistributedLockName { get; set; } + + public BackgroundJobNameFilter? JobNameFilter { get; set; } +} + +public class RecordingBackgroundJobWorker : IBackgroundJobWorker, ITransientDependency +{ + private readonly WorkerStartRecorder _recorder; + + public RecordingBackgroundJobWorker(WorkerStartRecorder recorder) + { + _recorder = recorder; + } + + public Task StartAsync( + string? distributedLockName = null, + BackgroundJobNameFilter? jobNameFilter = null, + CancellationToken cancellationToken = default) + { + _recorder.Records.Add(new WorkerStartRecord + { + DistributedLockName = distributedLockName, + JobNameFilter = jobNameFilter + }); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs new file mode 100644 index 0000000000..7c6cc16dec --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobCleanupWorker.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Threading; + +namespace Volo.Abp.BackgroundJobs; + +public class TestableBackgroundJobCleanupWorker : BackgroundJobCleanupWorker +{ + public TestableBackgroundJobCleanupWorker( + AbpAsyncTimer timer, + IServiceScopeFactory serviceScopeFactory, + IOptions jobOptions, + IOptions workerOptions, + IAbpDistributedLock distributedLock) + : base(timer, serviceScopeFactory, jobOptions, workerOptions, distributedLock) + { + } + + public Task DoWorkPublicAsync(PeriodicBackgroundWorkerContext workerContext) + { + return DoWorkAsync(workerContext); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs new file mode 100644 index 0000000000..27274320fb --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/TestableBackgroundJobWorker.cs @@ -0,0 +1,55 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.BackgroundWorkers; +using Volo.Abp.DistributedLocking; +using Volo.Abp.Threading; +using Volo.Abp.Timing; + +namespace Volo.Abp.BackgroundJobs; + +/// +/// Exposes the protected members of for unit testing. +/// +public class TestableBackgroundJobWorker : BackgroundJobWorker +{ + public TestableBackgroundJobWorker( + AbpAsyncTimer timer, + IOptions jobOptions, + IOptions workerOptions, + IServiceScopeFactory serviceScopeFactory, + IAbpDistributedLock distributedLock) + : base(timer, jobOptions, workerOptions, serviceScopeFactory, distributedLock) + { + } + + public void ConfigureTest( + BackgroundJobNameFilter? jobNameFilter = null, + string? distributedLockName = null) + { + JobNameFilter = jobNameFilter ?? BackgroundJobNameFilter.None; + DistributedLockName = distributedLockName ?? WorkerOptions.DistributedLockName; + } + + public Task HandleJobSuccessPublicAsync(IBackgroundJobStore store, BackgroundJobInfo jobInfo, IClock clock) + { + return HandleJobSuccessAsync(store, jobInfo, clock); + } + + public Task ExecuteJobsInParallelPublicAsync(PeriodicBackgroundWorkerContext workerContext) + { + return ExecuteJobsInParallelAsync(workerContext); + } + + public Task> GetWaitingJobsPublicAsync(PeriodicBackgroundWorkerContext workerContext, IBackgroundJobStore store) + { + return GetWaitingJobsAsync(workerContext, store); + } + + public bool IsJobEligiblePublic(BackgroundJobInfo? jobInfo, IClock clock) + { + return IsJobEligible(jobInfo, clock); + } +} diff --git a/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs new file mode 100644 index 0000000000..dd9feec657 --- /dev/null +++ b/framework/test/Volo.Abp.BackgroundJobs.Tests/Volo/Abp/BackgroundJobs/WorkerTestJobs.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs; + +public class ParallelJobTracker +{ + public ConcurrentBag Executed { get; } = new ConcurrentBag(); + + public ConcurrentBag ScopeIds { get; } = new ConcurrentBag(); +} + +/// +/// Scoped service used to verify that each parallel job runs in its own service scope. +/// +public class ScopeMarker +{ + public Guid Id { get; } = Guid.NewGuid(); +} + +public class ParallelTestJobArgs +{ + public string Value { get; set; } = default!; +} + +public class ParallelTestJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly ParallelJobTracker _tracker; + private readonly ScopeMarker _scopeMarker; + + public ParallelTestJob(ParallelJobTracker tracker, ScopeMarker scopeMarker) + { + _tracker = tracker; + _scopeMarker = scopeMarker; + } + + public override Task ExecuteAsync(ParallelTestJobArgs args) + { + _tracker.Executed.Add(args.Value); + _tracker.ScopeIds.Add(_scopeMarker.Id); + return Task.CompletedTask; + } +} + +public class WorkerJobAArgs +{ + public string Value { get; set; } = default!; +} + +public class WorkerJobA : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(WorkerJobAArgs args) + { + return Task.CompletedTask; + } +} + +public class WorkerJobBArgs +{ + public string Value { get; set; } = default!; +} + +public class WorkerJobB : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(WorkerJobBArgs args) + { + return Task.CompletedTask; + } +} + +// Two different args types that resolve to the same job name, to exercise the manager's +// backstop validation (eager AddDedicatedWorker validation compares by type, not resolved name). +[BackgroundJobName("shared-job-name")] +public class SharedNameJobAArgs +{ +} + +[BackgroundJobName("shared-job-name")] +public class SharedNameJobBArgs +{ +} + +public class SharedNameJobA : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(SharedNameJobAArgs args) + { + return Task.CompletedTask; + } +} + +public class SharedNameJobB : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(SharedNameJobBArgs args) + { + return Task.CompletedTask; + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs new file mode 100644 index 0000000000..c30fbb0d43 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs @@ -0,0 +1,275 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Xunit; + +namespace Volo.Abp.Http.Client.ClientProxying; + +public class ClientProxyBase_GetAcceptForActionReturn_Tests +{ + [Fact] + public void IRemoteStreamContent_Should_Pick_OctetStream_Even_When_ContentTypes_Include_Json() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json", "text/plain", "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void RemoteStreamContent_Concrete_Type_Should_Pick_OctetStream() + { + var action = BuildAction( + returnType: typeof(RemoteStreamContent).FullName!, + contentTypes: null); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void Json_In_ContentTypes_Should_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/json", "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Only_Text_ContentTypes_Should_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "text/csv" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void Empty_Or_Null_ContentTypes_Should_Return_Null() + { + InvokeGetAcceptForActionReturn(BuildAction("System.Int32", null)).ShouldBeNull(); + InvokeGetAcceptForActionReturn(BuildAction("System.Int32", new string[0])).ShouldBeNull(); + } + + [Fact] + public void Mixed_Text_And_Octet_Stream_Should_Echo_First_Content_Type() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/octet-stream" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void JsonV2_Variant_Should_Still_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/json; charset=utf-8" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Single_TextHtml_Should_Echo_Back_TextHtml() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/html" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/html"); + } + + [Fact] + public void OctetStream_Only_With_ObjectReturn_Should_Echo_OctetStream() + { + var action = BuildAction( + returnType: "My.Project.UserDto", + contentTypes: new[] { "application/octet-stream" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void ApplicationXml_Only_Should_Echo_Back_Xml_Instead_Of_Legacy_Pair() + { + var action = BuildAction( + returnType: "My.Project.SoapEnvelope", + contentTypes: new[] { "application/xml" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/xml"); + } + + [Fact] + public void Case_Insensitive_Json_Match() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "APPLICATION/JSON" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Json_With_Charset_Parameter_Should_Still_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/json; charset=utf-8" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Text_With_Charset_Parameter_Should_Still_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain ; charset=utf-8 " }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void Text_Json_Should_Echo_Back_Text_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/json"); + } + + [Fact] + public void Application_Problem_Json_Should_Echo_Back_The_Plus_Json_Variant() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/problem+json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/problem+json"); + } + + [Fact] + public void Vendor_Plus_Json_Should_Echo_Back_The_Plus_Json_Variant() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/vnd.api+json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/vnd.api+json"); + } + + [Fact] + public void IsRemoteStream_Flag_True_Should_Pick_OctetStream_Regardless_Of_TypeName() + { + var action = BuildAction( + returnType: "My.Project.CustomStream", + contentTypes: new[] { "application/json" }); + action.ReturnValue.IsRemoteStream = true; + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + private static string? InvokeGetAcceptForActionReturn(ActionApiDescriptionModel action) + { + var proxy = new TestableClientProxy(); + return proxy.PublicGetAcceptForActionReturn(action); + } + + private static ActionApiDescriptionModel BuildAction(string returnType, IList? contentTypes) + { + return new ActionApiDescriptionModel + { + UniqueName = "Sample", + Name = "Sample", + HttpMethod = "GET", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes + }, + AuthorizeDatas = new List() + }; + } + + [Fact] + public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json", "text/plain" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0"); + + headers.ShouldContain("application/octet-stream; v=2.0"); + headers.ShouldNotContain(h => h == "text/plain; v=2.0"); + headers.ShouldNotContain(h => h == "application/json; v=2.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("application/octet-stream"); + } + + [Fact] + public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0"); + + headers.ShouldContain("text/plain; v=1.0"); + headers.ShouldContain("application/json; v=1.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("text/plain"); + headers.ShouldContain("application/json"); + headers.ShouldNotContain(h => h.Contains("; v=")); + } + + private static IList InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version) + { + var proxy = new TestableClientProxy(); + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x"); + var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty); + proxy.PublicAddAcceptHeaders(action, message, apiVersion); + return message.Headers.Accept.Select(a => a.ToString()).ToList(); + } + + private sealed class TestableClientProxy : ClientProxyBase + { + public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) + => GetAcceptForActionReturn(action); + + public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) + => AddAcceptHeaders(action, requestMessage, apiVersion); + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs new file mode 100644 index 0000000000..12b453f4f4 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs @@ -0,0 +1,804 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Http.ProxyScripting.Generators; +using Volo.Abp.Json; +using Volo.Abp.Timing; +using Xunit; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Volo.Abp.Http.Client.ClientProxying; + +public class ClientProxyRequestPayloadBuilder_FormData_Tests +{ + private readonly ClientProxyRequestPayloadBuilder _builder; + private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer(); + private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0"); + + public ClientProxyRequestPayloadBuilder_FormData_Tests() + { + var services = new ServiceCollection(); + var scopeFactory = services.BuildServiceProvider().GetRequiredService(); + var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions()); + _builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock()); + } + + [Fact] + public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var stream = MakeStream("hello-direct"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(1); + parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain"); + (await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct"); + } + + [Fact] + public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStreamPart(parts[1], "File", "hello-single", "text/plain"); + } + + [Fact] + public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "batch", + Files = new[] + { + new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"), + new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Label", "batch"); + await AssertStreamPart(parts[1], "Files", "a-content", "text/plain"); + await AssertStreamPart(parts[2], "Files", "b-content", "text/csv"); + } + + [Fact] + public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestNestedUploadDto + { + Outer = "outerVal", + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain"); + } + + [Fact] + public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "Tag", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStringPart(parts[1], "Tag", "T1"); + } + + [Fact] + public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice" }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + } + + [Fact] + public async Task No_Form_Or_Body_Params_Should_Return_Null_Content() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + }); + var args = new Dictionary { ["id"] = 42 }; + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", File = null }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStringPart(parts[0], "Name", "Alice"); + } + + [Fact] + public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestThreeLevelDto + { + Outer = "outerVal", + Inner = new TestThreeLevelMiddleDto + { + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"), + }, + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain"); + } + + [Fact] + public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.FileNameStar.ShouldBe("中文-文件名.txt"); + (await part.ReadAsStringAsync()).ShouldBe("hello-utf8"); + } + + [Fact] + public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + body.ShouldContain("\"File\""); + } + + [Fact] + public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Main", nameOnMethod: "input"), + FormFileParam(name: "Extras", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestMixedUploadDto + { + Label = "combo", + Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"), + Extras = new[] + { + new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"), + new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + await AssertStringPart(parts[0], "Label", "combo"); + await AssertStreamPart(parts[1], "Main", "main-body", "text/plain"); + await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv"); + await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain"); + } + + [Fact] + public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other() + { + var actionA = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var actionB = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + + var argsA = new Dictionary + { + ["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") }, + }; + var argsB = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "B", + Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") }, + }, + }; + + var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType().ToList(); + var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType().ToList(); + + await AssertStringPart(contentA[0], "Name", "A"); + await AssertStreamPart(contentA[1], "File", "aa", "text/plain"); + await AssertStringPart(contentB[0], "Label", "B"); + await AssertStreamPart(contentB[1], "Files", "bb", "text/csv"); + } + + [Fact] + public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + FormParam(name: "ChildOnly", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestInheritedUploadDto + { + Name = "inherited", + File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"), + ChildOnly = "extra", + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "inherited"); + await AssertStreamPart(parts[1], "File", "from-base", "text/plain"); + await AssertStringPart(parts[2], "ChildOnly", "extra"); + } + + [Fact] + public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestRecordUploadDto( + Name: "Diana", + File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")), + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Diana"); + await AssertStreamPart(parts[1], "File", "from-record", "text/plain"); + } + + [Fact] + public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "When", nameOnMethod: "input"), + FormParam(name: "Status", nameOnMethod: "input"), + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc), + Status = TestStatus.Active, + Quantity = 7, + File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + (await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56"); + (await parts[1].ReadAsStringAsync()).ShouldBe("Active"); + (await parts[2].ReadAsStringAsync()).ShouldBe("7"); + await AssertStreamPart(parts[3], "File", "primitives", "text/plain"); + } + + [Fact] + public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + Quantity = null, + File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStreamPart(parts[0], "File", "null-qty", "text/plain"); + } + + [Fact] + public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "ExtraField", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto + { + Name = "poly", + ExtraField = "derived-only", + File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"), + }; + var args = new Dictionary { ["input"] = polymorphic }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "poly"); + await AssertStringPart(parts[1], "ExtraField", "derived-only"); + await AssertStreamPart(parts[2], "File", "from-derived", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = "closed-string", + File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "closed-string"); + await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = 42, + File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "42"); + } + + [Fact] + public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source() + { + // Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream` + // observes an empty body (no internal buffering). Opting in to buffering + // later would double-allocate for large uploads. + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var sourceStream = MakeStream("retry-payload"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + sourceStream.Position.ShouldBe(0); + + (await part.ReadAsStringAsync()).ShouldBe("retry-payload"); + + sourceStream.Position.ShouldBe(sourceStream.Length); + } + + [Fact] + public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing() + { + var action = BuildAction(parameters: Array.Empty()); + var args = new Dictionary(); + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["id"] = 7, + ["input"] = new TestUploadDto + { + Name = "Bob", + File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Bob"); + await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain"); + } + + private Task InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary args) + => _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion); + + private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters) + => new() + { + UniqueName = "TestAction", + Name = "TestAction", + HttpMethod = "POST", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters.ToList(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.String", + TypeSimple = "string", + }, + AuthorizeDatas = new List(), + }; + + private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = ParameterBindingSources.Form, + }; + + private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = typeof(IRemoteStreamContent).FullName!, + TypeSimple = "stream", + BindingSourceId = ParameterBindingSources.FormFile, + }; + + private static MemoryStream MakeStream(string text) + { + var ms = new MemoryStream(); + ms.Write(Encoding.UTF8.GetBytes(text)); + ms.Position = 0; + return ms; + } + + private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + (await part.ReadAsStringAsync()).ShouldBe(expectedValue); + } + + private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType); + (await part.ReadAsStringAsync()).ShouldBe(expectedBody); + } + + private class TestUploadDto + { + public string? Name { get; set; } + public string? Tag { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestUploadFilesDto + { + public string? Label { get; set; } + public IEnumerable? Files { get; set; } + } + + private class TestNestedUploadDto + { + public string? Outer { get; set; } + public TestNestedChildDto? Child { get; set; } + } + + private class TestNestedChildDto + { + public IRemoteStreamContent? File { get; set; } + } + + private class TestThreeLevelDto + { + public string? Outer { get; set; } + public TestThreeLevelMiddleDto? Inner { get; set; } + } + + private class TestThreeLevelMiddleDto + { + public TestNestedChildDto? Child { get; set; } + } + + private class TestMixedUploadDto + { + public string? Label { get; set; } + public IRemoteStreamContent? Main { get; set; } + public IEnumerable? Extras { get; set; } + } + + private class TestUploadDtoBase + { + public string? Name { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestInheritedUploadDto : TestUploadDtoBase + { + public string? ChildOnly { get; set; } + } + + private record TestRecordUploadDto(string Name, IRemoteStreamContent File); + + private class TestPrimitiveUploadDto + { + public DateTime When { get; set; } + public TestStatus Status { get; set; } + public int? Quantity { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private enum TestStatus + { + Pending = 0, + Active = 1, + Done = 2, + } + + private class TestPolymorphicDerivedDto : TestUploadDtoBase + { + public string? ExtraField { get; set; } + } + + private class TestGenericUploadDto + { + public T? Payload { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class StubJsonSerializer : IJsonSerializer + { + public string Serialize(object obj, bool camelCase = true, bool indented = false) + => System.Text.Json.JsonSerializer.Serialize(obj); + + public T Deserialize(string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString)!; + + public object Deserialize(Type type, string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!; + } + + private class TestClock : IClock + { + public DateTime Now => DateTime.UtcNow; + public DateTimeKind Kind => DateTimeKind.Utc; + public bool SupportsMultipleTimezone => false; + public DateTime Normalize(DateTime dateTime) => dateTime; + public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime; + public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset; + public DateTime ConvertToUtc(DateTime dateTime) => dateTime; + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index 21786155bf..fdf279e1ce 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -8,6 +9,26 @@ public interface IRegularTestController { Task IncrementValueAsync(int value); + Task GetPlainStringAsync(); + + Task GetProducesJsonStringAsync(); + + Task GetProducesTextStringAsync(); + + Task GetNullStringAsync(); + + Task GetProducesJsonNullStringAsync(); + + Task GetEmptyStringAsync(); + + Task GetEscapedStringAsync(); + + Task DownloadIconAsync(); + + Task GetReferenceTypeObjectAsync(); + + Task GetByteArrayAsync(); + Task GetException1Async(); Task GetException2Async(); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 0000000000..b89692eb90 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Http.Client; +using Volo.Abp.TestApp.Application; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class PeopleAppServiceClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase +{ + private readonly IPeopleAppService _peopleAppService; + + public PeopleAppServiceClientProxy_ReturnContentTypes_Tests() + { + _peopleAppService = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusWithProducesJsonAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata() + { + using var content = await _peopleAppService.GetBinaryImageAsync(); + using var ms = new MemoryStream(); + await content.GetStream().CopyToAsync(ms); + var bytes = ms.ToArray(); + + content.FileName.ShouldBe("tiny.png"); + content.ContentType.ShouldStartWith("image/png"); + bytes.Length.ShouldBeGreaterThan(8); + + bytes[0].ShouldBe((byte)0x89); + bytes[1].ShouldBe((byte)0x50); + bytes[2].ShouldBe((byte)0x4E); + bytes[3].ShouldBe((byte)0x47); + } + + [Fact] + public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client() + { + await Should.ThrowAsync( + () => _peopleAppService.ThrowFromStringAsync() + ); + } + + [Fact] + public async Task DownloadAsync_Should_Still_Work() + { + using var content = await _peopleAppService.DownloadAsync(); + using var reader = new StreamReader(content.GetStream()); + var text = await reader.ReadToEndAsync(); + text.ShouldBe("DownloadAsync"); + content.FileName.ShouldBe("download.rtf"); + content.ContentType.ShouldStartWith("application/rtf"); + } + + [Fact] + public async Task UploadAsync_String_Return_Should_Stay_Unquoted() + { + using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello")); + var result = await _peopleAppService.UploadAsync( + new Content.RemoteStreamContent(ms, "upload.txt", "text/plain")); + result.ShouldBe("hello:text/plain:upload.txt"); + result.StartsWith("\"").ShouldBeFalse(); + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 46b355090b..1d0864991b 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -20,6 +23,82 @@ public class RegularTestController : AbpController, IRegularTestController return Task.FromResult(value + 1); } + [HttpGet] + [Route("plain-string")] + public Task GetPlainStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-json-string")] + [Produces("application/json")] + public Task GetProducesJsonStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-text-string")] + [Produces("text/plain")] + public Task GetProducesTextStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("null-string")] + public Task GetNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("produces-json-null-string")] + [Produces("application/json")] + public Task GetProducesJsonNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("empty-string")] + public Task GetEmptyStringAsync() + { + return Task.FromResult(string.Empty); + } + + [HttpGet] + [Route("escaped-string")] + [Produces("application/json")] + public Task GetEscapedStringAsync() + { + return Task.FromResult("a\"b\\c\nd"); + } + + [HttpGet] + [Route("download-icon")] + public Task DownloadIconAsync() + { + var bytes = Encoding.UTF8.GetBytes("ICON-BYTES"); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "icon.bin", "application/octet-stream")); + } + + [HttpGet] + [Route("reference-type-object")] + public Task GetReferenceTypeObjectAsync() + { + return Task.FromResult(new Car { Year = 1999, Model = "BMW" }); + } + + [HttpGet] + [Route("byte-array")] + public Task GetByteArrayAsync() + { + return Task.FromResult(new byte[] { 1, 2, 3, 4 }); + } + [HttpGet] [Route("get-exception1")] public Task GetException1Async() diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 0000000000..92c8b5b23c --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs @@ -0,0 +1,103 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase +{ + private readonly IRegularTestController _controller; + + public RegularTestControllerClientProxy_ReturnContentTypes_Tests() + { + _controller = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task GetPlainStringAsync_Should_Return_Unwrapped_String() + { + var result = await _controller.GetPlainStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetProducesJsonStringAsync_Should_Strip_Json_Quotes() + { + var result = await _controller.GetProducesJsonStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + result.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetProducesTextStringAsync_Should_Return_Raw_Text_Body() + { + var result = await _controller.GetProducesTextStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetNullStringAsync_Should_Return_Default_Or_Empty() + { + var result = await _controller.GetNullStringAsync(); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetEmptyStringAsync_Should_Return_Empty() + { + var result = await _controller.GetEmptyStringAsync(); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null() + { + var result = await _controller.GetProducesJsonNullStringAsync(); + result.ShouldNotBe("null"); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters() + { + var result = await _controller.GetEscapedStringAsync(); + result.ShouldBe("a\"b\\c\nd"); + } + + [Fact] + public async Task DownloadIconAsync_Should_Return_Binary_Bytes() + { + using var content = await _controller.DownloadIconAsync(); + using var ms = new MemoryStream(); + await content.GetStream().CopyToAsync(ms); + ms.ToArray().ShouldBe(System.Text.Encoding.UTF8.GetBytes("ICON-BYTES")); + content.FileName.ShouldBe("icon.bin"); + } + + [Fact] + public async Task GetReferenceTypeObjectAsync_Should_Not_Be_Wrapped_As_RemoteStreamContent() + { + var result = await _controller.GetReferenceTypeObjectAsync(); + result.ShouldNotBeNull(); + result.ShouldNotBeAssignableTo(); + } + + [Fact] + public async Task GetByteArrayAsync_Should_Round_Trip_Bytes() + { + var bytes = await _controller.GetByteArrayAsync(); + bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); + } + + [Fact] + public async Task Existing_IncrementValueAsync_Regression_Should_Still_Work() + { + var result = await _controller.IncrementValueAsync(41); + result.ShouldBe(42); + } +} diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs new file mode 100644 index 0000000000..d3c3197937 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs @@ -0,0 +1,279 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Xunit; + +namespace Volo.Abp.Http.Modeling; + +public class ReturnValueApiDescriptionModel_Tests +{ + [Fact] + public void Create_Without_ContentTypes_Should_Leave_Property_Null() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + + model.ShouldNotBeNull(); + model.TypeSimple.ShouldBe("string"); + model.ContentTypes.ShouldBeNull(); + } + + [Fact] + public void Create_With_ContentTypes_Should_Populate_The_Property() + { + var model = ReturnValueApiDescriptionModel.Create( + typeof(string), + new[] { "application/json", "text/plain" }); + + model.ContentTypes.ShouldNotBeNull(); + model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); + } +} + +public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests +{ + [Fact] + public void Direct_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Concrete_RemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(MyCustomStreamContent)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Task_Of_IRemoteStreamContent_Should_Be_True_After_UnwrapTask() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void IRemoteStreamContent_Array_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[])); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Concrete_RemoteStreamContent_Array_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[])); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void List_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(List)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Plain_String_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Int_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(int)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Plain_Dto_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(PlainDto)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dto_Containing_IRemoteStreamContent_Property_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(DtoWithStream)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dto_Inheriting_From_Type_With_Stream_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(DtoInheritingStream)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Byte_Array_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(byte[])); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dictionary_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Dictionary)); + model.IsRemoteStream.ShouldBeFalse(); + } + + private class MyCustomStreamContent : IRemoteStreamContent + { + public string? FileName => null; + public string? ContentType => null; + public long? ContentLength => null; + public Stream GetStream() => Stream.Null; + public void Dispose() { } + } + + private class PlainDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class DtoWithStream + { + public string FileName { get; set; } = string.Empty; + public IRemoteStreamContent? File { get; set; } + } + + private class DtoInheritingStream : PlainDto + { + public IRemoteStreamContent? Stream { get; set; } + } +} + +public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests +{ + [Fact] + public void Deserializing_Json_Without_ContentTypes_Field_Should_Leave_It_Null() + { + var json = """ + { + "type": "System.String", + "typeSimple": "string" + } + """; + + var model = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model.ShouldNotBeNull(); + model!.Type.ShouldBe("System.String"); + model.TypeSimple.ShouldBe("string"); + model.ContentTypes.ShouldBeNull(); + } + + [Fact] + public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It() + { + var json = """ + { + "type": "System.String", + "typeSimple": "string", + "contentTypes": ["application/json", "text/plain"] + } + """; + + var model = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model!.ContentTypes.ShouldNotBeNull(); + model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); + } + + [Fact] + public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + var json = System.Text.Json.JsonSerializer.Serialize(model); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + deserialized!.ContentTypes.ShouldBeNull(); + } +} + +public class ActionApiDescriptionModel_Tests +{ + [Fact] + public void Create_Should_Propagate_ReturnValueContentTypes() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }, + allowAnonymous: true, + authorizeDatas: null, + implementFrom: null, + returnValueContentTypes: new[] { "application/octet-stream" }); + + model.ReturnValue.ContentTypes.ShouldNotBeNull(); + model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" }); + } + + [Fact] + public void Create_Without_ReturnValueContentTypes_Should_Leave_Null() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }); + + model.ReturnValue.ContentTypes.ShouldBeNull(); + } + + public string SampleMethod() => string.Empty; +} diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs new file mode 100644 index 0000000000..a7c63f8270 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -0,0 +1,266 @@ +#nullable enable +using System.Collections.Generic; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Http.ProxyScripting.Generators; +using Volo.Abp.Http.ProxyScripting.Generators.JQuery; +using Xunit; + +namespace Volo.Abp.Http.ProxyScripting.Generators.JQuery; + +public class JQueryProxyScriptGenerator_ContentTypes_Tests +{ + private readonly JQueryProxyScriptGenerator _generator = new( + Microsoft.Extensions.Options.Options.Create(new DynamicJavaScriptProxyOptions())); + + [Fact] + public void Should_Emit_Json_DataType_And_Accept_When_ContentTypes_Contain_Json() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/json", "text/json" })); + + script.ShouldContain("dataType: 'json'"); + script.ShouldContain("Accept: 'application/json'"); + script.ShouldNotContain("dataType: 'text'"); + } + + [Fact] + public void Should_Emit_Text_DataType_And_Accept_When_ContentTypes_Only_Text() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "text/plain", "text/csv" })); + + script.ShouldContain("dataType: 'text'"); + script.ShouldContain("Accept: 'text/plain'"); + } + + [Fact] + public void Should_Fallback_To_Legacy_Text_When_Return_Is_String_And_ContentTypes_Null() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: null)); + + // Legacy behavior preserved: string return → dataType: 'text' (no Accept override) + script.ShouldContain("dataType: 'text'"); + script.ShouldNotContain("Accept:"); + } + + [Fact] + public void Should_Emit_No_DataType_For_Non_String_Return_Without_ContentTypes() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.Int32", + contentTypes: null)); + + script.ShouldNotContain("dataType: 'text'"); + script.ShouldNotContain("dataType: 'json'"); + } + + [Fact] + public void Should_Not_Emit_Json_DataType_When_Only_Binary_ContentTypes() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.Byte[]", + contentTypes: new[] { "application/octet-stream", "image/png" })); + + // jQuery dataType doesn't have a clean "blob" — fall through to no override + script.ShouldNotContain("dataType: 'json'"); + script.ShouldNotContain("dataType: 'text'"); + } + + [Fact] + public void Should_Prefer_Json_When_Json_Present_Even_With_Other_Types() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "My.Project.UserDto", + contentTypes: new[] { "application/xml", "application/json", "text/html" })); + + script.ShouldContain("dataType: 'json'"); + script.ShouldContain("Accept: 'application/json'"); + } + + [Fact] + public void Case_Insensitive_Json_Detection() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "APPLICATION/JSON" })); + + script.ShouldContain("dataType: 'json'"); + } + + [Fact] + public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() + { + // IRemoteStreamContent: ABP advertises application/json in formatter list, but + // forcing dataType:'json' here would make the server JSON-serialise the stream. + var model = BuildAppModel( + returnType: "Volo.Abp.Content.IRemoteStreamContent", + contentTypes: new[] { "text/plain", "application/json", "text/json" }, + isRemoteStream: true); + + var script = _generator.CreateScript(model); + + script.ShouldNotContain("dataType: 'json'"); + script.ShouldNotContain("Accept: 'application/json'"); + } + + [Fact] + public void Multipart_Upload_Should_Emit_FormData_Body_With_ProcessData_And_ContentType_False() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + ("File", "input", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: input"); + script.ShouldContain("processData: false"); + script.ShouldContain("contentType: false"); + script.ShouldNotContain("contentType: 'application/x-www-form-urlencoded"); + } + + [Fact] + public void Multipart_Upload_Should_Use_NameOnMethod_As_Data_Variable() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("file", "file", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: file"); + script.ShouldContain("processData: false"); + script.ShouldContain("contentType: false"); + } + + [Fact] + public void Plain_Form_Action_Without_FormFile_Should_Still_Emit_UrlEncoded_ContentType() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + })); + + script.ShouldContain("contentType: 'application/x-www-form-urlencoded; charset=UTF-8'"); + script.ShouldNotContain("processData: false"); + script.ShouldNotContain("contentType: false"); + } + + [Fact] + public void Multipart_Upload_Should_Skip_FormPostData_And_Body_Generation() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + ("File", "input", ParameterBindingSources.FormFile), + })); + + script.ShouldNotContain("'Name=' + "); + script.ShouldNotContain("JSON.stringify"); + } + + [Fact] + public void Multiple_Direct_FormFile_Params_Should_Forward_Only_First_Var_Known_Limitation() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("file1", "file1", ParameterBindingSources.FormFile), + ("file2", "file2", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: file1"); + script.ShouldNotContain("data: file2"); + script.ShouldNotContain("$.merge(file1, file2)"); + } + + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes, bool isRemoteStream = false) + { + var model = ApplicationApiDescriptionModel.Create(); + var module = model.GetOrAddModule("app", "Default"); + var controller = module.GetOrAddController( + name: "TestController", + groupName: null, + isRemoteService: true, + isIntegrationService: false, + apiVersion: null, + type: typeof(object)); + + var action = new ActionApiDescriptionModel + { + UniqueName = "DoSomethingAsync", + Name = "DoSomethingAsync", + HttpMethod = "GET", + Url = "api/test/do-something", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes, + IsRemoteStream = isRemoteStream, + }, + AuthorizeDatas = new List(), + }; + controller.AddAction("DoSomethingAsync", action); + + return model; + } + + private static ApplicationApiDescriptionModel BuildUploadModel( + (string Name, string NameOnMethod, string BindingSourceId)[] uploadParameters) + { + var model = ApplicationApiDescriptionModel.Create(); + var module = model.GetOrAddModule("app", "Default"); + var controller = module.GetOrAddController( + name: "TestController", + groupName: null, + isRemoteService: true, + isIntegrationService: false, + apiVersion: null, + type: typeof(object)); + + var parameters = new List(); + foreach (var (name, nameOnMethod, binding) in uploadParameters) + { + parameters.Add(new ParameterApiDescriptionModel + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = binding, + }); + } + + var action = new ActionApiDescriptionModel + { + UniqueName = "UploadAsync", + Name = "UploadAsync", + HttpMethod = "POST", + Url = "api/test/upload", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters, + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.Void", + TypeSimple = "void", + }, + AuthorizeDatas = new List(), + }; + controller.AddAction("UploadAsync", action); + + return model; + } +} diff --git a/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs new file mode 100644 index 0000000000..202c1d59e2 --- /dev/null +++ b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Volo.Abp.Timing; +using Xunit; + +namespace Volo.Abp.Json; + +/// +/// Regression tests for the warning +/// "Could not convert DateTime with unspecified Kind using timezone '...'." +/// logged by AbpDateTimeConverterBase.Normalize. +/// +/// When is the converter treats +/// an value as local time in the current user's timezone and +/// converts it to UTC. For a value within the offset distance of / +/// (most commonly the placeholder) a +/// non-zero offset pushes the UTC equivalent outside the supported range, which used to be swallowed +/// and logged as a warning on every serialization. The converter now detects this boundary case and +/// keeps the value unchanged, so no warning is emitted while the serialized output stays the same. +/// +public class AbpDateTimeConverterTimezone_Tests : AbpJsonSystemTextJsonTestBase +{ + private const string WarningFragment = "Could not convert DateTime with unspecified Kind"; + + private static readonly CapturingLoggerProvider LogCapture = new(); + + private readonly IJsonSerializer _jsonSerializer; + private readonly ICurrentTimezoneProvider _currentTimezoneProvider; + + public AbpDateTimeConverterTimezone_Tests() + { + _jsonSerializer = GetRequiredService(); + _currentTimezoneProvider = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + // The warning only happens with a UTC clock, where Unspecified values get converted to UTC. + services.Configure(options => options.Kind = DateTimeKind.Utc); + + LogCapture.Clear(); + services.AddSingleton(LogCapture); + + base.AfterAddApplication(services); + } + + private sealed class FileModel + { + public DateTime DateModified { get; set; } + + public DateTime DateCreated { get; set; } + } + + [Theory] + [InlineData("Asia/Shanghai")] // +08:00 + [InlineData("Europe/Brussels")] // +01:00 / +02:00 + public void Should_Not_Warn_When_Serializing_MinValue_Under_Positive_Offset_Timezone(string timeZoneId) + { + _currentTimezoneProvider.TimeZone = timeZoneId; + LogCapture.Clear(); + + DateTime.MinValue.Kind.ShouldBe(DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MinValue, + DateCreated = DateTime.MinValue + }); + + // The placeholder is serialized unchanged and no warning is logged. + json.ShouldContain("0001-01-01"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Value_Near_MinValue_Under_Positive_Offset_Timezone() + { + // Not exactly DateTime.MinValue: any value within the offset distance of the lower bound + // overflows the same way, so the converter must absorb it rather than warn. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows + + _jsonSerializer.Serialize(new FileModel + { + DateModified = nearMin, + DateCreated = nearMin + }); + + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_MaxValue_Under_Negative_Offset_Timezone() + { + // The symmetric case: a negative offset would push MaxValue past DateTime.MaxValue. + _currentTimezoneProvider.TimeZone = "America/New_York"; + LogCapture.Clear(); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MaxValue, + DateCreated = DateTime.MaxValue + }); + + json.ShouldContain("9999-12-31"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Real_Utc_Timestamp_Under_Positive_Offset_Timezone() + { + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; + LogCapture.Clear(); + + var utc = new DateTime(2026, 6, 27, 10, 28, 7, DateTimeKind.Utc); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = utc, + DateCreated = utc + }); + + json.ShouldContain("2026-06-27T10:28:07Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Still_Convert_Real_Unspecified_Timestamp_To_Utc_Under_Positive_Offset_Timezone() + { + // A genuine (non-sentinel) Unspecified value must still be converted to UTC using the offset. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = unspecified, + DateCreated = unspecified + }); + + // 18:00 in +08:00 == 10:00 UTC. + json.ShouldContain("2026-06-27T10:00:00Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Warnings { get; } = new(); + + public void Clear() + { + Warnings.Clear(); + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(Warnings); + } + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _sink; + + public CapturingLogger(ConcurrentQueue sink) + { + _sink = sink; + } + + public IDisposable BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Warning; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + if (logLevel >= LogLevel.Warning) + { + _sink.Enqueue(formatter(state, exception)); + } + } + } + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs index 72150477a0..ba3f3f3fe4 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpReverseMapperly_Tests.cs @@ -31,18 +31,18 @@ public partial class MyReverseClassMapper : TwoWayMapperBase Task GetParamsFromQueryAsync(GetParamsInput input); Task GetParamsFromFormAsync(GetParamsInput input); + + Task EchoStatusAsync(); + + Task EchoStatusWithProducesJsonAsync(); + + Task GetBinaryImageAsync(); + + Task ThrowFromStringAsync(); } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs index a3920ec8ca..5eb9370754 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Shouldly; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.TestApp.Domain; using Volo.Abp.Domain.Repositories; @@ -94,6 +95,30 @@ public class PeopleAppService : CrudAppService, IPeople return new RemoteStreamContent(memoryStream, "download.rtf", "application/rtf"); } + public Task EchoStatusAsync() + { + return Task.FromResult("Open"); + } + + [Produces("application/json")] + public Task EchoStatusWithProducesJsonAsync() + { + return Task.FromResult("Open"); + } + + public Task GetBinaryImageAsync() + { + var bytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "tiny.png", "image/png")); + } + + public Task ThrowFromStringAsync() + { + throw new BusinessException("TestApp.StringEndpointBoom", "string endpoint failed"); + } + public async Task UploadAsync(IRemoteStreamContent streamContent) { using (var reader = new StreamReader(streamContent.GetStream())) diff --git a/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs b/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs new file mode 100644 index 0000000000..6707eb8b82 --- /dev/null +++ b/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs @@ -0,0 +1,60 @@ +using System; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Timing; + +public class TimezoneProviderExtensions_Tests : AbpIntegratedTest +{ + private readonly ITimezoneProvider _timezoneProvider; + + public TimezoneProviderExtensions_Tests() + { + _timezoneProvider = GetRequiredService(); + } + + [Theory] + [InlineData("Asia/Shanghai")] // +08:00 + [InlineData("Europe/Brussels")] // +01:00 / +02:00 + public void Should_Keep_MinValue_Unchanged_Under_Positive_Offset(string timeZoneId) + { + // A positive offset would push DateTime.MinValue below the supported range; keep it as-is. + var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MinValue, timeZoneId); + + result.ShouldBe(DateTime.MinValue); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Keep_Value_Near_MinValue_Unchanged_Under_Positive_Offset() + { + var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows + + var result = _timezoneProvider.ConvertUnspecifiedToUtc(nearMin, "Asia/Shanghai"); + + result.ShouldBe(nearMin); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Keep_MaxValue_Unchanged_Under_Negative_Offset() + { + var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MaxValue, "America/New_York"); + + result.ShouldBe(DateTime.MaxValue); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Convert_Unspecified_Value_To_Utc_Using_Offset() + { + var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified); + + var result = _timezoneProvider.ConvertUnspecifiedToUtc(unspecified, "Asia/Shanghai"); // +08:00 + + // 18:00 in +08:00 == 10:00 UTC. + result.ShouldBe(new DateTime(2026, 6, 27, 10, 0, 0, DateTimeKind.Utc)); + result.Kind.ShouldBe(DateTimeKind.Utc); + } +} diff --git a/latest-versions.json b/latest-versions.json index ccd634c25c..79868e94a9 100644 --- a/latest-versions.json +++ b/latest-versions.json @@ -1,4 +1,22 @@ [ + { + "version": "10.5.0", + "releaseDate": "", + "type": "stable", + "message": "", + "leptonx": { + "version": "5.5.0" + } + }, + { + "version": "10.4.1", + "releaseDate": "", + "type": "stable", + "message": "", + "leptonx": { + "version": "5.4.1" + } + }, { "version": "10.4.0", "releaseDate": "", diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs index 976705b07d..5569f6800a 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/DemoAppModule.cs @@ -1,5 +1,7 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Autofac; +using Volo.Abp.BackgroundJobs.DemoApp.Jobs; using Volo.Abp.BackgroundJobs.DemoApp.Shared; using Volo.Abp.BackgroundJobs.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -32,17 +34,31 @@ public class DemoAppModule : AbpModule options.JobPollPeriod = 1000; options.DefaultFirstWaitDuration = 1; options.DefaultWaitFactor = 1; + + // Keep every successfully completed job as history (marks CompletionTime instead of deleting). + // Completed jobs are excluded from the waiting query and pruned after SuccessfulJobRetentionTime. + options.StoreSuccessfulJobs = true; + options.SuccessfulJobRetentionTime = System.TimeSpan.FromDays(1); + + // A dedicated worker (with its own distributed lock "DemoFeesWorkerLock") that only processes + // the slow fee-calculation jobs, so they don't block other jobs. A default worker is added automatically + // and processes all the remaining job types (e.g. SendEmailJob). + options.AddDedicatedWorker("DemoFeesWorkerLock"); + + // Let each worker execute up to 4 jobs in parallel (each job claimed with its own distributed lock, + // so multiple application instances can execute different jobs concurrently). + options.MaxParallelJobExecutionCount = 4; }); } - public override Task OnApplicationInitializationAsync(ApplicationInitializationContext context) + public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) { - //TODO: Configure console logging - //context - // .ServiceProvider - // .GetRequiredService() - // .AddConsole(LogLevel.Debug); + // Enqueue a few demo jobs. The fee-calculation jobs are handled by the dedicated worker, + // while SendEmailJob is handled by the default worker. + var backgroundJobManager = context.ServiceProvider.GetRequiredService(); - return Task.CompletedTask; + await backgroundJobManager.EnqueueAsync(new CalculateAwsFeesJobArgs { AccountId = "acc-1" }); + await backgroundJobManager.EnqueueAsync(new CalculateAzureFeesJobArgs { SubscriptionId = "sub-1" }); + await backgroundJobManager.EnqueueAsync(new SendEmailJobArgs { To = "user@example.com", Subject = "Welcome" }); } } diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs new file mode 100644 index 0000000000..2262bbc68d --- /dev/null +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAwsFeesJob.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs; + +public class CalculateAwsFeesJobArgs +{ + public string AccountId { get; set; } = default!; +} + +public class CalculateAwsFeesJob : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(CalculateAwsFeesJobArgs args) + { + // A slow, resource-intensive job that is isolated on a dedicated worker (see DemoAppModule). + Logger.LogInformation($"[AWS fees] Calculating fees for account '{args.AccountId}'..."); + return Task.CompletedTask; + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs new file mode 100644 index 0000000000..d0dc43cd94 --- /dev/null +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/CalculateAzureFeesJob.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs; + +public class CalculateAzureFeesJobArgs +{ + public string SubscriptionId { get; set; } = default!; +} + +public class CalculateAzureFeesJob : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(CalculateAzureFeesJobArgs args) + { + // Isolated on the same dedicated fees worker as CalculateAwsFeesJob. + Logger.LogInformation($"[Azure fees] Calculating fees for subscription '{args.SubscriptionId}'..."); + return Task.CompletedTask; + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs new file mode 100644 index 0000000000..846b06f4fe --- /dev/null +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Jobs/SendEmailJob.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BackgroundJobs.DemoApp.Jobs; + +public class SendEmailJobArgs +{ + public string To { get; set; } = default!; + + public string Subject { get; set; } = default!; +} + +public class SendEmailJob : AsyncBackgroundJob, ITransientDependency +{ + public override Task ExecuteAsync(SendEmailJobArgs args) + { + // A fast job. It is NOT configured for a dedicated worker, so the default worker processes it + // without waiting behind the slow fee-calculation jobs. + Logger.LogInformation($"[Email] Sending '{args.Subject}' to '{args.To}'..."); + return Task.CompletedTask; + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs new file mode 100644 index 0000000000..01241ef94a --- /dev/null +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Volo.Abp.BackgroundJobs.DemoApp.Db; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations +{ + [DbContext(typeof(DemoAppDbContext))] + [Migration("20260701082002_Added_CompletionTime_To_BackgroundJobs")] + partial class Added_CompletionTime_To_BackgroundJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer) + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("nvarchar(96)"); + + b.Property("CompletionTime") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasColumnName("ExtraProperties"); + + b.Property("IsAbandoned") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("JobArgs") + .IsRequired() + .HasMaxLength(1048576) + .HasColumnType("nvarchar(max)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastTryTime") + .HasColumnType("datetime2"); + + b.Property("NextTryTime") + .HasColumnType("datetime2"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint") + .HasDefaultValue((byte)15); + + b.Property("TryCount") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.HasKey("Id"); + + b.HasIndex("ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime"); + + b.ToTable("AbpBackgroundJobs", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs new file mode 100644 index 0000000000..96f35f3379 --- /dev/null +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/20260701082002_Added_CompletionTime_To_BackgroundJobs.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations +{ + /// + public partial class Added_CompletionTime_To_BackgroundJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AbpBackgroundJobs_IsAbandoned_NextTryTime", + table: "AbpBackgroundJobs"); + + migrationBuilder.AddColumn( + name: "CompletionTime", + table: "AbpBackgroundJobs", + type: "datetime2", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AbpBackgroundJobs_ApplicationName_CompletionTime_IsAbandoned_NextTryTime", + table: "AbpBackgroundJobs", + columns: new[] { "ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AbpBackgroundJobs_ApplicationName_CompletionTime_IsAbandoned_NextTryTime", + table: "AbpBackgroundJobs"); + + migrationBuilder.DropColumn( + name: "CompletionTime", + table: "AbpBackgroundJobs"); + + migrationBuilder.CreateIndex( + name: "IX_AbpBackgroundJobs_IsAbandoned_NextTryTime", + table: "AbpBackgroundJobs", + columns: new[] { "IsAbandoned", "NextTryTime" }); + } + } +} diff --git a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs index ab91bc354c..af43eb368f 100644 --- a/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs +++ b/modules/background-jobs/app/Volo.Abp.BackgroundJobs.DemoApp/Migrations/DemoAppDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.SqlServer) - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -34,6 +34,9 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations .HasMaxLength(96) .HasColumnType("nvarchar(96)"); + b.Property("CompletionTime") + .HasColumnType("datetime2"); + b.Property("ConcurrencyStamp") .IsConcurrencyToken() .IsRequired() @@ -83,7 +86,7 @@ namespace Volo.Abp.BackgroundJobs.DemoApp.Migrations b.HasKey("Id"); - b.HasIndex("IsAbandoned", "NextTryTime"); + b.HasIndex("ApplicationName", "CompletionTime", "IsAbandoned", "NextTryTime"); b.ToTable("AbpBackgroundJobs", (string)null); }); diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs index c01f933781..18862c8b1d 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobRecord.cs @@ -48,6 +48,12 @@ public class BackgroundJobRecord : AggregateRoot, IHasCreationTime /// public virtual bool IsAbandoned { get; set; } + /// + /// The time this job was completed successfully. When set, the job is kept as history and excluded + /// from the waiting jobs query (set only when successful job persistence is enabled). + /// + public virtual DateTime? CompletionTime { get; set; } + /// /// Priority of this job. /// diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs index c7dbd6fbef..9d24c6c236 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobStore.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; using Volo.Abp.ObjectMapping; @@ -22,9 +23,13 @@ public class BackgroundJobStore : IBackgroundJobStore, ITransientDependency public virtual async Task FindAsync(Guid jobId) { - return ObjectMapper.Map( - await BackgroundJobRepository.FindAsync(jobId) - ); + var backgroundJobRecord = await BackgroundJobRepository.FindAsync(jobId); + if (backgroundJobRecord == null) + { + return null!; + } + + return ObjectMapper.Map(backgroundJobRecord); } public virtual async Task InsertAsync(BackgroundJobInfo jobInfo) @@ -34,18 +39,37 @@ public class BackgroundJobStore : IBackgroundJobStore, ITransientDependency ); } - public virtual async Task> GetWaitingJobsAsync(string applicationName, int maxResultCount) + public virtual async Task> GetWaitingJobsAsync(string? applicationName, int maxResultCount) { return ObjectMapper.Map, List>( await BackgroundJobRepository.GetWaitingListAsync(applicationName, maxResultCount) ); } + public virtual async Task> GetWaitingJobsAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter) + { + return ObjectMapper.Map, List>( + await BackgroundJobRepository.GetWaitingListAsync(applicationName, maxResultCount, jobNameFilter) + ); + } + public virtual async Task DeleteAsync(Guid jobId) { await BackgroundJobRepository.DeleteAsync(jobId); } + public virtual async Task DeleteAsync( + string? applicationName, + DateTime completedBefore, + int maxResultCount, + CancellationToken cancellationToken = default) + { + return await BackgroundJobRepository.DeleteAsync(applicationName, completedBefore, maxResultCount, cancellationToken); + } + public virtual async Task UpdateAsync(BackgroundJobInfo jobInfo) { var backgroundJobRecord = await BackgroundJobRepository.FindAsync(jobInfo.Id); diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs index 9ac5cef9bf..a605582201 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/BackgroundJobsDomainMapperlyMappers.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Riok.Mapperly.Abstractions; using Volo.Abp.Mapperly; -namespace Volo.Abp.BackgroundJobs; +namespace Volo.Abp.BackgroundJobs; [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] public partial class BackgroundJobInfoToBackgroundJobRecordMapper @@ -31,6 +31,6 @@ public partial class BackgroundJobRecordToBackgroundJobInfoMapper : MapperBase { public override partial BackgroundJobInfo Map(BackgroundJobRecord source); - + public override partial void Map(BackgroundJobRecord source, BackgroundJobInfo destination); } diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs index 7e189df5c8..d4e8df2d83 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.Domain/Volo/Abp/BackgroundJobs/IBackgroundJobRepository.cs @@ -2,12 +2,26 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using Volo.Abp.Domain.Repositories; namespace Volo.Abp.BackgroundJobs; public interface IBackgroundJobRepository : IBasicRepository { - Task> GetWaitingListAsync([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default); + Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + CancellationToken cancellationToken = default); + + Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string? applicationName, + DateTime completedBefore, + int maxResultCount, + CancellationToken cancellationToken = default); } diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs index 53a2c53843..70a73a8562 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/BackgroundJobsDbContextModelCreatingExtensions.cs @@ -29,9 +29,10 @@ public static class BackgroundJobsDbContextModelCreatingExtensions b.Property(x => x.NextTryTime); b.Property(x => x.LastTryTime); b.Property(x => x.IsAbandoned).HasDefaultValue(false); + b.Property(x => x.CompletionTime); b.Property(x => x.Priority).HasDefaultValue(BackgroundJobPriority.Normal).HasSentinel(BackgroundJobPriority.Normal); - b.HasIndex(x => new { x.IsAbandoned, x.NextTryTime }); + b.HasIndex(x => new { x.ApplicationName, x.CompletionTime, x.IsAbandoned, x.NextTryTime }); b.ApplyObjectExtensionMappings(); }); diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs index a81de3cdfb..5b55a7adf8 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.EntityFrameworkCore/Volo/Abp/BackgroundJobs/EntityFrameworkCore/EfCoreBackgroundJobRepository.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -23,20 +22,71 @@ public class EfCoreBackgroundJobRepository : EfCoreRepository> GetWaitingListAsync([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default) + public virtual Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + CancellationToken cancellationToken = default) { - return await (await GetWaitingListQueryAsync(applicationName, maxResultCount)).ToListAsync(GetCancellationToken(cancellationToken)); + return GetWaitingListAsync(applicationName, maxResultCount, null, cancellationToken); } - protected virtual async Task> GetWaitingListQueryAsync([CanBeNull] string applicationName, int maxResultCount) + public virtual async Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter, + CancellationToken cancellationToken = default) + { + return await (await GetWaitingListQueryAsync(applicationName, maxResultCount, jobNameFilter)) + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + protected virtual async Task> GetWaitingListQueryAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter = null) { var now = Clock.Now; - return (await GetDbSetAsync()) + var filter = jobNameFilter ?? BackgroundJobNameFilter.None; + var jobNames = filter.JobNames.ToList(); + + var query = (await GetDbSetAsync()) .Where(t => t.ApplicationName == applicationName) - .Where(t => !t.IsAbandoned && t.NextTryTime <= now) + .Where(t => !t.IsAbandoned && t.CompletionTime == null && t.NextTryTime <= now); + + if (filter.Mode == BackgroundJobNameFilterMode.Include) + { + query = query.Where(t => jobNames.Contains(t.JobName)); + } + else if (filter.Mode == BackgroundJobNameFilterMode.Exclude) + { + query = query.Where(t => !jobNames.Contains(t.JobName)); + } + + return query .OrderByDescending(t => t.Priority) .ThenBy(t => t.TryCount) .ThenBy(t => t.NextTryTime) .Take(maxResultCount); } + + public virtual async Task DeleteAsync(string? applicationName, DateTime completedBefore, int maxResultCount, CancellationToken cancellationToken = default) + { + var token = GetCancellationToken(cancellationToken); + var dbSet = await GetDbSetAsync(); + + var ids = await dbSet + .Where(t => t.ApplicationName == applicationName) + .Where(t => t.CompletionTime != null && t.CompletionTime < completedBefore) + .OrderBy(t => t.CompletionTime) + .Select(t => t.Id) + .Take(maxResultCount) + .ToListAsync(token); + + if (ids.Count == 0) + { + return 0; + } + + return await dbSet.Where(t => ids.Contains(t.Id)).ExecuteDeleteAsync(token); + } } diff --git a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.MongoDB/Volo/Abp/BackgroundJobs/MongoDB/MongoBackgroundJobRepository.cs b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.MongoDB/Volo/Abp/BackgroundJobs/MongoDB/MongoBackgroundJobRepository.cs index 71a5680524..af4ab7ef89 100644 --- a/modules/background-jobs/src/Volo.Abp.BackgroundJobs.MongoDB/Volo/Abp/BackgroundJobs/MongoDB/MongoBackgroundJobRepository.cs +++ b/modules/background-jobs/src/Volo.Abp.BackgroundJobs.MongoDB/Volo/Abp/BackgroundJobs/MongoDB/MongoBackgroundJobRepository.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using System.Linq; -using JetBrains.Annotations; using MongoDB.Driver; using MongoDB.Driver.Linq; using Volo.Abp.Domain.Repositories.MongoDB; @@ -24,20 +23,78 @@ public class MongoBackgroundJobRepository : MongoDbRepository> GetWaitingListAsync([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default) + public virtual Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + CancellationToken cancellationToken = default) { - return await (await GetWaitingListQuery(applicationName, maxResultCount, cancellationToken)).ToListAsync(GetCancellationToken(cancellationToken)); + return GetWaitingListAsync(applicationName, maxResultCount, null, cancellationToken); } - protected virtual async Task> GetWaitingListQuery([CanBeNull] string applicationName, int maxResultCount, CancellationToken cancellationToken = default) + public virtual async Task> GetWaitingListAsync( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter, + CancellationToken cancellationToken = default) + { + return await (await GetWaitingListQuery(applicationName, maxResultCount, jobNameFilter, cancellationToken)) + .ToListAsync(GetCancellationToken(cancellationToken)); + } + + protected virtual async Task> GetWaitingListQuery( + string? applicationName, + int maxResultCount, + BackgroundJobNameFilter? jobNameFilter = null, + CancellationToken cancellationToken = default) { var now = Clock.Now; - return (await GetQueryableAsync(cancellationToken)) + var filter = jobNameFilter ?? BackgroundJobNameFilter.None; + var jobNames = filter.JobNames.ToList(); + + var query = (await GetQueryableAsync(cancellationToken)) .Where(t => t.ApplicationName == applicationName) - .Where(t => !t.IsAbandoned && t.NextTryTime <= now) + .Where(t => !t.IsAbandoned && t.CompletionTime == null && t.NextTryTime <= now); + + if (filter.Mode == BackgroundJobNameFilterMode.Include) + { + query = query.Where(t => jobNames.Contains(t.JobName)); + } + else if (filter.Mode == BackgroundJobNameFilterMode.Exclude) + { + query = query.Where(t => !jobNames.Contains(t.JobName)); + } + + return query .OrderByDescending(t => t.Priority) .ThenBy(t => t.TryCount) .ThenBy(t => t.NextTryTime) .Take(maxResultCount); } + + public virtual async Task DeleteAsync(string? applicationName, DateTime completedBefore, int maxResultCount, CancellationToken cancellationToken = default) + { + var token = GetCancellationToken(cancellationToken); + + var ids = await (await GetQueryableAsync(token)) + .Where(t => t.ApplicationName == applicationName) + .Where(t => t.CompletionTime != null && t.CompletionTime < completedBefore) + .OrderBy(t => t.CompletionTime) + .Select(t => t.Id) + .Take(maxResultCount) + .ToListAsync(token); + + if (ids.Count == 0) + { + return 0; + } + + var dbContext = await GetDbContextAsync(token); + var collection = dbContext.Collection(); + + var result = dbContext.SessionHandle != null + ? await collection.DeleteManyAsync(dbContext.SessionHandle, x => ids.Contains(x.Id), cancellationToken: token) + : await collection.DeleteManyAsync(x => ids.Contains(x.Id), cancellationToken: token); + + return (int)result.DeletedCount; + } } diff --git a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobRepository_Tests.cs b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobRepository_Tests.cs index c62cb43309..b717d28d15 100644 --- a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobRepository_Tests.cs +++ b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobRepository_Tests.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Shouldly; using Volo.Abp.Modularity; @@ -36,4 +38,99 @@ public abstract class BackgroundJobRepository_Tests : Background backgroundJobs.Any(j => j.ApplicationName == "App2").ShouldBeFalse(); backgroundJobs.Any(j => j.ApplicationName == null).ShouldBeFalse(); } + + [Fact] + public async Task Should_Filter_Waiting_List_By_Included_Job_Names() + { + // App1 waiting jobs: two "TestJobName" + one "OtherJobName". + var testJobs = await _backgroundJobRepository.GetWaitingListAsync("App1", 10, BackgroundJobNameFilter.Include(new[] { "TestJobName" })); + testJobs.Count.ShouldBe(2); + testJobs.ShouldAllBe(j => j.JobName == "TestJobName"); + + var otherJobs = await _backgroundJobRepository.GetWaitingListAsync("App1", 10, BackgroundJobNameFilter.Include(new[] { "OtherJobName" })); + otherJobs.Count.ShouldBe(1); + otherJobs.Single().JobName.ShouldBe("OtherJobName"); + + var none = await _backgroundJobRepository.GetWaitingListAsync("App1", 10, BackgroundJobNameFilter.Include(new[] { "NonExistentJob" })); + none.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Filter_Waiting_List_By_Excluded_Job_Names() + { + var withoutTestJob = await _backgroundJobRepository.GetWaitingListAsync("App1", 10, BackgroundJobNameFilter.Exclude(new[] { "TestJobName" })); + withoutTestJob.Count.ShouldBe(1); + withoutTestJob.Single().JobName.ShouldBe("OtherJobName"); + + var withoutNonExistent = await _backgroundJobRepository.GetWaitingListAsync("App1", 10, BackgroundJobNameFilter.Exclude(new[] { "NonExistentJob" })); + withoutNonExistent.Count.ShouldBe(3); + } + + [Fact] + public async Task Should_Return_Null_From_Store_When_Job_Not_Found() + { + // The parallel worker re-reads a claimed job under the lock; a job removed by another instance must return null (not throw). + var store = GetRequiredService(); + var found = await store.FindAsync(Guid.NewGuid()); + found.ShouldBeNull(); + } + + [Fact] + public async Task Should_Exclude_Completed_Jobs_From_Waiting_List() + { + var completedJobId = Guid.NewGuid(); + await _backgroundJobRepository.InsertAsync( + new BackgroundJobRecord(completedJobId) + { + ApplicationName = "App1", + JobName = "TestJobName", + JobArgs = "{ value: 1 }", + NextTryTime = _clock.Now.Subtract(TimeSpan.FromMinutes(1)), + Priority = BackgroundJobPriority.Normal, + IsAbandoned = false, + CompletionTime = _clock.Now, + CreationTime = _clock.Now.Subtract(TimeSpan.FromMinutes(2)), + TryCount = 1 + }, + autoSave: true); + + var waitingJobs = await _backgroundJobRepository.GetWaitingListAsync("App1", 100); + waitingJobs.ShouldNotContain(j => j.Id == completedJobId); + } + + [Fact] + public async Task Should_Delete_Old_Successful_Jobs_Of_The_Given_Application_Only() + { + var oldApp1Id = Guid.NewGuid(); + await _backgroundJobRepository.InsertAsync(NewCompletedJob(oldApp1Id, "App1", _clock.Now.Subtract(TimeSpan.FromDays(2))), autoSave: true); + + var recentApp1Id = Guid.NewGuid(); + await _backgroundJobRepository.InsertAsync(NewCompletedJob(recentApp1Id, "App1", _clock.Now), autoSave: true); + + var oldApp2Id = Guid.NewGuid(); + await _backgroundJobRepository.InsertAsync(NewCompletedJob(oldApp2Id, "App2", _clock.Now.Subtract(TimeSpan.FromDays(2))), autoSave: true); + + var deleted = await _backgroundJobRepository.DeleteAsync("App1", _clock.Now.Subtract(TimeSpan.FromDays(1)), 100); + deleted.ShouldBe(1); + + (await _backgroundJobRepository.FindAsync(oldApp1Id)).ShouldBeNull(); // App1 old completed → deleted + (await _backgroundJobRepository.FindAsync(recentApp1Id)).ShouldNotBeNull(); // App1 recent → kept + (await _backgroundJobRepository.FindAsync(oldApp2Id)).ShouldNotBeNull(); // App2 old → not touched (isolation) + } + + private BackgroundJobRecord NewCompletedJob(Guid id, string applicationName, DateTime completionTime) + { + return new BackgroundJobRecord(id) + { + ApplicationName = applicationName, + JobName = "TestJobName", + JobArgs = "{ value: 1 }", + NextTryTime = _clock.Now.Subtract(TimeSpan.FromMinutes(1)), + Priority = BackgroundJobPriority.Normal, + IsAbandoned = false, + CompletionTime = completionTime, + CreationTime = _clock.Now.Subtract(TimeSpan.FromMinutes(5)), + TryCount = 1 + }; + } } diff --git a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestData.cs b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestData.cs index 0d8b2bfd29..cd01e1b09a 100644 --- a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestData.cs +++ b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestData.cs @@ -8,4 +8,5 @@ public class BackgroundJobsTestData : ISingletonDependency public Guid JobId1 { get; } = Guid.NewGuid(); public Guid JobId2 { get; } = Guid.NewGuid(); public Guid JobId3 { get; } = Guid.NewGuid(); + public Guid JobId4 { get; } = Guid.NewGuid(); } diff --git a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestDataBuilder.cs b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestDataBuilder.cs index f6f4c37e12..47ebf103e2 100644 --- a/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestDataBuilder.cs +++ b/modules/background-jobs/test/Volo.Abp.BackgroundJobs.TestBase/Volo/Abp/BackgroundJobs/BackgroundJobsTestDataBuilder.cs @@ -67,5 +67,21 @@ public class BackgroundJobsTestDataBuilder : ITransientDependency TryCount = 2 } ); + + // App1 waiting job with a different job name, to verify job-name filtering. + await _backgroundJobRepository.InsertAsync( + new BackgroundJobRecord(_testData.JobId4) + { + ApplicationName = "App1", + JobName = "OtherJobName", + JobArgs = "{ value: 4 }", + NextTryTime = _clock.Now.Subtract(TimeSpan.FromMinutes(1)), + Priority = BackgroundJobPriority.Normal, + IsAbandoned = false, + LastTryTime = null, + CreationTime = _clock.Now.Subtract(TimeSpan.FromMinutes(3)), + TryCount = 0 + } + ); } } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavMenu.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavMenu.razor.cs index 8387850970..3fa39de6da 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavMenu.razor.cs +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavMenu.razor.cs @@ -27,7 +27,7 @@ public partial class NavMenu : IDisposable _ = InvokeAsync(async () => { Menu = await MenuManager.GetMainMenuAsync(); - StateHasChanged(); + await InvokeAsync(StateHasChanged); }); } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavToolbar.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavToolbar.razor index 91ded03c1e..bc62b71ca5 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavToolbar.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Web.MudBlazorBasicTheme/Themes/Basic/NavToolbar.razor @@ -49,7 +49,7 @@ _ = InvokeAsync(async () => { await GetToolbarItemRendersAsync(); - StateHasChanged(); + await InvokeAsync(StateHasChanged); }); } diff --git a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json index 546d308f72..2f5625c006 100644 --- a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json +++ b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json @@ -3,8 +3,8 @@ "name": "asp.net", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.shared": "~10.5.0", - "@abp/prismjs": "~10.5.0", - "@abp/highlight.js": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.shared": "~10.6.0", + "@abp/prismjs": "~10.6.0", + "@abp/highlight.js": "~10.6.0" } } diff --git a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock index 5ebf761d36..024b2b8a38 100644 --- a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock +++ b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock @@ -2,204 +2,204 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== - dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== + dependencies: + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/clipboard@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.5.0.tgz#17645cc5823daec941b3bd66a7ad85b14e42ee71" - integrity sha512-5mz/PommBkkhDQcrTCboCzFMu3kIOuWkcNnJXrwu7jCJACf+63fWui7yboXoW34RY+WBnXnYRQuWzWXomvG9ZQ== +"@abp/clipboard@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.6.0.tgz#7e11f8bc0a91134c858455fd5b03e26aa03eed81" + integrity sha512-I5RhdEZQ3Kh64bbZLcYMPZbP9uUG4KMz4FwsYXJCvn/bv1wJo2lQGlAWkZfqBl/cXgkTTTHy4kfADjIkjmnpRA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" clipboard "^2.0.11" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/highlight.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.5.0.tgz#4e411b4827481f116fd4b1ca4f567c075c52d44d" - integrity sha512-yaP38qMiAA/dcH/+yDiDhDgOPKi2+Y5VJsuAtOEaMDtbaLVcvpFB3vNCB10RlYTdpPCrSuzLUBkpEGXk/YAE0w== +"@abp/highlight.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.6.0.tgz#db52752d888de9af194c6925c77d44a4c1520b88" + integrity sha512-7fbG0TCb47PQG7ZDPvnsOFzyimo40zdzMb0uLvQu0Jx1V9SYWxGoSOZNMlzrYJsj6GN7QouQhMA6FKCh1fw2KA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@highlightjs/cdn-assets" "~11.11.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/prismjs@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.5.0.tgz#280164e3f1b4f82a545fcfdc5df6a7d79666889d" - integrity sha512-8VufRbaS3BFsfmnpi3hm6U6UiRynnMsU95QP8YAPGaTH7tJX+Pn0E3zTOFmskgUP7ZrLG9fFRIwukahYX0K7LQ== +"@abp/prismjs@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.6.0.tgz#e43a59b3d5a3b7e8d4ddea5f1837248270453717" + integrity sha512-5n9AvCMRek7AAmRTT6X30w9lONCafLGPvZupxh17dvdZyHHdxFO9NQvWwSYKdgQxPx8VKtUvfvTtYJ5yb+nfyw== dependencies: - "@abp/clipboard" "~10.5.0" - "@abp/core" "~10.5.0" + "@abp/clipboard" "~10.6.0" + "@abp/core" "~10.6.0" prismjs "^1.30.0" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json index 08d0eb053c..e225beaf82 100644 --- a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json +++ b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json @@ -3,8 +3,8 @@ "name": "asp.net", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0", - "@abp/prismjs": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0", + "@abp/prismjs": "~10.6.0" }, "devDependencies": {} } diff --git a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/yarn.lock b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/yarn.lock index 21334a71ac..fdf01320c8 100644 --- a/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/yarn.lock +++ b/modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/yarn.lock @@ -2,203 +2,203 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== - dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" - -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== - dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== + dependencies: + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" + +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== + dependencies: + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/clipboard@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.5.0.tgz#17645cc5823daec941b3bd66a7ad85b14e42ee71" - integrity sha512-5mz/PommBkkhDQcrTCboCzFMu3kIOuWkcNnJXrwu7jCJACf+63fWui7yboXoW34RY+WBnXnYRQuWzWXomvG9ZQ== +"@abp/clipboard@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.6.0.tgz#7e11f8bc0a91134c858455fd5b03e26aa03eed81" + integrity sha512-I5RhdEZQ3Kh64bbZLcYMPZbP9uUG4KMz4FwsYXJCvn/bv1wJo2lQGlAWkZfqBl/cXgkTTTHy4kfADjIkjmnpRA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" clipboard "^2.0.11" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/prismjs@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.5.0.tgz#280164e3f1b4f82a545fcfdc5df6a7d79666889d" - integrity sha512-8VufRbaS3BFsfmnpi3hm6U6UiRynnMsU95QP8YAPGaTH7tJX+Pn0E3zTOFmskgUP7ZrLG9fFRIwukahYX0K7LQ== +"@abp/prismjs@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.6.0.tgz#e43a59b3d5a3b7e8d4ddea5f1837248270453717" + integrity sha512-5n9AvCMRek7AAmRTT6X30w9lONCafLGPvZupxh17dvdZyHHdxFO9NQvWwSYKdgQxPx8VKtUvfvTtYJ5yb+nfyw== dependencies: - "@abp/clipboard" "~10.5.0" - "@abp/core" "~10.5.0" + "@abp/clipboard" "~10.6.0" + "@abp/core" "~10.6.0" prismjs "^1.30.0" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/blogging/app/Volo.BloggingTestApp/package.json b/modules/blogging/app/Volo.BloggingTestApp/package.json index 197911b8b6..4b611bbf00 100644 --- a/modules/blogging/app/Volo.BloggingTestApp/package.json +++ b/modules/blogging/app/Volo.BloggingTestApp/package.json @@ -3,7 +3,7 @@ "name": "volo.blogtestapp", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0", - "@abp/blogging": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0", + "@abp/blogging": "~10.6.0" } } diff --git a/modules/blogging/app/Volo.BloggingTestApp/yarn.lock b/modules/blogging/app/Volo.BloggingTestApp/yarn.lock index 501d74779b..6aa40385b9 100644 --- a/modules/blogging/app/Volo.BloggingTestApp/yarn.lock +++ b/modules/blogging/app/Volo.BloggingTestApp/yarn.lock @@ -2,229 +2,229 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== - dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" - -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== - dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== + dependencies: + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" + +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== + dependencies: + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/blogging@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/blogging/-/blogging-10.5.0.tgz#b920201fa33e2d46843cba894b0a0068235ea362" - integrity sha512-jVQWRjJuZ4NHJiOERX2A7t0ioNEAHf/2n8vP1vbZ5lLSO5Vxai9JTb2+mNa0+jHzWgNSIEZJ26WtJLhcHiyPqA== +"@abp/blogging@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/blogging/-/blogging-10.6.0.tgz#7506859c3f707c6dd4d1cfdd4470f47f44ae0b71" + integrity sha512-sOm9wtD/+bZOzL91S6hIavnLVQ2OZpQEFoZqABvdo43S3sdTEz8V8YigNBopXo0tqANqLa1fgsy8s9sicpNnqQ== dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" - "@abp/owl.carousel" "~10.5.0" - "@abp/prismjs" "~10.5.0" - "@abp/tui-editor" "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" + "@abp/owl.carousel" "~10.6.0" + "@abp/prismjs" "~10.6.0" + "@abp/tui-editor" "~10.6.0" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/clipboard@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.5.0.tgz#17645cc5823daec941b3bd66a7ad85b14e42ee71" - integrity sha512-5mz/PommBkkhDQcrTCboCzFMu3kIOuWkcNnJXrwu7jCJACf+63fWui7yboXoW34RY+WBnXnYRQuWzWXomvG9ZQ== +"@abp/clipboard@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.6.0.tgz#7e11f8bc0a91134c858455fd5b03e26aa03eed81" + integrity sha512-I5RhdEZQ3Kh64bbZLcYMPZbP9uUG4KMz4FwsYXJCvn/bv1wJo2lQGlAWkZfqBl/cXgkTTTHy4kfADjIkjmnpRA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" clipboard "^2.0.11" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/owl.carousel@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/owl.carousel/-/owl.carousel-10.5.0.tgz#8da4905782125bfa6fb78d254ebf907c59377a23" - integrity sha512-VMzP6r0Cz/IgJHuqNAjKmaX3/ik4TofGmwibvr8ln0L3DiILUC5b9/PVY0UFNy2C8VIajXDEjKNSKCVmIc9UUg== +"@abp/owl.carousel@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/owl.carousel/-/owl.carousel-10.6.0.tgz#1bbac0677be6b19ff893ab05d795b59c290dbc74" + integrity sha512-Jtyk9ZlhwPMe8AT9s50Izy/sO/+B5rGmf8vEDicJ2O3WjajWqGTf8jlATAZ/dTuuvHJLoTZ6aOSEOq5/btn+IQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" owl.carousel "^2.3.4" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/prismjs@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.5.0.tgz#280164e3f1b4f82a545fcfdc5df6a7d79666889d" - integrity sha512-8VufRbaS3BFsfmnpi3hm6U6UiRynnMsU95QP8YAPGaTH7tJX+Pn0E3zTOFmskgUP7ZrLG9fFRIwukahYX0K7LQ== +"@abp/prismjs@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.6.0.tgz#e43a59b3d5a3b7e8d4ddea5f1837248270453717" + integrity sha512-5n9AvCMRek7AAmRTT6X30w9lONCafLGPvZupxh17dvdZyHHdxFO9NQvWwSYKdgQxPx8VKtUvfvTtYJ5yb+nfyw== dependencies: - "@abp/clipboard" "~10.5.0" - "@abp/core" "~10.5.0" + "@abp/clipboard" "~10.6.0" + "@abp/core" "~10.6.0" prismjs "^1.30.0" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/tui-editor@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/tui-editor/-/tui-editor-10.5.0.tgz#51db96a4453a9b1ba0c507351e3d19e3e464f8ae" - integrity sha512-sNZX4jSIVDMcKcl9v8vf79WhrmLaTGjSCKG2MWyQnSc7pphfXlFLjX3MmwBzT6qMgPYUoQ0B1fuZv0Rni41zCg== +"@abp/tui-editor@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/tui-editor/-/tui-editor-10.6.0.tgz#c5a02bf6067336c3483419d0f60d23869cc8fe15" + integrity sha512-6aTzSVwlZB84ILtCsZPYkQP07X+mC1z8tcW2xKHzYImUfzTC+3Ex3KE7mjd2SSfNTfT9SXQ1Tn37Ig81C1GCMw== dependencies: - "@abp/jquery" "~10.5.0" - "@abp/prismjs" "~10.5.0" + "@abp/jquery" "~10.6.0" + "@abp/prismjs" "~10.6.0" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/client-simulation/demo/Volo.ClientSimulation.Demo/package.json b/modules/client-simulation/demo/Volo.ClientSimulation.Demo/package.json index c3ef40c2a7..79c59a49a5 100644 --- a/modules/client-simulation/demo/Volo.ClientSimulation.Demo/package.json +++ b/modules/client-simulation/demo/Volo.ClientSimulation.Demo/package.json @@ -3,6 +3,6 @@ "name": "client-simulation-web", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0" } } diff --git a/modules/client-simulation/demo/Volo.ClientSimulation.Demo/yarn.lock b/modules/client-simulation/demo/Volo.ClientSimulation.Demo/yarn.lock index 7593c33b6b..f1a2fef083 100644 --- a/modules/client-simulation/demo/Volo.ClientSimulation.Demo/yarn.lock +++ b/modules/client-simulation/demo/Volo.ClientSimulation.Demo/yarn.lock @@ -2,186 +2,186 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/cms-kit/angular/package.json b/modules/cms-kit/angular/package.json index 01362b6409..5805c54a18 100644 --- a/modules/cms-kit/angular/package.json +++ b/modules/cms-kit/angular/package.json @@ -15,11 +15,11 @@ }, "private": true, "dependencies": { - "@abp/ng.account": "~10.5.0", - "@abp/ng.identity": "~10.5.0", - "@abp/ng.setting-management": "~10.5.0", - "@abp/ng.tenant-management": "~10.5.0", - "@abp/ng.theme.basic": "~10.5.0", + "@abp/ng.account": "~10.6.0", + "@abp/ng.identity": "~10.6.0-rc.3", + "@abp/ng.setting-management": "~10.6.0", + "@abp/ng.tenant-management": "~10.6.0", + "@abp/ng.theme.basic": "~10.6.0", "@angular/animations": "~10.0.0", "@angular/common": "~10.0.0", "@angular/compiler": "~10.0.0", diff --git a/modules/cms-kit/angular/projects/cms-kit/package.json b/modules/cms-kit/angular/projects/cms-kit/package.json index cf57450363..4613568e19 100644 --- a/modules/cms-kit/angular/projects/cms-kit/package.json +++ b/modules/cms-kit/angular/projects/cms-kit/package.json @@ -4,8 +4,8 @@ "peerDependencies": { "@angular/common": "^9.1.11", "@angular/core": "^9.1.11", - "@abp/ng.core": ">=10.5.0", - "@abp/ng.theme.shared": ">=10.5.0" + "@abp/ng.core": ">=10.6.0", + "@abp/ng.theme.shared": ">=10.6.0" }, "dependencies": { "tslib": "^2.0.0" diff --git a/modules/cms-kit/host/Volo.CmsKit.IdentityServer/package.json b/modules/cms-kit/host/Volo.CmsKit.IdentityServer/package.json index dd25da54d0..314d4b703a 100644 --- a/modules/cms-kit/host/Volo.CmsKit.IdentityServer/package.json +++ b/modules/cms-kit/host/Volo.CmsKit.IdentityServer/package.json @@ -3,6 +3,6 @@ "name": "my-app-identityserver", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0" } } diff --git a/modules/cms-kit/host/Volo.CmsKit.IdentityServer/yarn.lock b/modules/cms-kit/host/Volo.CmsKit.IdentityServer/yarn.lock index 7593c33b6b..f1a2fef083 100644 --- a/modules/cms-kit/host/Volo.CmsKit.IdentityServer/yarn.lock +++ b/modules/cms-kit/host/Volo.CmsKit.IdentityServer/yarn.lock @@ -2,186 +2,186 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/cms-kit/host/Volo.CmsKit.Web.Host/package.json b/modules/cms-kit/host/Volo.CmsKit.Web.Host/package.json index da9ba552ec..6975891d92 100644 --- a/modules/cms-kit/host/Volo.CmsKit.Web.Host/package.json +++ b/modules/cms-kit/host/Volo.CmsKit.Web.Host/package.json @@ -3,6 +3,6 @@ "name": "my-app", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0" } } diff --git a/modules/cms-kit/host/Volo.CmsKit.Web.Host/yarn.lock b/modules/cms-kit/host/Volo.CmsKit.Web.Host/yarn.lock index 7593c33b6b..f1a2fef083 100644 --- a/modules/cms-kit/host/Volo.CmsKit.Web.Host/yarn.lock +++ b/modules/cms-kit/host/Volo.CmsKit.Web.Host/yarn.lock @@ -2,186 +2,186 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/cms-kit/host/Volo.CmsKit.Web.Unified/package.json b/modules/cms-kit/host/Volo.CmsKit.Web.Unified/package.json index f23395b70f..0d40595497 100644 --- a/modules/cms-kit/host/Volo.CmsKit.Web.Unified/package.json +++ b/modules/cms-kit/host/Volo.CmsKit.Web.Unified/package.json @@ -3,7 +3,7 @@ "name": "my-app", "private": true, "dependencies": { - "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0", - "@abp/cms-kit": "10.5.0" + "@abp/aspnetcore.mvc.ui.theme.basic": "~10.6.0", + "@abp/cms-kit": "10.6.0" } } diff --git a/modules/cms-kit/host/Volo.CmsKit.Web.Unified/yarn.lock b/modules/cms-kit/host/Volo.CmsKit.Web.Unified/yarn.lock index 84ece7c8ef..d7c7da8f57 100644 --- a/modules/cms-kit/host/Volo.CmsKit.Web.Unified/yarn.lock +++ b/modules/cms-kit/host/Volo.CmsKit.Web.Unified/yarn.lock @@ -2,298 +2,298 @@ # yarn lockfile v1 -"@abp/aspnetcore.mvc.ui.theme.basic@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.5.0.tgz#ea188c534693e8ab02f6892e7346c6b2a19591c1" - integrity sha512-dFudLJ5Q+vieAuS71KD26rxfhm1qvx3g53UcZv/jCNFsaqIoF8dgHIQTdpJx4qtkXEinfvWCXO5quNff/mTXvA== - dependencies: - "@abp/aspnetcore.mvc.ui.theme.shared" "~10.5.0" - -"@abp/aspnetcore.mvc.ui.theme.shared@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.5.0.tgz#59c9d3cc5566de18a2c71734ef8e36b6461b20de" - integrity sha512-a88SfpHDx2An6EGbCrIgDzZmuqfpkwc+lqBtllmQybhWrNjCnPSQzYJuwgQU5R6X+6HotVEpluK8Rv6FnQPS/w== - dependencies: - "@abp/aspnetcore.mvc.ui" "~10.5.0" - "@abp/bootstrap" "~10.5.0" - "@abp/bootstrap-datepicker" "~10.5.0" - "@abp/bootstrap-daterangepicker" "~10.5.0" - "@abp/datatables.net-bs5" "~10.5.0" - "@abp/font-awesome" "~10.5.0" - "@abp/jquery-validation-unobtrusive" "~10.5.0" - "@abp/lodash" "~10.5.0" - "@abp/luxon" "~10.5.0" - "@abp/malihu-custom-scrollbar-plugin" "~10.5.0" - "@abp/moment" "~10.5.0" - "@abp/select2" "~10.5.0" - "@abp/sweetalert2" "~10.5.0" - "@abp/timeago" "~10.5.0" - -"@abp/aspnetcore.mvc.ui@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.5.0.tgz#e6bf4e3f55c8f23095e6ae63d4f1b3aa5470ab2d" - integrity sha512-3fEzOCy5f/AMr8WypZqrfLaZPkZux4mMPtLhW8+LguC95UV14puqBE3nmxliN64GVFmYpSy6nWEfsT4AxB+nBA== +"@abp/aspnetcore.mvc.ui.theme.basic@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.basic/-/aspnetcore.mvc.ui.theme.basic-10.6.0.tgz#2eacaf2fbd3f59ff312f7bc299a3411aa6e9fab5" + integrity sha512-Ia9u4/MnQzUT4hxDP+rF9KtfjD5IwzyV5F2DRON4FEUjrZK65WKjFfFklGUPeY3sMeWsrBERrtVg2fAwPzIWQg== + dependencies: + "@abp/aspnetcore.mvc.ui.theme.shared" "~10.6.0" + +"@abp/aspnetcore.mvc.ui.theme.shared@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.6.0.tgz#8ee561db513a70d8fcbf6e42b8adc9be76febfcd" + integrity sha512-woEPzKF8v4xtarCDpNLcTOgYzYuV5VYtA6Z03bCATZEsOK5LM7tqT/HbkbSXyevkPqyQD50GcqgQ4a4y7Py7gQ== + dependencies: + "@abp/aspnetcore.mvc.ui" "~10.6.0" + "@abp/bootstrap" "~10.6.0" + "@abp/bootstrap-datepicker" "~10.6.0" + "@abp/bootstrap-daterangepicker" "~10.6.0" + "@abp/datatables.net-bs5" "~10.6.0" + "@abp/font-awesome" "~10.6.0" + "@abp/jquery-validation-unobtrusive" "~10.6.0" + "@abp/lodash" "~10.6.0" + "@abp/luxon" "~10.6.0" + "@abp/malihu-custom-scrollbar-plugin" "~10.6.0" + "@abp/moment" "~10.6.0" + "@abp/select2" "~10.6.0" + "@abp/sweetalert2" "~10.6.0" + "@abp/timeago" "~10.6.0" + +"@abp/aspnetcore.mvc.ui@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.6.0.tgz#3af39eab4a39f6168070ac943580c2d6e04726d3" + integrity sha512-1Ga+rEPxhcrJAXTiIZGdrjnrwfFcO760DeKLRylRZXygnzfXlyKo9hupszBMPm6bWRp6a/blfWzZ19s1GgDv0w== dependencies: ansi-colors "^4.1.3" -"@abp/bootstrap-datepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.5.0.tgz#4cad320259ef055d9702ffc1c3f972d9e0fe643c" - integrity sha512-Ic9icfBIzBTHa5i15hLHTyKB13OgSbg1iTeUDG10ciKrxRaXKtTXgFCngnL/7bEBYAd/PdeYGTCg4RUhqnWvtA== +"@abp/bootstrap-datepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.6.0.tgz#a4c469ed9180395ffbaef91cdf373adaf158105e" + integrity sha512-vSYpozkhK6fl+xa3e5wKJ4XPVETqVO68HfllPlP8B0+50jrFqyZXwAT9DeWdoOdzlDug67erWdH4zCbK5bFnuA== dependencies: bootstrap-datepicker "^1.10.1" -"@abp/bootstrap-daterangepicker@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.5.0.tgz#d1000a900fedd30602979f84fe97184ebf4316f9" - integrity sha512-zfFVeSCBZoSoatTrtjDVDKZ8TX8xzyzoPaGe+vskZH5uL8CgYJnmTy7RsRlGR79PFz7VKQ/zoyNXrufZKcho+w== +"@abp/bootstrap-daterangepicker@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.6.0.tgz#13dd661fe496cc144265db3b75d2185597d80d31" + integrity sha512-H8k1JgobHh2FtrNqFN82Zu96h53pZ/ymbpkPH5a6ujcdilsu08U0hie3UmrPqzx+YwNebzroNDWsVEvPzG4IWw== dependencies: - "@abp/moment" "~10.5.0" + "@abp/moment" "~10.6.0" bootstrap-daterangepicker "^3.1.0" -"@abp/bootstrap@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.5.0.tgz#a224a69d06a68e7f40c7d69be48b2a0b1c721e90" - integrity sha512-J2u16oMmi2XHfM0kP/joe+GHimyKlSGJ7cJcknNUOndEpxAX8f6WptIKmqNZv46x/3CzXsknM2AOAqGmX/gWzA== +"@abp/bootstrap@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.6.0.tgz#0c9beca3a264700656f71f8e1b814f0d0ba3d443" + integrity sha512-fuh/FVnHarEZEJ68loWMSKWwvzlxpEXGEedvyni+zUmgUNU3sjB6zt2nX5eBsVIVOAjMGO8LARCqWlYx2kbXqw== dependencies: - "@abp/core" "~10.5.0" - "@abp/popper.js" "~10.5.0" + "@abp/core" "~10.6.0" + "@abp/popper.js" "~10.6.0" bootstrap "^5.3.8" -"@abp/clipboard@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.5.0.tgz#17645cc5823daec941b3bd66a7ad85b14e42ee71" - integrity sha512-5mz/PommBkkhDQcrTCboCzFMu3kIOuWkcNnJXrwu7jCJACf+63fWui7yboXoW34RY+WBnXnYRQuWzWXomvG9ZQ== +"@abp/clipboard@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.6.0.tgz#7e11f8bc0a91134c858455fd5b03e26aa03eed81" + integrity sha512-I5RhdEZQ3Kh64bbZLcYMPZbP9uUG4KMz4FwsYXJCvn/bv1wJo2lQGlAWkZfqBl/cXgkTTTHy4kfADjIkjmnpRA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" clipboard "^2.0.11" -"@abp/cms-kit.admin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/cms-kit.admin/-/cms-kit.admin-10.5.0.tgz#745195541a7b79fd5697a00b6baf98107335f1d7" - integrity sha512-/oS1ymT1UuOYQRRg8owFoMxc359HtgSrXwUFoCEOnd3k6jMlb32+n936PNNss689rXA5Q/xqKjV+s8gcpO9ibQ== +"@abp/cms-kit.admin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/cms-kit.admin/-/cms-kit.admin-10.6.0.tgz#e5a5a0159fe7cc7c8c7ed6561e58aa617e55a3ab" + integrity sha512-+g1BMf/uACbQ2rcIwpnRnWPvewPo7/0wrBdEXPXkkm0Dfi+WJQ7mlz1MegC2n1rtk2qpmiVG7CJ/KySzCeekvg== dependencies: - "@abp/codemirror" "~10.5.0" - "@abp/jstree" "~10.5.0" - "@abp/markdown-it" "~10.5.0" - "@abp/slugify" "~10.5.0" - "@abp/tui-editor" "~10.5.0" - "@abp/uppy" "~10.5.0" + "@abp/codemirror" "~10.6.0" + "@abp/jstree" "~10.6.0" + "@abp/markdown-it" "~10.6.0" + "@abp/slugify" "~10.6.0" + "@abp/tui-editor" "~10.6.0" + "@abp/uppy" "~10.6.0" -"@abp/cms-kit.public@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/cms-kit.public/-/cms-kit.public-10.5.0.tgz#80bad1dbc1d6e133189310d9ba917e9dae750244" - integrity sha512-A/CWfQTCA7lPLTJVf1rceC8GRmmSSVkDfXsbOesHwR0p8dnWe40afyNtqZViSl26AJ2eMZxV90dMNr/SMRqjJA== +"@abp/cms-kit.public@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/cms-kit.public/-/cms-kit.public-10.6.0.tgz#9ac9c6bc699ae1110da8a563680bbd7a04293db5" + integrity sha512-12qIddnHgYDqCpNjBzarqv7y+Xmm6At3rZnMo76bOneNnbFWOv92iZHpM+LDm5kz2aofehNxCjIkBSu6B7LI+A== dependencies: - "@abp/highlight.js" "~10.5.0" - "@abp/star-rating-svg" "~10.5.0" + "@abp/highlight.js" "~10.6.0" + "@abp/star-rating-svg" "~10.6.0" -"@abp/cms-kit@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/cms-kit/-/cms-kit-10.5.0.tgz#d496d169320cb1eb2eb610713728837c6abfac6a" - integrity sha512-KxZ4Ku4yIfsTN+H2bzdnoc7v6yZFUgE+07TGlhV4cPHCv1jbx1QnwxFWblDjTLW5sUnBPEM9o0+Hj0hMxTBGzA== +"@abp/cms-kit@10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/cms-kit/-/cms-kit-10.6.0.tgz#1b2b952a2c25875b78e690841dd8f9e0497eeaad" + integrity sha512-jJCA4/vLZ5aqf6Agzj2kcAZ6P1rwziTbC2pkauVo+l9NsydI1AwWNhD4as9VYV3vfAvV+ykZJ+ybeLyH/RKwCw== dependencies: - "@abp/cms-kit.admin" "~10.5.0" - "@abp/cms-kit.public" "~10.5.0" + "@abp/cms-kit.admin" "~10.6.0" + "@abp/cms-kit.public" "~10.6.0" -"@abp/codemirror@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/codemirror/-/codemirror-10.5.0.tgz#446ecef4b829ba1797eb992697ac071ab79673e0" - integrity sha512-tM2kSFiSbLcwHJ7bHUPtKl908JUNpk3OPyST46f1/U7EjVKV45lipuLxOVM+1/XxD6poKaA0e91r0xgXzuGMUg== +"@abp/codemirror@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/codemirror/-/codemirror-10.6.0.tgz#7869833a4dd10ecac7b847bcbfb87ea426bd3f65" + integrity sha512-25XGyTJn8xAVmWI0j4E/OoQGD4FKA2vckZlSQqC0nI6dfPc1DN4lB4Eb4UhDykpyU3Yu29+bFC0jmAmBTktVqg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@codemirror/lang-css" "^6.0.0" "@codemirror/lang-javascript" "^6.0.0" "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.0.0" codemirror "^6.0.2" -"@abp/core@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.5.0.tgz#58364410c1df45ea5bb82c08e03141d8294e61e7" - integrity sha512-RNflX2ENTMunO3MZlMx2EZu7sqHUray2lAfkUotjNLIbz0h3btb4zoMU3W5Vy/WPhWRT7Ry7FtnUCdOK+9tNkg== +"@abp/core@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.6.0.tgz#604018588e331db3e40d2179c6232b6918cc640e" + integrity sha512-iF175HkoSmD6j6qhdhDA/m5kd80YrdW06T+xsl/xo9K4U5zbpu3hR7uq1hpt8+CqBh6IYDsuaU6EB/b1MuUItg== dependencies: - "@abp/utils" "~10.5.0" + "@abp/utils" "~10.6.0" -"@abp/datatables.net-bs5@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.5.0.tgz#21b0e2ae9c78473980dedf89a07fdcb72f31bee2" - integrity sha512-j2e48mBy+oZ2y+t8aLQ4iyx0XbN5QcGjTM+CBR9KnttY/w+KD2SRngdZbOdhdDI5W3IAdH5WTwmw2EyZXmf63w== +"@abp/datatables.net-bs5@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.6.0.tgz#af9b04a47eb8232c48df1cfb06c7e36c63303acf" + integrity sha512-2ci6GRll71ZOA/Qr8+w3LmmbDPuBySC0uZSCCw+MnRexRBoM5/vwe9Sy3O30XaAZzaefqcCy11UF8ZsUSAhFsA== dependencies: - "@abp/datatables.net" "~10.5.0" + "@abp/datatables.net" "~10.6.0" datatables.net-bs5 "^2.3.4" -"@abp/datatables.net@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.5.0.tgz#d569ae83696bc25a0802ce4b90105ecebbb602eb" - integrity sha512-pqyOUjEtO3glFnBXd8O4uHRrQ7uwmk91ypOyFVCEX7L6Ksg3nRonoaBqPINUxFGOBs97h2nhICYZT9Lblp+4Sg== +"@abp/datatables.net@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.6.0.tgz#5d438004468dbc22f01eeea89ebfdedee5c7ff6b" + integrity sha512-HDJ9yt7uFAM5z/a/kjiFixssdT/EJ1w2hO5lfY43qjAqL5dfFrUFqN9Cb4pkrdbryr4GAD4U8uW+zZCv1UT3gQ== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" datatables.net "^2.3.4" -"@abp/font-awesome@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.5.0.tgz#44347dae9d00fc20cde8fcd2cfb8cb1f58055ae2" - integrity sha512-on3fNBdAzTvVsjDtF3NgLIEm/FBPwdDnAwhFmkqioLmpF6iPnZeW4bAFlC3dsoEGrWiCvqERir6/ckmpYLA5zg== +"@abp/font-awesome@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.6.0.tgz#9043c1877101c126596dc1ac3153033d65ca375e" + integrity sha512-ZvqnVuEBxxSvpo1fM5M33nh3+xb/hBxrNLYvYmAJjqPgWRrHUXjXi1sUK6s+UV8p5SKkZ/gEyyO2Hq+PxLPHkA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@fortawesome/fontawesome-free" "^7.0.1" -"@abp/highlight.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.5.0.tgz#4e411b4827481f116fd4b1ca4f567c075c52d44d" - integrity sha512-yaP38qMiAA/dcH/+yDiDhDgOPKi2+Y5VJsuAtOEaMDtbaLVcvpFB3vNCB10RlYTdpPCrSuzLUBkpEGXk/YAE0w== +"@abp/highlight.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.6.0.tgz#db52752d888de9af194c6925c77d44a4c1520b88" + integrity sha512-7fbG0TCb47PQG7ZDPvnsOFzyimo40zdzMb0uLvQu0Jx1V9SYWxGoSOZNMlzrYJsj6GN7QouQhMA6FKCh1fw2KA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@highlightjs/cdn-assets" "~11.11.1" -"@abp/jquery-validation-unobtrusive@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.5.0.tgz#426e89a28c353f10ea26cd850818b2daa3519c81" - integrity sha512-XF3GO/lHCOqh3wYwyItNccVE1Jw8tGKgNu0fga+QwJr6O1xI8YnbD00tvrMkSnRM3UsxL5Ty6E2AkYn1g+At5A== +"@abp/jquery-validation-unobtrusive@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.6.0.tgz#999cf3b0137f72b101173ffafbd7241f9c4d72c7" + integrity sha512-PyydzHctaN1veYLX/Iq5w1jkIC+pJaNYXCdp/KpNj41cNrNWUmtOR5GyJaY3oyEi3K7FLhspVTrHqdLfdGm4Lw== dependencies: - "@abp/jquery-validation" "~10.5.0" + "@abp/jquery-validation" "~10.6.0" jquery-validation-unobtrusive "^4.0.0" -"@abp/jquery-validation@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.5.0.tgz#19c7b057cd3f08a9e85b180843dddf8a19a2b9d8" - integrity sha512-PcLB4muI6XJkzWCVR+0T5DP44qcggeI3OF/soJBba5rrYQ5Bd7WqcNJdIg9SQK3OEzpcgwDCns5Nxt4j88fH2Q== +"@abp/jquery-validation@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.6.0.tgz#c53f5b04f1f7852130698d91f3896009a428be70" + integrity sha512-smXaken4ta5VPlD3qRtTHMdSrJlr7ARCm5bfJkFYucaiffuyPWW3t75/OmdNDLVKIzeQNDcjX8damuKVaEpYpA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jquery-validation "^1.21.0" -"@abp/jquery@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.5.0.tgz#4fce1128f95351678c47cf3de2d6b407260d73d0" - integrity sha512-uT79LFWW9jgyII0QxYZkZj2h6plkrwJFNocsGcafoY6hZ0bOUyxpXa8YGwEe0A+j1d5iBl08SH3k2HWu4TpJ6w== +"@abp/jquery@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.6.0.tgz#86247d1c70d9c241c25f1aac5ac45b0f83acfff8" + integrity sha512-9BXhgoF2Je4/oP/XqrQSNA88tHl/nrTpgI6FS4mHvi1yCaK3n4IkpnUIry76UY32w4wy3M/edNWM1AcwJ93lJg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" jquery "~3.7.1" -"@abp/jstree@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/jstree/-/jstree-10.5.0.tgz#51526f49b48f99866a62695750a395cae0457c79" - integrity sha512-w5aMEH8NFywCtqrWuG8Zr3lqvINZSp5ehhOxVJ7x4nnBQvHe0mScZiDnPcg8xVUL+bEDJ1yAxkFoI+f/008/sQ== +"@abp/jstree@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/jstree/-/jstree-10.6.0.tgz#8d479ce85d120047fac6efd0f6d113e4e4f6b6b9" + integrity sha512-KvxaFmrYPP9+6X6AfVRVkESdG93R/mUxsiTtpFwI32UwyRotEbkznBaPragFPfFZXbQzv4NMmGe+GKeCyqNSaA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" jstree "^3.3.17" -"@abp/lodash@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.5.0.tgz#852d6cb369ff478d35345e7c17e222587a6b11d0" - integrity sha512-Z2GwsL4aLA9wxgP1j8youSlq7glEpe7Mu3XFT/e+w2qyKxyEZtzSzhPF/2RMaAPw8fuFBdu7r1Dhz1Va1RVheg== +"@abp/lodash@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.6.0.tgz#37005bc5fd9f5e600274482bea06323246b1eef3" + integrity sha512-IXwfRqF0fDJG+W32tneDurjx4ddlvRON3lUj7YEq7zlap0byoK9qbXJPAKyMIILE1Jqq6xm95RvyNa/M0xCqbA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" lodash "^4.18.1" -"@abp/luxon@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.5.0.tgz#1843a4061a791e23837a89e98624b20dad9e3b70" - integrity sha512-CDmtfGRLREQIQ6roJjw/WuBlkvSdaPtp6qHNEFIp0vCrppQZLeMqwxmVVMaaEPtimsAmQ8vpeLouWNrJsCuCzQ== +"@abp/luxon@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.6.0.tgz#613943060d438547bef6b843813e723315e4f97a" + integrity sha512-mU1QLcff3Q+rQ9kZ7fpPnc6r7pPcjYwBd9nHXiEQOIzFIPLXjnLC9OEZ4PuoHvBkYEV4mOTgdiCunU0KKqERiA== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" luxon "^3.7.2" -"@abp/malihu-custom-scrollbar-plugin@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.5.0.tgz#ec48d62548eaa92d5340c2cfab9a85e1160530c1" - integrity sha512-hWV8AktNDhnfwqrgYn+RSDqymgsSrcXWPdtfVns6KRk2niIVVNYzu28/CzTu7+BxSBkecAD0s9LhC4LeqYoV2w== +"@abp/malihu-custom-scrollbar-plugin@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.6.0.tgz#118436a2a643938c1432b0e11c9601fc02111997" + integrity sha512-0apbAT6UeDo8yuC72L/p9YfttP2xia9JdLO45/9iqDZ2eDW2YX4aBoDNwhh/SYTxA6xvqaZhKnUbjCVnPqVZdQ== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" malihu-custom-scrollbar-plugin "^3.1.5" -"@abp/markdown-it@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/markdown-it/-/markdown-it-10.5.0.tgz#fe67da4eb4005d5eee8a93013676095c4ff19a6f" - integrity sha512-2M0Gciz403dCD/BB1plVuZ3YHd2aiM3RYMH4+Gpa5koKwSB38aMJU1OdukPcMww1Oka14mM+Rev5aTvpdd8GKQ== +"@abp/markdown-it@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/markdown-it/-/markdown-it-10.6.0.tgz#1c1df9c1179aaa2bf93effa7b5de7fa90206ef7f" + integrity sha512-Yva/zHp4OcHLW3VQIpf1WHHDk8NfDDRc0s2IPQL33QdjphTMJvD4YyCNG6gbZZcuIwUaPi8Ics8hwX8gFB/IEw== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" markdown-it "^14.1.0" -"@abp/moment@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.5.0.tgz#2ccd8c1fe4ef1ff084c86e984d5932a9cf4a94fe" - integrity sha512-DTEA4pR3p5NivmqaEPgnjv3ogM23DyvgvNBe/79Xogem3ERML6KBph9vKYeB2MPOI5pSLNVAsMNhG9m6Ial8TA== +"@abp/moment@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.6.0.tgz#54049c3be271e1e40c630d2e66889dd2b939361c" + integrity sha512-SJZELm6DjDRJuxcZ+XhxoSEREPmCCv6uKWQoSJMIlclu513tSOLcus8GZoShAll28/SjIVsvDEhJfHDULeRa4Q== dependencies: moment "^2.30.1" -"@abp/popper.js@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.5.0.tgz#316bd9a5b1179babc7603662068bff08d510f832" - integrity sha512-+TwEmXxQF/sf2Pj2UVyRPyrMUmKc0HCSDzsLO89jSj4Ln+7gXgRzYo49Fuu816kJL6j/i+sXcq7BZOAEDc4X6w== +"@abp/popper.js@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.6.0.tgz#11b1b3256d8edbc0dcb89aacdb64c6378aedfa6c" + integrity sha512-y9YNhTi8LMcFxhvzax1RFBzOw2KIwS61xp9VwAuMqAB1DeD4glp7itFFgSAREMtxlNeBVS/FVJxskloOmBTv+A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" "@popperjs/core" "^2.11.8" -"@abp/prismjs@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.5.0.tgz#280164e3f1b4f82a545fcfdc5df6a7d79666889d" - integrity sha512-8VufRbaS3BFsfmnpi3hm6U6UiRynnMsU95QP8YAPGaTH7tJX+Pn0E3zTOFmskgUP7ZrLG9fFRIwukahYX0K7LQ== +"@abp/prismjs@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.6.0.tgz#e43a59b3d5a3b7e8d4ddea5f1837248270453717" + integrity sha512-5n9AvCMRek7AAmRTT6X30w9lONCafLGPvZupxh17dvdZyHHdxFO9NQvWwSYKdgQxPx8VKtUvfvTtYJ5yb+nfyw== dependencies: - "@abp/clipboard" "~10.5.0" - "@abp/core" "~10.5.0" + "@abp/clipboard" "~10.6.0" + "@abp/core" "~10.6.0" prismjs "^1.30.0" -"@abp/select2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.5.0.tgz#78abd9211863a214fe15b3fb76bfea8333cab02d" - integrity sha512-bqX6lUYRewSfsABhlkbf7A02ulz4oeit4ap+OCdgFa9yE75hHOzYBVHppQgCAHIdVcxw+XxOkejL6VEgM0EVRg== +"@abp/select2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.6.0.tgz#e2693b693a2f828f0291c76f8e446b923cd7a7d6" + integrity sha512-uwJP+A2uBUFirvujBcBe//pKDGt/qm+5A8ZkV4f24sErV/uZifC3sTBHB3smFH8tcOI5uGyILl7u+MW6ztzJ1A== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" select2 "^4.0.13" -"@abp/slugify@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/slugify/-/slugify-10.5.0.tgz#b49ef6f94c37d86508cf86c3ee0e80bb971dc930" - integrity sha512-Dol2IQhFetn9g0DDm/Yagv4ac7UtLg4eKMBAYnMhBdT5VnfDhHo7VG7QjpL4McwRpPh4IInmZyhAIIx771eTmQ== +"@abp/slugify@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/slugify/-/slugify-10.6.0.tgz#d1bc287cc709af1f7e628868f2a95366f4ab9560" + integrity sha512-sQAiszUHst3clMZ1Y2so9sKDzMS09dJmDOFAA265YC5PWu8VZPjnVf7VrJkM8qj/lpmrW62BxvqQ8Ess/czkCg== dependencies: slugify "^1.6.6" -"@abp/star-rating-svg@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/star-rating-svg/-/star-rating-svg-10.5.0.tgz#6a8121b83b53b9d748fc6054154774bc27fe71f8" - integrity sha512-ss2+r1QgavsgmsJN9CIypMpS3T/P13ZewQuLr7UXvGE1U29OSq0KgqDgX/1aWcBHY3Tda/+oQGUu/pMKcwRBwQ== +"@abp/star-rating-svg@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/star-rating-svg/-/star-rating-svg-10.6.0.tgz#4b21f22a79c4609501f44fd0206a9c56f29829a9" + integrity sha512-E00Py7WgJu/CRcllYPEZy3JdmQeoL3YRtlz7w8oHQbIPCkG/dT2WnoZP/EyQA4cpUIZuFwaCd8lBCdUFNTlnRA== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" star-rating-svg "^3.5.0" -"@abp/sweetalert2@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" - integrity sha512-5sjDIXsQ3d27qO8eRPpkrFXqnBeWuswlBGUhNS5SnRA0K5Q6IzVUVTYSbo7+ulQ3nWuZbv5DWSB4xujjzShwXg== +"@abp/sweetalert2@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.6.0.tgz#c5c49adc4aaee4cdc8f6e068c84f911b05001153" + integrity sha512-RtgG1aSGF8NOzBmR7PX9Sa36UfSMMvDl9n7dNWIeDU0cxlEj5IQBWxjtFk1ssOVRoHza8sZbEqn7vs3PkWQqyg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" sweetalert2 "^11.23.0" -"@abp/timeago@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.5.0.tgz#11e646bd69a1a6093b58a3100ea7ef8639339fb9" - integrity sha512-9qEKBdW6HhpOjDaexLdPSuJ/lag/jE01yuRJo8Qj62tjnEqzMQ8QaFdqR5YN0NiADyY7phvnHCmimPKUM5hmJw== +"@abp/timeago@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.6.0.tgz#8ea8255b4ca480a180ae21a6cd037b2446e7fadb" + integrity sha512-IXK3bfqf7IQqqJy7cFffwVdH4eKyvU8tK2XNVLyAuhQLu60OnxP8EoICc8dfDdEl6XgOB28H3QsVzxutRaz9Ew== dependencies: - "@abp/jquery" "~10.5.0" + "@abp/jquery" "~10.6.0" timeago "^1.6.7" -"@abp/tui-editor@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/tui-editor/-/tui-editor-10.5.0.tgz#51db96a4453a9b1ba0c507351e3d19e3e464f8ae" - integrity sha512-sNZX4jSIVDMcKcl9v8vf79WhrmLaTGjSCKG2MWyQnSc7pphfXlFLjX3MmwBzT6qMgPYUoQ0B1fuZv0Rni41zCg== +"@abp/tui-editor@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/tui-editor/-/tui-editor-10.6.0.tgz#c5a02bf6067336c3483419d0f60d23869cc8fe15" + integrity sha512-6aTzSVwlZB84ILtCsZPYkQP07X+mC1z8tcW2xKHzYImUfzTC+3Ex3KE7mjd2SSfNTfT9SXQ1Tn37Ig81C1GCMw== dependencies: - "@abp/jquery" "~10.5.0" - "@abp/prismjs" "~10.5.0" + "@abp/jquery" "~10.6.0" + "@abp/prismjs" "~10.6.0" -"@abp/uppy@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/uppy/-/uppy-10.5.0.tgz#518dfc38f7c51cf8b80eee32f482c659848963e6" - integrity sha512-QyFZ7X949wWn5vIn4Xno7WXfba2iuDlFzwM4CtW0bwaflf3I+ndstqJo/hEvMZE1Joc7lSbD4GMcauKx8ygoUA== +"@abp/uppy@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/uppy/-/uppy-10.6.0.tgz#1ebf7262eed35f5e2d056a9a052d11f6e141f37e" + integrity sha512-wz9tWBO/PEF5xbsHeO0xvQpjPKdjC/7iOTx05QrWYVWSzRVdgAGyjOpn8kko0cLvLjoJKavm5Ur6TlOQR36vdg== dependencies: - "@abp/core" "~10.5.0" + "@abp/core" "~10.6.0" uppy "^5.1.2" -"@abp/utils@~10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.5.0.tgz#d0c5cb23b2ad9c74fb5e0b9ecfa4fe96bbd3240a" - integrity sha512-00mcBklr6+/4HzmR1BjWSupUPjsjrzcPXsuWyJH9ylyLfaIEh13uzHqwpw3goCNsAf1NU9MaOdqlIbGAaNB9cA== +"@abp/utils@~10.6.0": + version "10.6.0" + resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.6.0.tgz#6ff03d75b7b22a1e83b7a0149a3f6ff54bf30b69" + integrity sha512-dQaHPKNKcsyeyCot28UOwOhU5ykS6tqVVNXqRvQrrQIvqj5tOAGtWMeXRP1eP47JRCmF4kOgOSZcMvVnuoUrUQ== dependencies: just-compare "^2.3.0" diff --git a/modules/cms-kit/src/Volo.CmsKit.Admin.Web/CmsKitAdminWebMappers.cs b/modules/cms-kit/src/Volo.CmsKit.Admin.Web/CmsKitAdminWebMappers.cs index 99b07a65fc..fe9dadfd66 100644 --- a/modules/cms-kit/src/Volo.CmsKit.Admin.Web/CmsKitAdminWebMappers.cs +++ b/modules/cms-kit/src/Volo.CmsKit.Admin.Web/CmsKitAdminWebMappers.cs @@ -19,8 +19,8 @@ public partial class BlogFeatureInputDtoToBlogFeatureViewModelMapper : TwoWayMap public override partial FeaturesModalModel.BlogFeatureViewModel Map(BlogFeatureInputDto source); public override partial void Map(BlogFeatureInputDto source, FeaturesModalModel.BlogFeatureViewModel destination); - public override partial BlogFeatureInputDto ReverseMap(FeaturesModalModel.BlogFeatureViewModel destination); - public override partial void ReverseMap(FeaturesModalModel.BlogFeatureViewModel destination, BlogFeatureInputDto source); + public override partial BlogFeatureInputDto ReverseMap(FeaturesModalModel.BlogFeatureViewModel source); + public override partial void ReverseMap(FeaturesModalModel.BlogFeatureViewModel source, BlogFeatureInputDto destination); } [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] @@ -29,8 +29,8 @@ public partial class BlogFeatureDtoToBlogFeatureViewModelMapper : TwoWayMapperBa public override partial FeaturesModalModel.BlogFeatureViewModel Map(BlogFeatureDto source); public override partial void Map(BlogFeatureDto source, FeaturesModalModel.BlogFeatureViewModel destination); - public override partial BlogFeatureDto ReverseMap(FeaturesModalModel.BlogFeatureViewModel destination); - public override partial void ReverseMap(FeaturesModalModel.BlogFeatureViewModel destination, BlogFeatureDto source); + public override partial BlogFeatureDto ReverseMap(FeaturesModalModel.BlogFeatureViewModel source); + public override partial void ReverseMap(FeaturesModalModel.BlogFeatureViewModel source, BlogFeatureDto destination); } [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] @@ -39,8 +39,8 @@ public partial class CreateBlogPostDtoToCreateBlogPostViewModelMapper : TwoWayMa public override partial CreateModel.CreateBlogPostViewModel Map(CreateBlogPostDto source); public override partial void Map(CreateBlogPostDto source, CreateModel.CreateBlogPostViewModel destination); - public override partial CreateBlogPostDto ReverseMap(CreateModel.CreateBlogPostViewModel destination); - public override partial void ReverseMap(CreateModel.CreateBlogPostViewModel destination, CreateBlogPostDto source); + public override partial CreateBlogPostDto ReverseMap(CreateModel.CreateBlogPostViewModel source); + public override partial void ReverseMap(CreateModel.CreateBlogPostViewModel source, CreateBlogPostDto destination); } [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] @@ -49,8 +49,8 @@ public partial class TagUpdateDtoToTagEditViewModelMapper : TwoWayMapperBase", - "namespace": "Volo.Docs.TableOfContents", - "name": "GenerateTocHeadings", - "summary": null, - "isAsync": false, - "isPublic": true, - "isPrivate": false, - "isStatic": false, - "parameters": [ - { - "type": "String", - "name": "markdownContent", - "isOptional": false - } - ] - }, - { - "returnType": "List", - "namespace": "Volo.Docs.TableOfContents", - "name": "GenerateTocItems", - "summary": null, - "isAsync": false, - "isPublic": true, - "isPrivate": false, - "isStatic": false, - "parameters": [ - { - "type": "List", - "name": "tocHeadings", - "isOptional": false - }, - { - "type": "Int32", - "name": "topLevel", - "isOptional": false - }, - { - "type": "Int32", - "name": "levelCount", - "isOptional": false - } - ] - }, - { - "returnType": "Int32", - "namespace": "Volo.Docs.TableOfContents", - "name": "GetTopLevel", - "summary": null, - "isAsync": false, - "isPublic": true, - "isPrivate": false, - "isStatic": false, - "parameters": [ - { - "type": "List", - "name": "headings", - "isOptional": false - } - ] - }, - { - "returnType": "List", - "namespace": "Volo.Docs.TableOfContents", - "name": "GenerateTocItems", - "summary": null, - "isAsync": false, - "isPublic": true, - "isPrivate": false, - "isStatic": false, - "parameters": [ - { - "type": "String", - "name": "markdownContent", - "isOptional": false - }, - { - "type": "Int32", - "name": "levelCount", - "isOptional": false - }, - { - "type": "Nullable", - "name": "topLevel", - "isOptional": true - } - ] - } - ], - "contentType": "applicationService", - "name": "TocGeneratorService", - "summary": null - }, { "namespace": "Volo.Docs.Documents", "baseClass": { diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs index 8d301395c0..3a770e6781 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs @@ -173,7 +173,7 @@ namespace Volo.Docs.GitHub.Documents { if (commits == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } var gitHubCommit = isFirstCommit ? @@ -182,20 +182,20 @@ namespace Volo.Docs.GitHub.Documents if (gitHubCommit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit.Author == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } private async Task> GetGitHubCommitsOrNull(Project project, string documentName, string languageCode, string version) @@ -230,8 +230,8 @@ namespace Volo.Docs.GitHub.Documents var fileCommitsAfterCreation = commits.Take(commits.Count - 1); var commitsToEvaluate = (lastKnownSignificantUpdateTime != null - ? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.DateTime > lastKnownSignificantUpdateTime) - : fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.DateTime > DateTime.Now.AddDays(-14)); + ? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.UtcDateTime > lastKnownSignificantUpdateTime) + : fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.UtcDateTime > DateTime.UtcNow.AddDays(-14)); foreach (var gitHubCommit in commitsToEvaluate) { @@ -243,7 +243,7 @@ namespace Volo.Docs.GitHub.Documents if (_githubPatchAnalyzer.HasPatchSignificantChanges(fullCommit.Files.First(f => f.Filename == fileName).Patch)) { - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } } diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ar.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ar.json index f7d3533653..38b56e96b3 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ar.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ar.json @@ -29,6 +29,7 @@ "FullSearch": "ابحث في المستندات", "Volo.Docs.Domain:010001": "لم يتم تمكين البحث المرن.", "MultipleVersionDocumentInfo": "هذا المستند له إصدارات متعددة. حدد الخيارات الأنسب لك.", + "DocumentOptions": "خيارات المستند", "New": "جديد", "Upd": "مُحدث", "NewExplanation": "تم إنشاؤه في الأسبوعين الماضيين.", @@ -41,4 +42,4 @@ "GoogleTranslate": "ترجمة جوجل", "DownloadPDF": "تحميل PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/cs.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/cs.json index ed6c8767b8..a4a16fc883 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/cs.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/cs.json @@ -29,6 +29,7 @@ "FullSearch": "Hledat v dokumentech", "Volo.Docs.Domain:010001": "Elastické vyhledávání není povoleno.", "MultipleVersionDocumentInfo": "Tento dokument má více verzí. Vyberte možnosti, které vám nejlépe vyhovují.", + "DocumentOptions": "Možnosti dokumentu", "New": "Nové", "Upd": "Akt", "NewExplanation": "Vytvořeno v posledních dvou týdnech.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Přeložit do češtiny", "DownloadPDF": "Stáhnout PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/de.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/de.json index 6a614441eb..10ff142b20 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/de.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/de.json @@ -29,6 +29,7 @@ "FullSearch": "In Dokumenten suchen", "Volo.Docs.Domain:010001": "Elastic search ist nicht aktiviert.", "MultipleVersionDocumentInfo": "Dieses Dokument hat mehrere Versionen. Wählen Sie die für Sie am besten geeigneten Optionen aus.", + "DocumentOptions": "Dokumentoptionen", "New": "Neu", "Upd": "Upd", "NewExplanation": "Erstellt in den letzten zwei Wochen.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Ins Deutsche übersetzen", "DownloadPDF": "PDF herunterladen" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/el.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/el.json index 2a1a72dca2..e2c8ce222e 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/el.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/el.json @@ -29,6 +29,7 @@ "FullSearch": "Αναζήτηση σε έγγραφα", "Volo.Docs.Domain:010001": "Η ελαστική αναζήτηση δεν είναι ενεργοποιημένη.", "MultipleVersionDocumentInfo": "Αυτό το έγγραφο έχει πολλές εκδόσεις. Επιλέξτε τις επιλογές που σας ταιριάζουν καλύτερα.", + "DocumentOptions": "Επιλογές εγγράφου", "New": "Νέο", "Upd": "Επικαιροποιημένο", "NewExplanation": "Δημιουργήθηκε τις τελευταίες δύο εβδομάδες", @@ -40,4 +41,4 @@ "GoogleTranslate": "Μετάφραση στα ελληνικά", "DownloadPDF": "Κατέβασμα PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en-GB.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en-GB.json index d56e8da6df..884d9ce8ba 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en-GB.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en-GB.json @@ -29,6 +29,7 @@ "FullSearch": "Search in documents", "Volo.Docs.Domain:010001": "Elastic search is not enabled.", "MultipleVersionDocumentInfo": "This document has multiple versions. Select the options that best fit you.", + "DocumentOptions": "Document Options", "New": "New", "Upd": "Upd", "NewExplanation": "Created in the last two weeks.", diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en.json index 6715146b50..9859a7a4a2 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/en.json @@ -29,6 +29,7 @@ "FullSearch": "Search in documents", "Volo.Docs.Domain:010001": "Elastic search is not enabled.", "MultipleVersionDocumentInfo": "There are multiple versions of this document. Pick the options that suit you best.", + "DocumentOptions": "Document Options", "New": "New", "Upd": "Upd", "NewExplanation": "Created in the last two weeks.", diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/es.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/es.json index 3dcd97e7fe..451cc363aa 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/es.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/es.json @@ -29,6 +29,7 @@ "FullSearch": "Buscar en documentos", "Volo.Docs.Domain:010001": "Búsqueda elástica no habilida", "MultipleVersionDocumentInfo": "Este documento tiene múltiple versiones. Selecciona las opciones que más te interesan.", + "DocumentOptions": "Opciones del documento", "New": "Nuevo", "Upd": "Upd", "NewExplanation": "Creado en las últimas dos semanas.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Traducir con Google", "DownloadPDF": "Descargar PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fi.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fi.json index db74d481a0..4abfa3082f 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fi.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fi.json @@ -29,6 +29,7 @@ "FullSearch": "Hae asiakirjoista", "Volo.Docs.Domain:010001": "Elastinen haku ei ole käytössä.", "MultipleVersionDocumentInfo": "Tällä asiakirjalla on useita versioita. Valitse sinulle parhaiten sopivat vaihtoehdot.", + "DocumentOptions": "Asiakirjan asetukset", "New": "Uusi", "Upd": "Päivitykset", "NewExplanation": "Luotu kahden viime viikon aikana.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google-käännös", "DownloadPDF": "Lataa PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fr.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fr.json index 1bab402f73..f337478461 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fr.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/fr.json @@ -29,6 +29,7 @@ "FullSearch": "Rechercher dans les documents", "Volo.Docs.Domain:010001": "La recherche élastique n'est pas activée.", "MultipleVersionDocumentInfo": "Ce document a plusieurs versions. Sélectionnez les options qui vous conviennent le mieux.", + "DocumentOptions": "Options du document", "New": "Nouveau", "Upd": "Mettre à jour", "NewExplanation": "Créé au cours des deux dernières semaines.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google Traduction", "DownloadPDF": "Télécharger PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hi.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hi.json index 2ba78ec460..19e0a89278 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hi.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hi.json @@ -29,6 +29,7 @@ "FullSearch": "दस्तावेजों में खोजें", "Volo.Docs.Domain:010001": "लोचदार खोज सक्षम नहीं है।", "MultipleVersionDocumentInfo": "इस दस्तावेज़ के कई संस्करण हैं। आपके लिए सबसे उपयुक्त विकल्प का चयन करें।", + "DocumentOptions": "दस्तावेज़ विकल्प", "New": "नवीन व", "Upd": "युपीडी", "NewExplanation": "पिछले दो हफ्तों में बनाया गया।", @@ -41,4 +42,4 @@ "GoogleTranslate": "गूगल अनुवाद", "DownloadPDF": "PDF डाउनलोड करें" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hr.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hr.json index 1061dee7ab..c699f2ba50 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hr.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hr.json @@ -29,6 +29,7 @@ "FullSearch": "Traži u dokumentima", "Volo.Docs.Domain:010001": "Elastic search nije omogućen.", "MultipleVersionDocumentInfo": "Postoji više verzija ovog dokumenta. Odaberite opcije koje vam najbolje odgovaraju.", + "DocumentOptions": "Opcije dokumenta", "New": "Novi", "Upd": "Upd", "NewExplanation": "Stvoreno u posljednja dva tjedna.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google prevoditelj", "DownloadPDF": "Preuzmi PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hu.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hu.json index b8dee63ca4..f61ad53e0c 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hu.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/hu.json @@ -29,6 +29,7 @@ "FullSearch": "Keresés a dokumentumokban", "Volo.Docs.Domain:010001": "A Elastic search nincs engedélyezve.", "MultipleVersionDocumentInfo": "Ennek a dokumentumnak több változata van. Válassza ki az Ön számára legmegfelelőbb lehetőségeket.", + "DocumentOptions": "Dokumentumbeállítások", "New": "Új", "Upd": "Mód", "NewExplanation": "Az elmúlt két hétben készült.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google Fordító", "DownloadPDF": "PDF letöltése" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/is.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/is.json index 62235d3f62..ca4d3f184f 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/is.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/is.json @@ -29,6 +29,7 @@ "FullSearch": "Leita í öllum skjölum", "Volo.Docs.Domain:010001": "Teygjanleg leit er ekki virkjuð.", "MultipleVersionDocumentInfo": "Þetta skjal er með margar útgáfur. Veldu þá valkosti sem henta þér best.", + "DocumentOptions": "Skjalastillingar", "New": "Nýtt", "Upd": "Upd", "NewExplanation": "Búið til á síðustu tveimur vikum.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google þýðing", "DownloadPDF": "Sækja PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/it.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/it.json index 91ef732c01..05c3a1af8f 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/it.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/it.json @@ -29,6 +29,7 @@ "FullSearch": "Cerca nei documenti", "Volo.Docs.Domain:010001": "La ricerca elastica non è abilitata.", "MultipleVersionDocumentInfo": "Questo documento ha più versioni. Seleziona le opzioni più adatte a te.", + "DocumentOptions": "Opzioni del documento", "New": "Nuovo", "Upd": "Agg", "NewExplanation": "Creato nelle ultime due settimane.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Traduci con Google", "DownloadPDF": "Scarica PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/nl.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/nl.json index 48a82699d7..b6a47a9b6d 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/nl.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/nl.json @@ -29,6 +29,7 @@ "FullSearch": "Zoek in documenten", "Volo.Docs.Domain:010001": "Elastisch zoeken is niet ingeschakeld.", "MultipleVersionDocumentInfo": "Dit document heeft meerdere versies. Selecteer de opties die het beste bij u passen.", + "DocumentOptions": "Documentopties", "New": "Nieuw", "Upd": "Bew", "NewExplanation": "Gemaakt in de afgelopen twee weken.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google Vertalen", "DownloadPDF": "PDF downloaden" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pl-PL.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pl-PL.json index 35ae3e6801..7ed8cf3aff 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pl-PL.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pl-PL.json @@ -29,6 +29,7 @@ "FullSearch": "Szukaj w dokumentach", "Volo.Docs.Domain:010001": "Wyszukiwanie elastyczne nie jest włączone.", "MultipleVersionDocumentInfo": "Ten dokument ma wiele wersji. Wybierz opcje, które najbardziej Ci odpowiadają.", + "DocumentOptions": "Opcje dokumentu", "New": "Nowy", "Upd": "Aktualizacja", "NewExplanation": "Utworzony w ciągu ostatnich dwóch tygodni.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Tłumaczenie na polski", "DownloadPDF": "Pobierz PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pt-BR.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pt-BR.json index 497f0c93e2..aab4f4f7d9 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pt-BR.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/pt-BR.json @@ -29,6 +29,7 @@ "FullSearch": "Pesquisa em documentos", "Volo.Docs.Domain:010001": "A pesquisa elástica não está ativada.", "MultipleVersionDocumentInfo": "Este documento possui várias versões. Selecione as opções mais adequadas para você.", + "DocumentOptions": "Opções do documento", "New": "Novo", "Upd": "Atualizar", "NewExplanation": "Criado nas últimas duas semanas.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Traduzir com Google", "DownloadPDF": "Baixar PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ro-RO.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ro-RO.json index 155dad0754..e6b9afa522 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ro-RO.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ro-RO.json @@ -29,6 +29,7 @@ "FullSearch": "Caută în documente", "Volo.Docs.Domain:010001": "Căutarea elastică nu este activată.", "MultipleVersionDocumentInfo": "Acest document are multiple versiuni. Selectaţi opţiunile cele mai potrivite pentru dumneavoastră.", + "DocumentOptions": "Opțiuni document", "New": "Nou", "Upd": "Upd", "NewExplanation": "Creat în ultimele două săptămâni.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Traducere Google", "DownloadPDF": "Descărcare PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ru.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ru.json index 2b5a200187..aa124e4666 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ru.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/ru.json @@ -29,6 +29,7 @@ "FullSearch": "Искать в документах", "Volo.Docs.Domain:010001": "Эластичный поиск не включен.", "MultipleVersionDocumentInfo": "У этого документа есть несколько версий. Выберите наиболее подходящие для вас варианты.", + "DocumentOptions": "Параметры документа", "New": "Новый", "Upd": "Upd", "NewExplanation": "Создано за последние две недели.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google Translate", "DownloadPDF": "Скачать PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sk.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sk.json index 8f0f246a16..5f4ddcafe7 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sk.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sk.json @@ -29,6 +29,7 @@ "FullSearch": "Vyhľadať v dokumentoch", "Volo.Docs.Domain:010001": "Elastic vyhľadávanie nie je povolené.", "MultipleVersionDocumentInfo": "Tento dokument má viacero verzií. Vyberte si možnosť, ktorá vám vyhovuje najviac.", + "DocumentOptions": "Možnosti dokumentu", "New": "Nový", "Upd": "Upd", "NewExplanation": "Vytvorené za posledné dva týždne.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google preklad", "DownloadPDF": "Stiahnuť PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sl.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sl.json index c182e66b3e..e8b559db6f 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sl.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sl.json @@ -29,6 +29,7 @@ "FullSearch": "Išči v dokumentih", "Volo.Docs.Domain:010001": "Elastično iskanje ni omogočeno.", "MultipleVersionDocumentInfo": "Ta dokument ima več verzij. Izberite možnosti, ki so najprimernejše.", + "DocumentOptions": "Možnosti dokumenta", "New": "Nov", "Upd": "Posod.", "NewExplanation": "Ustvarjeno v zadnjih dveh tednih.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Google prevajalnik", "DownloadPDF": "Prenesi PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sv.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sv.json index 29db42be57..0c543da150 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sv.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/sv.json @@ -29,6 +29,7 @@ "FullSearch": "Sök i dokument", "Volo.Docs.Domain:010001": "Elastisk sökning är inte aktiverad.", "MultipleVersionDocumentInfo": "Det finns flera versioner av detta dokument. Välj de alternativ som passar dig bäst.", + "DocumentOptions": "Dokumentalternativ", "New": "Nya", "Upd": "Uppdatering", "NewExplanation": "Skapad under de senaste två veckorna.", @@ -46,4 +47,4 @@ "GoogleTranslate": "Google Översätt", "DownloadPDF": "Ladda ner PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/tr.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/tr.json index 35b08c6a62..763448fb00 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/tr.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/tr.json @@ -29,6 +29,7 @@ "FullSearch": "Dokümanlarda Ara", "Volo.Docs.Domain:010001": "Elastic search etkin değil", "MultipleVersionDocumentInfo": "Bu dökümanın birden çok versiyonu bulunmaktadır. Sizin için en uygun olan seçenekleri seçiniz.", + "DocumentOptions": "Belge Seçenekleri", "New": "Yeni", "Upd": "Günc", "NewExplanation": "Son iki hafta içinde oluşturuldu.", diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/vi.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/vi.json index d6916d05ad..0e50fd8569 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/vi.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/vi.json @@ -29,6 +29,7 @@ "FullSearch": "Tìm kiếm trong tài liệu", "Volo.Docs.Domain:010001": "Tìm kiếm đàn hồi không được bật.", "MultipleVersionDocumentInfo": "Tài liệu này có nhiều phiên bản. Chọn các tùy chọn phù hợp nhất với bạn.", + "DocumentOptions": "Tùy chọn tài liệu", "New": "Mới", "Upd": "Cập nhật", "NewExplanation": "Được tạo trong hai tuần qua.", @@ -41,4 +42,4 @@ "GoogleTranslate": "Dịch Google", "DownloadPDF": "Tải xuống PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hans.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hans.json index d214b9b4a6..d462a22ae5 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hans.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hans.json @@ -29,6 +29,7 @@ "FullSearch": "在文档中搜索", "Volo.Docs.Domain:010001": "未启用Elastic search。", "MultipleVersionDocumentInfo": "本文档有多个版本。请选择最适合您的选项。", + "DocumentOptions": "文档选项", "New": "新", "Upd": "更新文档", "NewExplanation": "最近两周创建。", @@ -41,4 +42,4 @@ "GoogleTranslate": "谷歌翻译", "DownloadPDF": "下载PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hant.json b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hant.json index ee4a291b61..9bc8aa6230 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hant.json +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/Localization/Domain/zh-Hant.json @@ -29,6 +29,7 @@ "FullSearch": "搜索文件", "Volo.Docs.Domain:010001": "Elastic search未啟用.", "MultipleVersionDocumentInfo": "此份文件有多個版本。請選擇一個最適合的。", + "DocumentOptions": "文件選項", "New": "新文檔", "Upd": "更新", "NewExplanation": "在最近兩周內創建.", @@ -41,4 +42,4 @@ "GoogleTranslate": "谷歌翻譯", "DownloadPDF": "下載PDF" } -} \ No newline at end of file +} diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml index a68298a431..e031837165 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml @@ -482,6 +482,13 @@ @if (visibleParameters.Count > 0) {
+
+ @L["DocumentOptions"] + +
+

@@ -534,6 +541,7 @@ } } +

} diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js index c9bfb3e18f..b4dc38de81 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Scripts/vs.js @@ -175,8 +175,51 @@ var docsContentWidth = $('.docs-content').width() - 74; $('.alert-criteria').width(docsContentWidth); } + + // Toggle the collapsed document-options popover by click/tap (works on touch, unlike a hover-only reveal). + function docsOptionsToggle() { + var $criteria = $('.alert-criteria'); + var $btn = $criteria.find('.options-header .toggle-btn'); + if (!$criteria.length || !$btn.length) { + return; + } + function setOpen(isOpen) { + $criteria.toggleClass('is-open', isOpen); + $btn.attr('aria-expanded', isOpen ? 'true' : 'false'); + } + // clear any previous bindings so a second init (e.g. partial reload) does not stack handlers + $btn.off('.docsOptions'); + $criteria.off('.docsOptions'); + $(document).off('.docsOptions'); + $(window).off('.docsOptions'); + $btn.on('click.docsOptions', function (e) { + e.preventDefault(); + e.stopPropagation(); + setOpen(!$criteria.hasClass('is-open')); + }); + $criteria.on('click.docsOptions', function (e) { + e.stopPropagation(); + }); + $(document).on('click.docsOptions', function () { + setOpen(false); + }); + $(document).on('keydown.docsOptions', function (e) { + if (e.key === 'Escape' && $criteria.hasClass('is-open')) { + setOpen(false); + // keyboard close: return focus to the button (focus ring is appropriate here) + $btn.trigger('focus'); + } + }); + $(window).on('scroll.docsOptions', function () { + if (!$('body').hasClass('scrolledMore')) { + setOpen(false); + } + }); + } + $(document).ready(function () { docsCriteria(); + docsOptionsToggle(); }); $(window).resize(function () { docsCriteria(); diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css index 71840b79d2..e0f242df24 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css @@ -52,19 +52,136 @@ body .mCS-autoHide > .mCustomScrollBox ~ .mCSB_scrollTools { filter: "alpha(opacity=1)"; -ms-filter: "alpha(opacity=1)"; } +body .alert-criteria .options-header { + display: none; +} body.scrolledMore { padding-top: 107px; } body.scrolledMore .alert-criteria { + position: fixed !important; + left: auto !important; + right: 20px !important; + bottom: 90px !important; + width: 360px !important; + max-width: calc(100vw - 40px) !important; + margin: 0 !important; + padding: 16px 18px 18px !important; + border-radius: 16px !important; + color: #2d2d2d !important; + z-index: 9999 !important; + visibility: hidden !important; + transition: none !important; + pointer-events: none; + background: transparent !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + border: 0 !important; + box-shadow: none !important; +} +body.scrolledMore .alert-criteria .options-header { + display: block !important; + color: #6f42c1 !important; + font-weight: 700; +} +body.scrolledMore .alert-criteria .options-header .toggle-btn { position: fixed; - bottom: 0; - z-index: 10; - border: 0; - margin-left: 0; + right: 20px; + bottom: 20px; + width: 60px; + height: 60px; + border-radius: 50%; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: #6f42c1; + background: linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%); + border: 1px solid #d9ccf2; + box-shadow: 0 8px 22px rgba(111, 66, 193, 0.16), 0 2px 6px rgba(31, 41, 55, 0.08); + visibility: visible; + pointer-events: auto; + cursor: pointer; + z-index: 9999; + transition: transform 0.2s ease, box-shadow 0.2s ease; } -body.scrolledMore .alert-criteria p.alert-p { +body.scrolledMore .alert-criteria .options-header .toggle-btn > i { display: none; } +body.scrolledMore .alert-criteria .options-header .toggle-btn::before { + content: "\f1de"; + font-family: "Font Awesome 6 Free", "Font Awesome 6 Pro", "FontAwesome"; + font-weight: 900; + font-size: 24px; +} +body.scrolledMore .alert-criteria .options-header .toggle-btn:hover { + transform: translateY(-2px); + box-shadow: 0 12px 28px rgba(111, 66, 193, 0.28); +} +body.scrolledMore .alert-criteria .options-header .toggle-btn:focus-visible { + outline: 2px solid #6f42c1; + outline-offset: 2px; +} +body.scrolledMore .alert-criteria.is-open { + visibility: visible !important; + pointer-events: auto; + background: #fff !important; + border: 1px solid #e6def7 !important; + box-shadow: 0 16px 40px rgba(40, 20, 80, 0.18) !important; +} +body.scrolledMore .alert-criteria.is-open .options-header { + display: flex !important; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +body.scrolledMore .alert-criteria.is-open .options-header .toggle-btn::before { + content: "\f00d"; + font-size: 22px; +} +body.scrolledMore .alert-criteria:not(.is-open), body.scrolledMore .alert-criteria:not(.is-open):hover, body.scrolledMore .alert-criteria:not(.is-open):focus-within { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; +} +body.scrolledMore .alert-criteria p.alert-p { + margin: 0 0 12px !important; + color: #6f42c1 !important; + font-weight: 600; +} +body.scrolledMore .alert-criteria .row > div { + width: 100% !important; + max-width: 100% !important; + flex: 0 0 100% !important; + margin-bottom: 1rem !important; +} +body.scrolledMore .alert-criteria .row > div:last-child { + margin-bottom: 0 !important; +} +@media (max-width: 768px) { + body.scrolledMore .alert-criteria { + right: 10px !important; + bottom: 75px !important; + width: calc(100vw - 20px) !important; + } + body.scrolledMore .alert-criteria .options-header .toggle-btn { + right: 10px; + bottom: 10px; + width: 55px; + height: 55px; + } + body.scrolledMore .alert-criteria .options-header .toggle-btn::before { + font-size: 22px; + } +} +body.scrolledMore:has(#abp-chatbot-container) .alert-criteria .options-header .toggle-btn { + right: 92px; +} +@media (max-width: 768px) { + body.scrolledMore:has(#abp-chatbot-container) .alert-criteria .options-header .toggle-btn { + right: 75px; + } +} .docs-page { padding: 0 1rem; diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css.map b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css.map index 9892f76098..bf55acaf25 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css.map +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["vs.scss"],"names":[],"mappings":"AAGA;EACI;EACA;;AAEA;EACI;EACA;;AAIJ;EACI;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAIA;EACI;;AAGJ;AAAA;AAAA;AAAA;EAII;;AAIR;AAAA;EAEI;;AAGJ;EACI;;AAEA;EACI;;AAEJ;EACI;;AAIR;AAAA;EAEI;EACA;EACA;;AAGJ;EACI;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;;AAMhB;EACI;;AAEA;EACI;EACA;EACA,KA9FK;EA+FL,QA/FK;EAgGL;EACA;EACA;EACA;EACA;EACA;EACA;;AAIQ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAMhB;EACI;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;;AAEJ;EACI;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAIR;AAAA;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;AAAA;AAAA;AAAA;EAII;;AAIR;EACI;EACA;;AAEJ;EACI;EACA;;AAEA;EACI;;AAIR;EACI;;AAEA;EAII;;AAIR;EACI;EACA;EACA;;AAIR;EACI;;AAIR;EAEI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;;AAIR;EACI;;AAIR;EACI;;AAGI;EACI;;AAGJ;EACI;;AAGI;EACI;;AAQhB;EACI;EACA;;AAIA;EACI;EACA;;AAMA;EACI;;AAOpB;EACI;;AAKhB;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AACA;EACI;EACA;EACA;EACA;EACA;;AAMA;EACI;EACA;EACA;EACA;EACA;;AACA;EACI;;AAEJ;EACI;EACA;;AAMhB;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAEJ;EACI;;AAGJ;EACI;EACA;;AAKZ;EACI;EACA;;AAEA;EACI;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAKJ;EACI;EACA;EACA;;AAGJ;EACI;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAMA;EACI;;AAQhB;EACI;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAGJ;AAAA;AAAA;AAAA;EAII;EACA;EACA;;AAGJ;AAAA;AAAA;AAAA;AAAA;AAAA;EAMI;EACA;;AAEA;AAAA;AAAA;AAAA;AAAA;AAAA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIA;AAAA;AAAA;AAAA;AAAA;AAAA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAGI;EACI;;AAIR;EACI;;AAGJ;AAAA;EAEI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAMR;EACI;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAKZ;EACI;EACA;;AAEA;EACI;EACA;EACA,KAtsBC;EAusBD;EACA;EACA;;AAGI;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAMA;EACI;;AAMA;EACI;;AAQxB;EACI;;AAIR;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA,eAlxBC;EAmxBD;;AAEA;EACI;EACA;EACA;;AACA;EACI;;AAGR;EACI;EACA,SAhyBH;;AAkyBD;EACI;EACA;EACA;;AAGJ;EACI;;;AAMhB;EACI;IACI;;EAEA;IACI;;EAGJ;IACI;;EAGJ;AAAA;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;;EAQR;IACI;;EAEA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAGI;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAIR;IACI;;EAGJ;IACI;IACA;;EAGJ;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;;EAEA;IACI;;EAKZ;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAIR;IACI;IACA;IACA;;EAIR;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAMZ;IACI;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAGJ;IACI;;EAIA;IACI;;EAEJ;IACI;;EAGJ;IACI;IACA;IACA;IACA;;EAGJ;IACI;IACA;IACA;IACA;;EAKZ;IACI;IACA;IACA;;EAEA;IACI;IACA;;EAIR;IACI;;EAEA;IACI;;;AAMhB;EAIgB;IACI;;EAEJ;IACI;;EAMI;IACI;IACA;;;AAS5B;EAEQ;IACI;IACA;;EAEJ;IAII;;EAHA;IACI;;;AAOhB;EAEQ;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA","file":"vs.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["vs.scss"],"names":[],"mappings":"AAGA;EACI;EACA;;AAEA;EACI;EACA;;AAIJ;EACI;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAIA;EACI;;AAGJ;AAAA;AAAA;AAAA;EAII;;AAIR;AAAA;EAEI;;AAGJ;EACI;;AAEA;EACI;;AAEJ;EACI;;AAIR;AAAA;EAEI;EACA;EACA;;AAGJ;EACI;;AAKA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EAEI;EACA;;AAIJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EAEI;EACA;EACA;EACA;EACA;;AAIJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EA5GJ;IA6GQ;IACA;IACA;;EAEA;IACI;IACA;IACA;IACA;IACA;;EAGJ;IACI;IACA;IACA;IACA;;;AAOR;EAEI;;AAGJ;EACI;IAEI;;;;AAOpB;EACI;;AAEA;EACI;EACA;EACA,KAtOK;EAuOL,QAvOK;EAwOL;EACA;EACA;EACA;EACA;EACA;EACA;;AAIQ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAMhB;EACI;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;;AAEJ;EACI;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAIR;AAAA;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;AAAA;AAAA;AAAA;EAII;;AAIR;EACI;EACA;;AAEJ;EACI;EACA;;AAEA;EACI;;AAIR;EACI;;AAEA;EAII;;AAIR;EACI;EACA;EACA;;AAIR;EACI;;AAIR;EAEI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;;AAIR;EACI;;AAIR;EACI;;AAGI;EACI;;AAGJ;EACI;;AAGI;EACI;;AAQhB;EACI;EACA;;AAIA;EACI;EACA;;AAMA;EACI;;AAOpB;EACI;;AAKhB;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AACA;EACI;EACA;EACA;EACA;EACA;;AAMA;EACI;EACA;EACA;EACA;EACA;;AACA;EACI;;AAEJ;EACI;EACA;;AAMhB;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAEJ;EACI;;AAGJ;EACI;EACA;;AAKZ;EACI;EACA;;AAEA;EACI;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAKJ;EACI;EACA;EACA;;AAGJ;EACI;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAMA;EACI;;AAQhB;EACI;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAGJ;AAAA;AAAA;AAAA;EAII;EACA;EACA;;AAGJ;AAAA;AAAA;AAAA;AAAA;AAAA;EAMI;EACA;;AAEA;AAAA;AAAA;AAAA;AAAA;AAAA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIA;AAAA;AAAA;AAAA;AAAA;AAAA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAGI;EACI;;AAIR;EACI;;AAGJ;AAAA;EAEI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;;AACA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAMR;EACI;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAKZ;EACI;EACA;;AAEA;EACI;EACA;EACA,KA90BC;EA+0BD;EACA;EACA;;AAGI;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAMA;EACI;;AAMA;EACI;;AAQxB;EACI;;AAIR;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA,eA15BC;EA25BD;;AAEA;EACI;EACA;EACA;;AACA;EACI;;AAGR;EACI;EACA,SAx6BH;;AA06BD;EACI;EACA;EACA;;AAGJ;EACI;;;AAMhB;EACI;IACI;;EAEA;IACI;;EAGJ;IACI;;EAGJ;AAAA;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;;EAQR;IACI;;EAEA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAGI;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAIR;IACI;;EAGJ;IACI;IACA;;EAGJ;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;;EAEA;IACI;;EAKZ;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAIR;IACI;IACA;IACA;;EAIR;IACI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAMZ;IACI;IACA;IACA;IACA;IACA;;EAEA;IACI;;EAGJ;IACI;;EAIA;IACI;;EAEJ;IACI;;EAGJ;IACI;IACA;IACA;IACA;;EAGJ;IACI;IACA;IACA;IACA;;EAKZ;IACI;IACA;IACA;;EAEA;IACI;IACA;;EAIR;IACI;;EAEA;IACI;;;AAMhB;EAIgB;IACI;;EAEJ;IACI;;EAMI;IACI;IACA;;;AAS5B;EAEQ;IACI;IACA;;EAEJ;IAII;;EAHA;IACI;;;AAOhB;EAEQ;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA","file":"vs.css"} \ No newline at end of file diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.scss b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.scss index 9d5de59393..1561fd57ac 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.scss +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Shared/Styles/vs.scss @@ -70,18 +70,167 @@ body { -ms-filter: 'alpha(opacity=1)'; } + // the options-header only exists to host the floating button; hide it while the bar is inline + // (non-scrolled), so the inline bar keeps its original look. It reappears once .scrolledMore collapses. + .alert-criteria .options-header { + display: none; + } + &.scrolledMore { padding-top: 107px; + // Collapse the floating document-options bar into a round button (like the chat widget). + // The bar slides out of the way; clicking/tapping the button (.toggle-btn) reveals the options + // by toggling .is-open (see vs.js). Click-based so it works on touch, unlike a hover-only reveal. + // The stylesheet is bundled into abp.io too. .alert-criteria { - position: fixed; - bottom: 0; - z-index: 10; - border: 0; - margin-left: -0; + position: fixed !important; + left: auto !important; + right: 20px !important; + bottom: 90px !important; + width: 360px !important; + max-width: calc(100vw - 40px) !important; + margin: 0 !important; + padding: 16px 18px 18px !important; + border-radius: 16px !important; + color: #2d2d2d !important; + z-index: 9999 !important; + visibility: hidden !important; + // hide instantly: the base 0.3s transition otherwise fades the bar out on collapse, + // flashing a translucent half-open panel while scrolling on narrow viewports + transition: none !important; + pointer-events: none; + background: transparent !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + border: 0 !important; + box-shadow: none !important; + + // collapsed: only the round FAB (a real - - {{ 'AbpAccount::Submit' | abpLocalization }} - - +

{{ 'AbpAccount::ResetPassword_Information' | abpLocalization }}

+
+ + * + +
+
+ + * + +
+ + + {{ 'AbpAccount::Submit' | abpLocalization }} + + + } } @else {

{{ 'AbpAccount::YourPasswordIsSuccessfullyReset' | abpLocalization }} diff --git a/npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts b/npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts index f4567c48bb..25879ac653 100644 --- a/npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts +++ b/npm/ng-packs/packages/account/src/lib/components/reset-password/reset-password.component.ts @@ -1,6 +1,14 @@ import { AccountService } from '@abp/ng.account.core/proxy'; import { ButtonComponent, getPasswordValidators } from '@abp/ng.theme.shared'; -import { Component, Injector, OnInit, inject } from '@angular/core'; +import { + Component, + effect, + Injector, + inject, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule, UntypedFormBuilder, @@ -15,6 +23,7 @@ import { LocalizationPipe } from '@abp/ng.core'; const PASSWORD_FIELDS = ['password', 'confirmPassword']; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-reset-password', templateUrl: './reset-password.component.html', imports: [ @@ -25,18 +34,19 @@ const PASSWORD_FIELDS = ['password', 'confirmPassword']; ButtonComponent, ], }) -export class ResetPasswordComponent implements OnInit { +export class ResetPasswordComponent { private fb = inject(UntypedFormBuilder); private accountService = inject(AccountService); private route = inject(ActivatedRoute); private router = inject(Router); private injector = inject(Injector); - form!: UntypedFormGroup; + private readonly queryParams = toSignal(this.route.queryParams); - inProgress = false; + form!: UntypedFormGroup; - isPasswordReset = false; + readonly inProgress = signal(false); + readonly isPasswordReset = signal(false); mapErrorsFn: Validation.MapErrorsFn = (errors, groupErrors, control) => { if (PASSWORD_FIELDS.indexOf(String(control?.name)) < 0) return errors; @@ -44,9 +54,16 @@ export class ResetPasswordComponent implements OnInit { return errors.concat(groupErrors.filter(({ key }) => key === 'passwordMismatch')); }; - ngOnInit(): void { - this.route.queryParams.subscribe(({ userId, resetToken }) => { - if (!userId || !resetToken) this.router.navigateByUrl('/account/login'); + constructor() { + effect(() => { + const params = this.queryParams(); + if (!params) return; + + const { userId, resetToken } = params; + if (!userId || !resetToken) { + void this.router.navigateByUrl('/account/login'); + return; + } this.form = this.fb.group( { @@ -63,9 +80,9 @@ export class ResetPasswordComponent implements OnInit { } onSubmit() { - if (this.form.invalid || this.inProgress) return; + if (this.form.invalid || this.inProgress()) return; - this.inProgress = true; + this.inProgress.set(true); this.accountService .resetPassword({ @@ -73,9 +90,9 @@ export class ResetPasswordComponent implements OnInit { resetToken: this.form.get('resetToken')?.value, password: this.form.get('password')?.value, }) - .pipe(finalize(() => (this.inProgress = false))) + .pipe(finalize(() => this.inProgress.set(false))) .subscribe(() => { - this.isPasswordReset = true; + this.isPasswordReset.set(true); }); } } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html index 2f8a80d721..fc8048fd41 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.html @@ -1,13 +1,13 @@

- @if (form && (!isEditMode || blogPost)) { -
+ @if (form && (!isEditMode() || blogPost())) { +
- @if (coverImagePreview) { + @if (coverImagePreview()) {
- Cover Image + Cover Image
- +
@@ -38,14 +38,15 @@ - @if (isTagsEnabled) { + @if (isTagsEnabled()) {
@@ -63,7 +64,7 @@ - @if (isEditMode) { + @if (isEditMode()) { {{ 'CmsKit::Publish' | abpLocalization }} diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts index 36a7cc2629..14b996a507 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-form/blog-post-form.component.ts @@ -1,6 +1,14 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; -import { Component, OnInit, inject, Injector, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + Injector, + OnInit, + signal, +} from '@angular/core'; import { ReactiveFormsModule, FormsModule, FormGroup, FormControl } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgxValidateCoreModule } from '@ngx-validate/core'; @@ -29,6 +37,7 @@ import { eCmsKitAdminComponents } from '../../../enums'; import { BlogPostFormService } from '../../../services'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-blog-post-form', templateUrl: './blog-post-form.component.html', providers: [ @@ -60,33 +69,33 @@ export class BlogPostFormComponent implements OnInit { private destroyRef = inject(DestroyRef); form: FormGroup; - blogPost: BlogPostDto | null = null; + readonly blogPost = signal(null); blogPostId: string | null = null; - isEditMode = false; + readonly isEditMode = signal(false); coverImageFile: File | null = null; - coverImagePreview: string | null = null; - tags: string = ''; - isTagsEnabled = true; + readonly coverImagePreview = signal(null); + readonly tags = signal(''); + readonly isTagsEnabled = signal(true); readonly BLOG_POST_ENTITY_TYPE = 'BlogPost'; ngOnInit() { const id = this.route.snapshot.params['id']; if (id) { - this.isEditMode = true; + this.isEditMode.set(true); this.blogPostId = id; this.loadBlogPost(id); } else { - this.isEditMode = false; + this.isEditMode.set(false); this.buildForm(); } } private loadBlogPost(id: string) { this.blogPostService.get(id).subscribe(blogPost => { - this.blogPost = blogPost; + this.blogPost.set(blogPost); if (blogPost.coverImageMediaId) { - this.coverImagePreview = `/api/cms-kit/media/${blogPost.coverImageMediaId}`; + this.coverImagePreview.set(`/api/cms-kit/media/${blogPost.coverImageMediaId}`); } this.buildForm(); this.loadTags(id); @@ -94,7 +103,6 @@ export class BlogPostFormComponent implements OnInit { } private loadTags(blogPostId: string) { - // TODO: use the public service to load the tags this.restService .request({ method: 'GET', @@ -102,28 +110,27 @@ export class BlogPostFormComponent implements OnInit { }) .subscribe(tags => { if (tags && tags.length > 0) { - this.tags = tags.map(t => t.name || '').join(', '); + this.tags.set(tags.map(t => t.name || '').join(', ')); } }); } private buildForm() { - const data = new FormPropData(this.injector, this.blogPost || {}); + const currentBlogPost = this.blogPost(); + const data = new FormPropData(this.injector, currentBlogPost || {}); const baseForm = generateFormFromProps(data); this.form = new FormGroup({ ...baseForm.controls, - content: new FormControl(this.blogPost?.content || ''), - coverImageMediaId: new FormControl(this.blogPost?.coverImageMediaId || null), + content: new FormControl(currentBlogPost?.content || ''), + coverImageMediaId: new FormControl(currentBlogPost?.coverImageMediaId || null), }); prepareSlugFromControl(this.form, 'title', 'slug', this.destroyRef); - // Check if tags feature is enabled for the blog - const blogId = this.form.get('blogId')?.value || this.blogPost?.blogId; + const blogId = this.form.get('blogId')?.value || currentBlogPost?.blogId; if (blogId) { this.checkTagsFeature(blogId); } - // Listen for blog selection changes this.form .get('blogId') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) @@ -142,7 +149,7 @@ export class BlogPostFormComponent implements OnInit { }) .subscribe(feature => { const { isEnabled } = feature || {}; - this.isTagsEnabled = isEnabled; + this.isTagsEnabled.set(isEnabled); }); } @@ -152,7 +159,7 @@ export class BlogPostFormComponent implements OnInit { this.coverImageFile = input.files[0]; const reader = new FileReader(); reader.onload = (e: any) => { - this.coverImagePreview = e.target.result; + this.coverImagePreview.set(e.target.result); }; reader.readAsDataURL(this.coverImageFile); } @@ -160,7 +167,7 @@ export class BlogPostFormComponent implements OnInit { removeCoverImage() { this.coverImageFile = null; - this.coverImagePreview = null; + this.coverImagePreview.set(null); this.form.patchValue({ coverImageMediaId: null }); } @@ -183,11 +190,12 @@ export class BlogPostFormComponent implements OnInit { } private setTags(blogPostId: string) { - if (!this.tags || !this.tags.trim()) { + const tagsValue = this.tags(); + if (!tagsValue || !tagsValue.trim()) { return of(null); } - const tagArray = this.tags + const tagArray = tagsValue .split(',') .map(t => t.trim()) .filter(t => t.length > 0); @@ -204,7 +212,6 @@ export class BlogPostFormComponent implements OnInit { } private executeSaveOperation(operation: 'save' | 'draft' | 'publish' | 'sendToReview') { - // First upload cover image if selected this.uploadCoverImage() .pipe( tap(coverImageMediaId => { @@ -213,11 +220,12 @@ export class BlogPostFormComponent implements OnInit { } }), switchMap(() => { - if (this.isEditMode) { - if (!this.blogPost || !this.blogPostId) { + if (this.isEditMode()) { + const currentBlogPost = this.blogPost(); + if (!currentBlogPost || !this.blogPostId) { return of(null); } - return this.blogPostFormService.update(this.blogPostId, this.form, this.blogPost); + return this.blogPostFormService.update(this.blogPostId, this.form, currentBlogPost); } switch (operation) { @@ -236,7 +244,6 @@ export class BlogPostFormComponent implements OnInit { if (!result || !result.id) { return of(null); } - // Set tags after blog post is created/updated return forkJoin([of(result), this.setTags(result.id)]); }), ) diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html index 9c8b768945..47f2632cf8 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.html @@ -1,4 +1,4 @@ - +
@@ -35,6 +35,10 @@
- +
diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts index 67f90689f4..f14960df35 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blog-posts/blog-post-list/blog-post-list.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; @@ -14,6 +15,7 @@ import { import { eCmsKitAdminComponents } from '../../../enums'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-blog-post-list', templateUrl: './blog-post-list.component.html', providers: [ @@ -25,21 +27,35 @@ import { eCmsKitAdminComponents } from '../../../enums'; ], imports: [ExtensibleTableComponent, PageComponent, LocalizationPipe, FormsModule, CommonModule], }) -export class BlogPostListComponent implements OnInit { - data: PagedResultDto = { items: [], totalCount: 0 }; - +export class BlogPostListComponent { public readonly list = inject(ListService); private blogPostService = inject(BlogPostAdminService); private confirmationService = inject(ConfirmationService); + readonly data = toSignal( + this.list.hookToQuery(query => { + let filters: Partial = {}; + if (this.list.filter) { + filters.filter = this.list.filter; + } + if (this.statusFilter !== null) { + filters.status = this.statusFilter; + } + const input: BlogPostGetListInput = { + ...query, + ...filters, + }; + return this.blogPostService.getList(input); + }), + { + initialValue: { items: [], totalCount: 0 } as PagedResultDto, + }, + ); + filter = ''; statusFilter: BlogPostStatus | null = null; BlogPostStatus = BlogPostStatus; - ngOnInit() { - this.hookToQuery(); - } - onSearch() { this.list.filter = this.filter; this.list.get(); @@ -49,25 +65,6 @@ export class BlogPostListComponent implements OnInit { this.list.get(); } - private hookToQuery() { - this.list - .hookToQuery(query => { - let filters: Partial = {}; - if (this.list.filter) { - filters.filter = this.list.filter; - } - if (this.statusFilter !== null) { - filters.status = this.statusFilter; - } - const input: BlogPostGetListInput = { - ...query, - ...filters, - }; - return this.blogPostService.getList(input); - }) - .subscribe(res => (this.data = res)); - } - delete(id: string, title: string) { this.confirmationService .warn('CmsKit::BlogPostDeletionConfirmationMessage', 'AbpUi::AreYouSure', { @@ -77,9 +74,7 @@ export class BlogPostListComponent implements OnInit { }) .subscribe((status: Confirmation.Status) => { if (status === Confirmation.Status.confirm) { - this.blogPostService.delete(id).subscribe(() => { - this.list.get(); - }); + this.blogPostService.delete(id).subscribe(() => this.list.get()); } }); } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html index da9240ac8c..1f04f5073e 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.html @@ -4,8 +4,8 @@ - @if (form) { -
+ @if (form(); as currentForm) { +
@for (featureControl of featuresFormArray.controls; track $index; let i = $index) {
@@ -19,7 +19,7 @@ formControlName="isEnabled" />
} @else { @@ -33,10 +33,10 @@ class="form-check-input" id="feature-{{ i }}" formControlName="isEnabled" - disabled + [disabled]="true" />
@@ -58,7 +58,7 @@ - + {{ 'AbpUi::Save' | abpLocalization }} diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts index 4b4bd4f420..30785fbeb5 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-features-modal/blog-features-modal.component.ts @@ -1,4 +1,12 @@ -import { Component, OnInit, inject, input, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + OnInit, + output, + signal, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormArray, FormBuilder, FormGroup } from '@angular/forms'; import { NgxValidateCoreModule } from '@ngx-validate/core'; @@ -22,6 +30,7 @@ export interface BlogFeaturesModalVisibleChange { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-blog-features-modal', templateUrl: './blog-features-modal.component.html', imports: [ @@ -42,8 +51,8 @@ export class BlogFeaturesModalComponent implements OnInit { blogId = input(); visibleChange = output(); - form: FormGroup; - features: BlogFeatureDto[] = []; + readonly form = signal(undefined); + readonly features = signal([]); private initialFeatureStates: Map = new Map(); ngOnInit() { @@ -54,19 +63,19 @@ export class BlogFeaturesModalComponent implements OnInit { private loadFeatures() { this.blogFeatureService.getList(this.blogId()!).subscribe(features => { - this.features = features.sort((a, b) => + const sorted = features.sort((a, b) => (a.featureName || '').localeCompare(b.featureName || ''), ); - // Store initial states + this.features.set(sorted); this.initialFeatureStates = new Map( - this.features.map(f => [f.featureName || '', f.isEnabled || false]), + sorted.map(f => [f.featureName || '', f.isEnabled || false]), ); - this.buildForm(); + this.buildForm(sorted); }); } - private buildForm() { - const featureControls = this.features.map(feature => + private buildForm(features: BlogFeatureDto[]) { + const featureControls = features.map(feature => this.fb.group({ featureName: [feature.featureName], isEnabled: [feature.isEnabled], @@ -74,13 +83,15 @@ export class BlogFeaturesModalComponent implements OnInit { }), ); - this.form = this.fb.group({ - features: this.fb.array(featureControls), - }); + this.form.set( + this.fb.group({ + features: this.fb.array(featureControls), + }), + ); } get featuresFormArray(): FormArray { - return this.form.get('features') as FormArray; + return this.form()!.get('features') as FormArray; } onVisibleChange(visible: boolean, refresh = false) { @@ -88,20 +99,19 @@ export class BlogFeaturesModalComponent implements OnInit { } save() { - if (!this.form.valid || !this.blogId()) { + const currentForm = this.form(); + if (!currentForm?.valid || !this.blogId()) { return; } - const featuresArray = this.form.get('features') as FormArray; + const featuresArray = currentForm.get('features') as FormArray; - // Only save features that have changed const changedFeatures: BlogFeatureInputDto[] = featuresArray.controls .map(control => { const featureName = control.get('featureName')?.value; const isEnabled = control.get('isEnabled')?.value; const initialIsEnabled = this.initialFeatureStates.get(featureName); - // Only include if the value has changed if (featureName && initialIsEnabled !== isEnabled) { return { featureName, @@ -112,23 +122,18 @@ export class BlogFeaturesModalComponent implements OnInit { }) .filter((input): input is BlogFeatureInputDto => input !== null); - // If no features changed, just close the modal if (changedFeatures.length === 0) { this.onVisibleChange(false, false); return; } - // Save only changed features const saveObservables = changedFeatures.map(input => this.blogFeatureService.set(this.blogId()!, input), ); - // Use forkJoin to save all changed features at once - forkJoin(saveObservables).subscribe({ - next: () => { - this.onVisibleChange(false, true); - this.toasterService.success('AbpUi::SavedSuccessfully'); - }, + forkJoin(saveObservables).subscribe(() => { + this.onVisibleChange(false, true); + this.toasterService.success('AbpUi::SavedSuccessfully'); }); } } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html index e9f224383f..93c0819475 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.html @@ -1,15 +1,15 @@ - +
- +
- @if (isModalVisible) { - + @if (isModalVisible()) { + } - @if (isFeaturesModalVisible) { + @if (isFeaturesModalVisible()) { } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts index 4b5c84495a..0fc9fb129e 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-list/blog-list.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ListService, PagedResultDto, LocalizationPipe } from '@abp/ng.core'; @@ -14,6 +15,7 @@ import { } from '../blog-features-modal/blog-features-modal.component'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-blog-list', templateUrl: './blog-list.component.html', providers: [ @@ -33,22 +35,33 @@ import { BlogFeaturesModalComponent, ], }) -export class BlogListComponent implements OnInit { - data: PagedResultDto = { items: [], totalCount: 0 }; - +export class BlogListComponent { public readonly list = inject(ListService); private blogService = inject(BlogAdminService); private confirmationService = inject(ConfirmationService); - filter = ''; - isModalVisible = false; - selected?: BlogDto; - isFeaturesModalVisible = false; - selectedBlogId?: string; + readonly data = toSignal( + this.list.hookToQuery(query => { + let filters: Partial = {}; + if (this.list.filter) { + filters.filter = this.list.filter; + } + const input: BlogGetListInput = { + ...query, + ...filters, + }; + return this.blogService.getList(input); + }), + { + initialValue: { items: [], totalCount: 0 } as PagedResultDto, + }, + ); - ngOnInit() { - this.hookToQuery(); - } + filter = ''; + readonly isModalVisible = signal(false); + readonly selected = signal(undefined); + readonly isFeaturesModalVisible = signal(false); + readonly selectedBlogId = signal(undefined); onSearch() { this.list.filter = this.filter; @@ -56,14 +69,14 @@ export class BlogListComponent implements OnInit { } add() { - this.selected = {} as BlogDto; - this.isModalVisible = true; + this.selected.set({} as BlogDto); + this.isModalVisible.set(true); } edit(id: string) { this.blogService.get(id).subscribe(blog => { - this.selected = blog; - this.isModalVisible = true; + this.selected.set(blog); + this.isModalVisible.set(true); }); } @@ -74,34 +87,14 @@ export class BlogListComponent implements OnInit { }) .subscribe((status: Confirmation.Status) => { if (status === Confirmation.Status.confirm) { - this.blogService.delete(id).subscribe(() => { - this.list.get(); - }); + this.blogService.delete(id).subscribe(() => this.list.get()); } }); } openFeatures(id: string) { - this.selectedBlogId = id; - this.isFeaturesModalVisible = true; - } - - private hookToQuery() { - this.list - .hookToQuery(query => { - let filters: Partial = {}; - if (this.list.filter) { - filters.filter = this.list.filter; - } - const input: BlogGetListInput = { - ...query, - ...filters, - }; - return this.blogService.getList(input); - }) - .subscribe(res => { - this.data = res; - }); + this.selectedBlogId.set(id); + this.isFeaturesModalVisible.set(true); } onVisibleModalChange(visibilityChange: BlogModalVisibleChange) { @@ -111,8 +104,8 @@ export class BlogListComponent implements OnInit { if (visibilityChange.refresh) { this.list.get(); } - this.selected = null; - this.isModalVisible = false; + this.selected.set(undefined); + this.isModalVisible.set(false); } onFeaturesModalChange(visibilityChange: BlogFeaturesModalVisibleChange) { @@ -122,7 +115,7 @@ export class BlogListComponent implements OnInit { if (visibilityChange.refresh) { this.list.get(); } - this.selectedBlogId = null; - this.isFeaturesModalVisible = false; + this.selectedBlogId.set(undefined); + this.isFeaturesModalVisible.set(false); } } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html index 83d235bcd3..f5d5ff7523 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.html @@ -1,4 +1,4 @@ - +

{{ (selected()?.id ? 'AbpUi::Edit' : 'AbpUi::New') | abpLocalization }} @@ -7,7 +7,7 @@ @if (form) { - + } @else { diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts index d9a4d07441..c87109f6ce 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/blogs/blog-modal/blog-modal.component.ts @@ -1,4 +1,14 @@ -import { Component, OnInit, inject, Injector, input, output, DestroyRef } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + Injector, + input, + OnInit, + output, + signal, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormGroup } from '@angular/forms'; import { NgxValidateCoreModule } from '@ngx-validate/core'; @@ -24,6 +34,7 @@ export interface BlogModalVisibleChange { } @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-blog-modal', templateUrl: './blog-modal.component.html', imports: [ @@ -46,7 +57,7 @@ export class BlogModalComponent implements OnInit { selected = input(); visibleChange = output(); - modalBusy = false; + readonly modalBusy = signal(false); form: FormGroup; @@ -65,12 +76,13 @@ export class BlogModalComponent implements OnInit { } save() { - if (this.modalBusy) { + if (this.modalBusy()) { return; } - this.modalBusy = true; + this.modalBusy.set(true); if (!this.form.valid) { + this.modalBusy.set(false); return; } @@ -86,7 +98,7 @@ export class BlogModalComponent implements OnInit { } as UpdateBlogDto); } - observable$.pipe(finalize(() => (this.modalBusy = false))).subscribe(() => { + observable$.pipe(finalize(() => this.modalBusy.set(false))).subscribe(() => { this.onVisibleChange(false, true); this.toasterService.success('AbpUi::SavedSuccessfully'); }); diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html index 0f4cf1fd1d..6f54edd503 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.html @@ -28,7 +28,7 @@


- + {{ 'AbpAccount::Save' | abpLocalization }}
diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts index 34bf59385e..38ccdd5ff5 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/cms-settings/cms-settings.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { NgbNav, @@ -33,11 +33,13 @@ import { CMS_KIT_COMMENTS_REQUIRE_APPROVEMENT } from '../comments'; ReactiveFormsModule, ], }) -export class CmsSettingsComponent { +export class CmsSettingsComponent implements OnInit { readonly commentAdminService = inject(CommentAdminService); readonly configState = inject(ConfigStateService); readonly toaster = inject(ToasterService); + readonly loading = signal(false); + commentApprovalControl = new FormControl(false); ngOnInit() { @@ -47,10 +49,12 @@ export class CmsSettingsComponent { } submit() { + this.loading.set(true); this.commentAdminService .updateSettings({ commentRequireApprovement: this.commentApprovalControl.value }) .pipe( finalize(() => { + this.loading.set(false); this.configState.refreshAppState().subscribe(); }), ) diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html index 7b4c18c4a3..708859176b 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.html @@ -1,5 +1,5 @@ - @if (comment) { + @if (comment(); as comment) {
@@ -59,7 +59,7 @@
- +
@@ -142,6 +142,6 @@

{{ 'CmsKit::RepliesToThisComment' | abpLocalization }}

- +
diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts index ffebe3ca2c..b9ff177e75 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-details/comment-details.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule, DatePipe } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -18,6 +19,7 @@ import { eCmsKitAdminComponents } from '../../../enums'; import { CommentEntityService } from '../../../services'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-comment-details', templateUrl: './comment-details.component.html', providers: [ @@ -46,8 +48,8 @@ import { CommentEntityService } from '../../../services'; ], }) export class CommentDetailsComponent implements OnInit { - comment: CommentWithAuthorDto | null = null; - data: PagedResultDto = { items: [], totalCount: 0 }; + readonly comment = signal(null); + readonly commentId = signal(null); readonly list = inject(ListService); readonly commentEntityService = inject(CommentEntityService); @@ -57,19 +59,33 @@ export class CommentDetailsComponent implements OnInit { private router = inject(Router); private fb = inject(FormBuilder); + readonly data = toSignal( + this.list.hookToQuery(query => { + const filters = (this.list.filter as Partial) || {}; + const input: CommentGetListInput = { + repliedCommentId: this.commentId() ?? undefined, + ...query, + ...filters, + }; + return this.commentService.getList(input); + }), + { + initialValue: { items: [], totalCount: 0 } as PagedResultDto, + }, + ); + filterForm!: FormGroup; commentApproveStateOptions = commentApproveStateOptions; - commentId!: string; requireApprovement: boolean; ngOnInit() { this.route.params.subscribe(params => { const id = params['id']; if (id) { - this.commentId = id; + this.commentId.set(id); this.loadComment(id); this.createFilterForm(); - this.hookToQuery(); + this.list.get(); } }); this.requireApprovement = this.commentEntityService.requireApprovement; @@ -86,7 +102,7 @@ export class CommentDetailsComponent implements OnInit { private loadComment(id: string) { this.commentService.get(id).subscribe(comment => { - this.comment = comment; + this.comment.set(comment); }); } @@ -95,7 +111,7 @@ export class CommentDetailsComponent implements OnInit { const filters: Partial = { author: formValue.author || undefined, commentApproveState: formValue.commentApproveState, - repliedCommentId: this.commentId, + repliedCommentId: this.commentId() ?? undefined, creationStartDate: formValue.creationStartDate || undefined, creationEndDate: formValue.creationEndDate || undefined, }; @@ -104,20 +120,6 @@ export class CommentDetailsComponent implements OnInit { this.list.get(); } - private hookToQuery() { - this.list - .hookToQuery(query => { - const filters = (this.list.filter as Partial) || {}; - const input: CommentGetListInput = { - repliedCommentId: this.commentId, - ...query, - ...filters, - }; - return this.commentService.getList(input); - }) - .subscribe(res => (this.data = res)); - } - navigateToReply(id: string) { this.router.navigate(['/cms/comments', id]); } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html index 5137014959..62d2eb5466 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.html @@ -87,6 +87,6 @@
- +
diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts index dc7da58d78..5ab7e299d5 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/comments/comment-list/comment-list.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { NgbDateAdapter, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; @@ -17,6 +18,7 @@ import { eCmsKitAdminComponents } from '../../../enums'; import { CommentEntityService } from '../../../services'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: 'abp-comment-list', templateUrl: './comment-list.component.html', providers: [ @@ -44,21 +46,32 @@ import { CommentEntityService } from '../../../services'; ], }) export class CommentListComponent implements OnInit { - data: PagedResultDto = { items: [], totalCount: 0 }; - readonly list = inject(ListService); readonly commentEntityService = inject(CommentEntityService); private commentService = inject(CommentAdminService); private fb = inject(FormBuilder); + readonly data = toSignal( + this.list.hookToQuery(query => { + const filters = (this.list.filter as Partial) || {}; + const input: CommentGetListInput = { + ...query, + ...filters, + }; + return this.commentService.getList(input); + }), + { + initialValue: { items: [], totalCount: 0 } as PagedResultDto, + }, + ); + filterForm!: FormGroup; commentApproveStateOptions = commentApproveStateOptions; requireApprovement: boolean; ngOnInit() { this.createFilterForm(); - this.hookToQuery(); this.requireApprovement = this.commentEntityService.requireApprovement; } @@ -85,17 +98,4 @@ export class CommentListComponent implements OnInit { this.list.filter = filters as any; this.list.get(); } - - private hookToQuery() { - this.list - .hookToQuery(query => { - const filters = (this.list.filter as Partial) || {}; - const input: CommentGetListInput = { - ...query, - ...filters, - }; - return this.commentService.getList(input); - }) - .subscribe(res => (this.data = res)); - } } diff --git a/npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html b/npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html index 407c83dc26..5a132fea79 100644 --- a/npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html +++ b/npm/ng-packs/packages/cms-kit/admin/src/components/global-resources/global-resources.component.html @@ -1,12 +1,12 @@ - +
@@ -29,9 +36,10 @@ id="select-all-in-all-tabs" name="select-all-in-all-tabs" class="form-check-input" - [(ngModel)]="selectAllTab" - (click)="onClickSelectAll()" - [disabled]="disabledSelectAllInAllTabs" + [checked]="selectAllTabState().checked" + [indeterminate]="selectAllTabState().indeterminate" + (change)="onSelectAllChange($event)" + [disabled]="disabledSelectAllInAllTabs()" />