diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0d8c4b53e..ce530bfea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,7 @@
+
diff --git a/docker-bake.hcl b/docker-bake.hcl
index 722bb3840..54f0618ce 100644
--- a/docker-bake.hcl
+++ b/docker-bake.hcl
@@ -46,7 +46,7 @@ target "awsecr_staging" {
tags = ["${awsecr_repostory}:staging"]
}
-# Printing
+# Production
target "production" {
context = "."
dockerfile = "Dockerfile.production"
diff --git a/sdk/js/react/src/api/catalog/products/gallery/index.ts b/sdk/js/react/src/api/catalog/products/gallery/index.ts
index aa6006b19..daa90685c 100644
--- a/sdk/js/react/src/api/catalog/products/gallery/index.ts
+++ b/sdk/js/react/src/api/catalog/products/gallery/index.ts
@@ -8,7 +8,9 @@ export const all = async (req: allResources.Request) =>
await axios.get>(allResources.url(req));
export const single = async (req: singleResources.Request) =>
- await axios.get(singleResources.url(req));
+ await axios.get(singleResources.url(req), {
+ headers: { 'X-Viewed-Product': req.viewed },
+ });
export const sortings = async () =>
await axios.get(sortingsResources.url());
diff --git a/sdk/js/react/src/api/catalog/products/gallery/single.ts b/sdk/js/react/src/api/catalog/products/gallery/single.ts
index c5452dc23..7db49d231 100644
--- a/sdk/js/react/src/api/catalog/products/gallery/single.ts
+++ b/sdk/js/react/src/api/catalog/products/gallery/single.ts
@@ -2,6 +2,7 @@ import { CategoryDto, Counts, GALLERY_BASE_PATH } from '@/api/catalog/common';
export type Request = {
id: string;
+ viewed?: boolean;
};
export type Response = {
diff --git a/sdk/js/react/src/api/common/exchange-rates/common.ts b/sdk/js/react/src/api/common/exchange-rates/common.ts
index f0b79a9b3..d4fe0c3ae 100644
--- a/sdk/js/react/src/api/common/exchange-rates/common.ts
+++ b/sdk/js/react/src/api/common/exchange-rates/common.ts
@@ -1,4 +1,4 @@
-import { Currency } from '@/types';
+import { Currency } from '@/constants';
export type ExchangeRate = {
date: string;
diff --git a/sdk/js/react/src/api/identity/common.ts b/sdk/js/react/src/api/identity/common.ts
index 0bf3b2980..b591746f2 100644
--- a/sdk/js/react/src/api/identity/common.ts
+++ b/sdk/js/react/src/api/identity/common.ts
@@ -1 +1,14 @@
+export type ViewedProduct = {
+ id: string;
+ viewedAt: string;
+};
+
+export type Fingerprint = {
+ id: string;
+ device: string;
+ location?: string;
+ deleteAllowed: boolean;
+ issuedAt: string;
+};
+
export const IDENTITY_BASE_PATH = '/identity';
diff --git a/sdk/js/react/src/api/identity/identity/change-names.ts b/sdk/js/react/src/api/identity/identity/change-names.ts
new file mode 100644
index 000000000..17a4b525c
--- /dev/null
+++ b/sdk/js/react/src/api/identity/identity/change-names.ts
@@ -0,0 +1,9 @@
+import { IDENTITY_BASE_PATH } from '../common';
+
+export type Request = {
+ username: string;
+ firstName?: string;
+ lastName?: string;
+};
+
+export const url = () => `${IDENTITY_BASE_PATH}/names`;
diff --git a/sdk/js/react/src/api/identity/identity/change-username.ts b/sdk/js/react/src/api/identity/identity/change-username.ts
deleted file mode 100644
index 215c3708a..000000000
--- a/sdk/js/react/src/api/identity/identity/change-username.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { IDENTITY_BASE_PATH } from '../common';
-
-export type Request = {
- username: string;
-};
-
-export const url = () => `${IDENTITY_BASE_PATH}/username`;
diff --git a/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts b/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts
new file mode 100644
index 000000000..c2056ba4c
--- /dev/null
+++ b/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts
@@ -0,0 +1,7 @@
+import { IDENTITY_BASE_PATH } from '../common';
+
+export type Request = {
+ refreshTokenId: string;
+};
+
+export const url = () => `${IDENTITY_BASE_PATH}/fingerprint`;
diff --git a/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts b/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts
new file mode 100644
index 000000000..9a3225e41
--- /dev/null
+++ b/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts
@@ -0,0 +1,7 @@
+import { IDENTITY_BASE_PATH } from '../common';
+
+export type Request = {
+ productId: string;
+};
+
+export const url = () => `${IDENTITY_BASE_PATH}/viewed-product`;
diff --git a/sdk/js/react/src/api/identity/identity/index.ts b/sdk/js/react/src/api/identity/identity/index.ts
index 3a982ae45..07cc2e831 100644
--- a/sdk/js/react/src/api/identity/identity/index.ts
+++ b/sdk/js/react/src/api/identity/identity/index.ts
@@ -1,4 +1,4 @@
-import { axios } from '@/api/axios';
+import { axios, config } from '@/api/axios';
import * as authnResources from './authn';
import * as authzResources from './authz';
import * as myAccountResources from './my-account';
@@ -7,7 +7,9 @@ import * as loginResources from './login';
import * as refreshResources from './refresh';
import * as logoutResources from './logout';
import * as deleteResources from './delete';
-import * as changeUsernameResources from './change-username';
+import * as deleteViewedProductResources from './delete-viewed-product';
+import * as deleteFingerprintResources from './delete-fingerprint';
+import * as changeNamesResources from './change-names';
import * as toggleTrackViewedProductsResources from './toggle-track-viewed-products';
import * as forgotPasswordResources from './forgot-password';
import * as resetPasswordResources from './reset-password';
@@ -37,14 +39,27 @@ export const refresh = async () =>
export const logout = async () => await axios.post(logoutResources.url());
-export const changeUsername = async (req: changeUsernameResources.Request) =>
- await axios.patch(changeUsernameResources.url(), req);
+export const changeUsername = async (req: changeNamesResources.Request) =>
+ await axios.patch(changeNamesResources.url(), req);
export const toggleTrackViewedProducts = async () =>
await axios.patch(toggleTrackViewedProductsResources.url());
export const delete_ = async () => await axios.delete(deleteResources.url());
+export const deleteViewedProduct = async (
+ req: deleteViewedProductResources.Request,
+) =>
+ await axios.delete(
+ deleteViewedProductResources.url(),
+ config({ data: req }),
+ );
+
+export const deleteFingerprint = async (
+ req: deleteFingerprintResources.Request,
+) =>
+ await axios.delete(deleteFingerprintResources.url(), config({ data: req }));
+
export const forgotPassword = async (req: forgotPasswordResources.Request) =>
await axios.post(forgotPasswordResources.url(), req);
diff --git a/sdk/js/react/src/api/identity/identity/my-account.ts b/sdk/js/react/src/api/identity/identity/my-account.ts
index a4db8d8a8..3d4129ce3 100644
--- a/sdk/js/react/src/api/identity/identity/my-account.ts
+++ b/sdk/js/react/src/api/identity/identity/my-account.ts
@@ -1,4 +1,4 @@
-import { IDENTITY_BASE_PATH } from '../common';
+import { IDENTITY_BASE_PATH, ViewedProduct, Fingerprint } from '../common';
export type Response = {
id: string;
@@ -9,6 +9,8 @@ export type Response = {
email: string;
trackViewedProducts: boolean;
createdAt: string;
+ viewedProducts: ViewedProduct[];
+ fingerprints: Fingerprint[];
};
export const url = () => `${IDENTITY_BASE_PATH}/my-account`;
diff --git a/sdk/js/react/src/api/identity/identity/types.ts b/sdk/js/react/src/api/identity/identity/types.ts
index b4194566f..521a0d01b 100644
--- a/sdk/js/react/src/api/identity/identity/types.ts
+++ b/sdk/js/react/src/api/identity/identity/types.ts
@@ -1,6 +1,7 @@
+export type { ViewedProduct, Fingerprint } from '../common';
export type { Response as AuthnResponse } from './authn';
export type { Response as AuthzResponse } from './authz';
-export type { Request as ChangeUsernameRequest } from './change-username';
+export type { Request as ChangeNamesRequest } from './change-names';
export type { Request as ConfirmEmailRequest } from './confirm-email';
export type { Response as DownloadUserInfoResponse } from './download-info';
export type { Request as ForgotPasswordRequest } from './forgot-password';
diff --git a/sdk/js/react/src/constants.ts b/sdk/js/react/src/constants.ts
index e8554b571..ecfb4e2e6 100644
--- a/sdk/js/react/src/constants.ts
+++ b/sdk/js/react/src/constants.ts
@@ -1,45 +1,39 @@
-import { Currency, Rates, Symbols } from './types';
-
export const EXCHANGE_RATES = {
- EUR: { rate: 1, symbol: '€', language: '' },
- USD: { rate: 1.1646, symbol: '$', language: 'en-US' },
- JPY: { rate: 173.1, symbol: '¥', language: 'ja-JP' },
- BGN: { rate: 1.9558, symbol: 'лв', language: 'bg-BG' },
- CZK: { rate: 24.485, symbol: 'Kč', language: 'cs-CZ' },
- DKK: { rate: 7.4632, symbol: 'kr', language: 'da-DK' },
- GBP: { rate: 0.8702, symbol: '£', language: 'en-GB' },
- HUF: { rate: 395.58, symbol: 'Ft', language: 'hu-HU' },
- PLN: { rate: 4.2653, symbol: 'zł', language: 'pl-PL' },
- RON: { rate: 5.0822, symbol: 'lei', language: 'ro-RO' },
- SEK: { rate: 11.003, symbol: 'kr', language: 'sv-SE' },
- CHF: { rate: 0.9366, symbol: 'CHF', language: 'fr-CH' },
- ISK: { rate: 143.6, symbol: 'Íkr', language: 'is-IS' },
- NOK: { rate: 11.674, symbol: 'kr', language: 'no-NO' },
- TRY: { rate: 47.9289, symbol: '₺', language: 'tr-TR' },
- AUD: { rate: 1.7897, symbol: 'A$', language: 'en-AU' },
- BRL: { rate: 6.3757, symbol: 'R$', language: 'pt-BR' },
- CAD: { rate: 1.6056, symbol: 'CA$', language: 'en-CA' },
- CNY: { rate: 8.3202, symbol: '¥', language: 'zh-CN' },
- HKD: { rate: 9.0915, symbol: 'HK$', language: 'zh-HK' },
- IDR: { rate: 19110.27, symbol: 'Rp', language: 'id-ID' },
- ILS: { rate: 3.9478, symbol: '₪', language: 'he-IL' },
- INR: { rate: 102.5795, symbol: '₹', language: 'hi-IN' },
- KRW: { rate: 1625.01, symbol: '₩', language: 'ko-KR' },
- MXN: { rate: 21.8726, symbol: '$', language: 'es-MX' },
- MYR: { rate: 4.9263, symbol: 'RM', language: 'ms-MY' },
- NZD: { rate: 1.9892, symbol: '$', language: 'en-NZ' },
- PHP: { rate: 66.762, symbol: '₱', language: 'fil-PH' },
- SGD: { rate: 1.5006, symbol: 'S$', language: 'en-SG' },
- THB: { rate: 37.704, symbol: '฿', language: 'th-TH' },
- ZAR: { rate: 20.6439, symbol: 'R', language: 'en-ZA' },
+ EUR: { rate: 1, symbol: '€', language: '', display: true },
+ USD: { rate: 1.1646, symbol: '$', language: 'en-US', display: true },
+ JPY: { rate: 173.1, symbol: '¥', language: 'ja-JP', display: true },
+ BGN: { rate: 1.9558, symbol: 'лв', language: 'bg-BG', display: false },
+ CZK: { rate: 24.485, symbol: 'Kč', language: 'cs-CZ', display: true },
+ DKK: { rate: 7.4632, symbol: 'kr', language: 'da-DK', display: true },
+ GBP: { rate: 0.8702, symbol: '£', language: 'en-GB', display: true },
+ HUF: { rate: 395.58, symbol: 'Ft', language: 'hu-HU', display: true },
+ PLN: { rate: 4.2653, symbol: 'zł', language: 'pl-PL', display: true },
+ RON: { rate: 5.0822, symbol: 'lei', language: 'ro-RO', display: true },
+ SEK: { rate: 11.003, symbol: 'kr', language: 'sv-SE', display: true },
+ CHF: { rate: 0.9366, symbol: 'CHF', language: 'fr-CH', display: true },
+ ISK: { rate: 143.6, symbol: 'Íkr', language: 'is-IS', display: true },
+ NOK: { rate: 11.674, symbol: 'kr', language: 'no-NO', display: true },
+ TRY: { rate: 47.9289, symbol: '₺', language: 'tr-TR', display: true },
+ AUD: { rate: 1.7897, symbol: 'A$', language: 'en-AU', display: true },
+ BRL: { rate: 6.3757, symbol: 'R$', language: 'pt-BR', display: true },
+ CAD: { rate: 1.6056, symbol: 'CA$', language: 'en-CA', display: true },
+ CNY: { rate: 8.3202, symbol: '¥', language: 'zh-CN', display: true },
+ HKD: { rate: 9.0915, symbol: 'HK$', language: 'zh-HK', display: true },
+ IDR: { rate: 19110.27, symbol: 'Rp', language: 'id-ID', display: true },
+ ILS: { rate: 3.9478, symbol: '₪', language: 'he-IL', display: true },
+ INR: { rate: 102.5795, symbol: '₹', language: 'hi-IN', display: true },
+ KRW: { rate: 1625.01, symbol: '₩', language: 'ko-KR', display: true },
+ MXN: { rate: 21.8726, symbol: '$', language: 'es-MX', display: true },
+ MYR: { rate: 4.9263, symbol: 'RM', language: 'ms-MY', display: true },
+ NZD: { rate: 1.9892, symbol: '$', language: 'en-NZ', display: true },
+ PHP: { rate: 66.762, symbol: '₱', language: 'fil-PH', display: true },
+ SGD: { rate: 1.5006, symbol: 'S$', language: 'en-SG', display: true },
+ THB: { rate: 37.704, symbol: '฿', language: 'th-TH', display: true },
+ ZAR: { rate: 20.6439, symbol: 'R', language: 'en-ZA', display: true },
} as const;
-export const CURRENCIES = Object.keys(EXCHANGE_RATES) as Currency[];
-
-export const RATES: Rates = Object.fromEntries(
- Object.entries(EXCHANGE_RATES).map(([k, v]) => [k, v.rate]),
-) as never;
+export type Currency = keyof typeof EXCHANGE_RATES;
-export const SYMBOLS: Symbols = Object.fromEntries(
- Object.entries(EXCHANGE_RATES).map(([k, v]) => [k, v.symbol]),
-) as never;
+export const CURRENCIES = Object.entries(EXCHANGE_RATES)
+ .filter(([, v]) => v.display)
+ .map(([k]) => k) as Array;
diff --git a/sdk/js/react/src/hooks/index.ts b/sdk/js/react/src/hooks/index.ts
index fb25256c2..9bcfb68a8 100644
--- a/sdk/js/react/src/hooks/index.ts
+++ b/sdk/js/react/src/hooks/index.ts
@@ -1,5 +1,6 @@
export * as queries from './queries';
export { useQuery } from './queries/useQuery';
export { useInfiniteQuery } from './queries/useInfiniteQuery';
+export { queryCall } from './queries/queryCall';
export * as mutations from './mutations';
export { useMutation } from './mutations/useMutation';
diff --git a/sdk/js/react/src/hooks/mutations/identity/identity.ts b/sdk/js/react/src/hooks/mutations/identity/identity.ts
index 144226443..4355d3c7d 100644
--- a/sdk/js/react/src/hooks/mutations/identity/identity.ts
+++ b/sdk/js/react/src/hooks/mutations/identity/identity.ts
@@ -3,7 +3,9 @@ import { identityApi as api } from '@/api';
import { Request as Login } from '@/api/identity/identity/login';
import { Request as ForgotPassword } from '@/api/identity/identity/forgot-password';
import { Request as ResetPassword } from '@/api/identity/identity/reset-password';
-import { Request as ChangeUsername } from '@/api/identity/identity/change-username';
+import { Request as ChangeNames } from '@/api/identity/identity/change-names';
+import { Request as DeleteViewedProduct } from '@/api/identity/identity/delete-viewed-product';
+import { Request as DeleteFingerprint } from '@/api/identity/identity/delete-fingerprint';
import { Request as Register } from '@/api/identity/identity/register';
import { Request as ConfirmEmail } from '@/api/identity/identity/confirm-email';
import { Request as RetryConfirmEmail } from '@/api/identity/identity/retry-confirm-email';
@@ -22,9 +24,9 @@ export const identity = {
mutationKey: [...BASE_KEY, 'refresh'],
mutationFn: async () => (await api.refresh()).data,
}),
- changeUsername: mutationOptions({
- mutationKey: [...BASE_KEY, 'change-username'],
- mutationFn: async (params: ChangeUsername) =>
+ changeNames: mutationOptions({
+ mutationKey: [...BASE_KEY, 'change-names'],
+ mutationFn: async (params: ChangeNames) =>
(await api.changeUsername(params)).data,
}),
toggleTrackViewedProducts: mutationOptions({
@@ -35,6 +37,16 @@ export const identity = {
mutationKey: [...BASE_KEY, 'delete-my-account'],
mutationFn: async () => (await api.delete_()).data,
}),
+ deleteViewedProduct: mutationOptions({
+ mutationKey: [...BASE_KEY, 'delete-viewed-product'],
+ mutationFn: async (params: DeleteViewedProduct) =>
+ (await api.deleteViewedProduct(params)).data,
+ }),
+ deleteFingerprint: mutationOptions({
+ mutationKey: [...BASE_KEY, 'delete-fingerprint'],
+ mutationFn: async (params: DeleteFingerprint) =>
+ (await api.deleteFingerprint(params)).data,
+ }),
forgotPassword: mutationOptions({
mutationKey: [...BASE_KEY, 'forgot-password'],
mutationFn: async (params: ForgotPassword) =>
diff --git a/sdk/js/react/src/hooks/queries/queryCall.ts b/sdk/js/react/src/hooks/queries/queryCall.ts
new file mode 100644
index 000000000..e19860ffe
--- /dev/null
+++ b/sdk/js/react/src/hooks/queries/queryCall.ts
@@ -0,0 +1,15 @@
+import { AxiosResponse } from 'axios';
+import { FetchQueryOptions } from '@tanstack/react-query';
+import * as customcadsQueries from '.';
+
+type Opts = FetchQueryOptions<
+ AxiosResponse,
+ Error,
+ AxiosResponse,
+ TKey
+>;
+
+export const queryCall = (
+ optionsSelector: (queries: typeof customcadsQueries) => Opts,
+ call: (options: Opts) => Promise | void>,
+) => call(optionsSelector(customcadsQueries));
diff --git a/sdk/js/react/src/index.ts b/sdk/js/react/src/index.ts
index ecae80f51..fa7ff4ce8 100644
--- a/sdk/js/react/src/index.ts
+++ b/sdk/js/react/src/index.ts
@@ -1,4 +1,3 @@
export * from './constants';
-export type { Currency, Rate, Symbol } from './types';
export * from './api';
export * from './hooks';
diff --git a/sdk/js/react/src/types.ts b/sdk/js/react/src/types.ts
deleted file mode 100644
index 697d2f5ad..000000000
--- a/sdk/js/react/src/types.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { EXCHANGE_RATES } from './constants';
-
-export type Currency = keyof typeof EXCHANGE_RATES;
-
-export type Rates = {
- [C in Currency]: (typeof EXCHANGE_RATES)[C]['rate'];
-};
-export type Rate = Rates[Currency];
-
-export type Symbols = {
- [C in Currency]: (typeof EXCHANGE_RATES)[C]['symbol'];
-};
-export type Symbol = Symbols[Currency];
diff --git a/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs b/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs
index 055628a99..3428c55a2 100644
--- a/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs
+++ b/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Accounts.API.Accounts.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class AccountsGroup : Group
{
@@ -9,7 +9,7 @@ public AccountsGroup()
{
Configure(Paths.Accounts, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(opt => opt.WithTags(Tags[Paths.Accounts]));
});
}
diff --git a/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs b/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs
index 939b315a8..7803b1a5a 100644
--- a/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs
+++ b/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Accounts.API.Roles.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class RolesGroup : Group
{
@@ -9,7 +9,7 @@ public RolesGroup()
{
Configure(Paths.Roles, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(opt => opt.WithTags(Tags[Paths.Roles]));
});
}
diff --git a/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs b/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs
index 2322a36fc..c64d1addc 100644
--- a/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs
+++ b/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs
@@ -22,8 +22,8 @@ public async Task Handle(DeleteAccountCommand req, CancellationToken ct)
await uow.SaveChangesAsync(ct).ConfigureAwait(false);
await raiser.RaiseApplicationEventAsync(
- @event: new AccountDeletedApplicationEvent(
- account.Username
+ @event: new AccountDeletedApplicationEvent(
+ account.Id
)
).ConfigureAwait(false);
}
diff --git a/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs
new file mode 100644
index 000000000..bf1ad0a2f
--- /dev/null
+++ b/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs
@@ -0,0 +1,14 @@
+using CustomCADs.Modules.Accounts.Domain.Repositories;
+using CustomCADs.Modules.Accounts.Domain.Repositories.Writes;
+using CustomCADs.Shared.Application.UseCases.Accounts.Commands;
+
+namespace CustomCADs.Modules.Accounts.Application.Accounts.Commands.Shared;
+
+public class DeleteViewedProductHandler(IAccountWrites writes, IUnitOfWork uow) : ICommandHandler
+{
+ public async Task Handle(DeleteViewedProductCommand req, CancellationToken ct = default)
+ {
+ await writes.UnviewProductAsync(req.CallerId, req.ProductId, ct).ConfigureAwait(false);
+ await uow.SaveChangesAsync(ct).ConfigureAwait(false);
+ }
+}
diff --git a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs
index 01a242781..5486874dc 100644
--- a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs
+++ b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs
@@ -13,12 +13,29 @@ public async Task HandleAsync(UserEditedApplicationEvent ae)
if (ae.Username is not null)
{
- account.SetUsername(ae.Username);
+ if (ae.Username != account.Username)
+ {
+ account.SetUsername(ae.Username);
+ }
}
+
if (ae.TrackViewedProducts is not null)
{
account.SetTrackViewedProducts(ae.TrackViewedProducts.Value);
}
+
+ if (ae.Names is not null)
+ {
+ if (ae.Names.FirstName != account.FirstName)
+ {
+ account.SetFirstName(ae.Names.FirstName);
+ }
+ if (ae.Names.LastName != account.LastName)
+ {
+ account.SetLastName(ae.Names.LastName);
+ }
+ }
+
await uow.SaveChangesAsync().ConfigureAwait(false);
}
}
diff --git a/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs
index d634d5405..f67e576cb 100644
--- a/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs
+++ b/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs
@@ -8,7 +8,7 @@ public class UserViewedProductHandler(IAccountWrites writes, IUnitOfWork uow)
{
public async Task HandleAsync(UserViewedProductApplicationEvent ae)
{
- await writes.ViewProductAsync(ae.AccountId, ae.Id).ConfigureAwait(false);
+ await writes.ViewProductAsync(ae.AccountId, ae.Id, ae.ViewedAt).ConfigureAwait(false);
await uow.SaveChangesAsync().ConfigureAwait(false);
}
}
diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs
new file mode 100644
index 000000000..c1997c85b
--- /dev/null
+++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs
@@ -0,0 +1,11 @@
+using CustomCADs.Modules.Accounts.Domain.Repositories.Reads;
+using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
+
+namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.Exists;
+
+public sealed class GetAccountExistsByUsernameHandler(IAccountReads reads)
+ : IQueryHandler
+{
+ public async Task Handle(GetAccountExistsByUsernameQuery req, CancellationToken ct)
+ => await reads.ExistsByUsernameAsync(req.Username, ct).ConfigureAwait(false);
+}
diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs
index 9a6da1c4b..000f4aff9 100644
--- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs
+++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs
@@ -12,6 +12,7 @@ public async Task Handle(GetAccountInfoByUsernameQuery req, Canc
?? throw CustomNotFoundException.ByProp(nameof(req.Username), req.Username);
return new(
+ Id: account.Id,
CreatedAt: account.CreatedAt,
TrackViewedProducts: account.TrackViewedProducts,
FirstName: account.FirstName,
diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs
deleted file mode 100644
index 28501e900..000000000
--- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CustomCADs.Modules.Accounts.Domain.Repositories.Reads;
-using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
-using CustomCADs.Shared.Domain.TypedIds.Catalog;
-
-namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct;
-
-public sealed class GetAccountViewedProductsByUsernameHandler(IAccountReads reads)
- : IQueryHandler
-{
- public async Task Handle(GetAccountViewedProductsByUsernameQuery req, CancellationToken ct)
- => await reads.ViewedProductsByUsernameAsync(req.Username, ct).ConfigureAwait(false);
-}
diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs
similarity index 95%
rename from src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs
rename to src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs
index ebad33cc8..0fa00dc42 100644
--- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs
+++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs
@@ -2,7 +2,7 @@
using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
using CustomCADs.Shared.Domain.TypedIds.Catalog;
-namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct;
+namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts;
public sealed class GetAccountViewedProductHandler(IAccountReads reads)
: IQueryHandler
diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs
new file mode 100644
index 000000000..eadcd6560
--- /dev/null
+++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs
@@ -0,0 +1,20 @@
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
+using CustomCADs.Modules.Accounts.Domain.Repositories.Reads;
+using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
+
+namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts;
+
+public sealed class GetAccountViewedProductsByUsernameHandler(IAccountReads reads)
+ : IQueryHandler
+{
+ public async Task Handle(GetAccountViewedProductsByUsernameQuery req, CancellationToken ct)
+ {
+ ViewedProduct[] viewedProducts = await reads
+ .ViewedProductsByUsernameAsync(req.Username, ct)
+ .ConfigureAwait(false);
+
+ return [.. viewedProducts
+ .Select(x => new ViewedProductDto(x.ProductId, x.ViewedAt))
+ ];
+ }
+}
diff --git a/src/Modules/Accounts/Domain/Accounts/Account.cs b/src/Modules/Accounts/Domain/Accounts/Account.cs
index da5489c83..55793c84d 100644
--- a/src/Modules/Accounts/Domain/Accounts/Account.cs
+++ b/src/Modules/Accounts/Domain/Accounts/Account.cs
@@ -2,8 +2,12 @@
namespace CustomCADs.Modules.Accounts.Domain.Accounts;
-public class Account : BaseAggregateRoot
+using Entities;
+
+public class Account : BaseAggregateRoot, ISoftDeletable
{
+ private readonly List viewedProducts = [];
+
private Account() { }
private Account(
string role,
@@ -30,6 +34,9 @@ private Account(
public string RoleName { get; private set; } = string.Empty;
public bool TrackViewedProducts { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
+ public bool IsDeleted { get; private set; }
+ public DateTimeOffset? DeletedAt { get; private set; }
+ public IReadOnlyCollection ViewedProducts => viewedProducts;
public static Account Create(
string role,
@@ -96,4 +103,18 @@ public Account SetTrackViewedProducts(bool track)
TrackViewedProducts = track;
return this;
}
+
+ public Account Delete()
+ {
+ IsDeleted = true;
+ DeletedAt = DateTimeOffset.UtcNow;
+
+ Username = this.Id.ToString();
+ Email = this.Id.ToString();
+
+ FirstName = null;
+ LastName = null;
+
+ return this;
+ }
}
diff --git a/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs b/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs
new file mode 100644
index 000000000..00be4dba1
--- /dev/null
+++ b/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs
@@ -0,0 +1,22 @@
+using CustomCADs.Shared.Domain.TypedIds.Catalog;
+
+namespace CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
+
+public class ViewedProduct
+{
+ private ViewedProduct() { }
+ private ViewedProduct(AccountId id, ProductId productId, DateTimeOffset viewedAt) : base()
+ {
+ AccountId = id;
+ ProductId = productId;
+ ViewedAt = viewedAt;
+ }
+
+ public AccountId AccountId { get; init; }
+ public ProductId ProductId { get; init; }
+ public DateTimeOffset ViewedAt { get; init; }
+ public Account Account { get; init; } = null!;
+
+ public static ViewedProduct Create(AccountId id, ProductId productId, DateTimeOffset viewedAt)
+ => new(id, productId, viewedAt);
+}
diff --git a/src/Modules/Accounts/Domain/Constants.cs b/src/Modules/Accounts/Domain/Constants.cs
new file mode 100644
index 000000000..63bec8b0f
--- /dev/null
+++ b/src/Modules/Accounts/Domain/Constants.cs
@@ -0,0 +1,17 @@
+namespace CustomCADs.Modules.Accounts.Domain;
+
+public static class Constants
+{
+ public static class Roles
+ {
+ public static readonly RoleId CustomerId = RoleId.New(1);
+ public static readonly RoleId ContributorId = RoleId.New(2);
+ public static readonly RoleId DesignerId = RoleId.New(3);
+ public static readonly RoleId AdminId = RoleId.New(4);
+
+ public const string CustomerDescription = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.";
+ public const string ContributorDescription = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.";
+ public const string DesignerDescription = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.";
+ public const string AdminDescription = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.";
+ }
+}
diff --git a/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs b/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs
index 718ee3812..a187df136 100644
--- a/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs
+++ b/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs
@@ -1,4 +1,5 @@
using CustomCADs.Modules.Accounts.Domain.Accounts;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using CustomCADs.Shared.Domain.Querying;
using CustomCADs.Shared.Domain.TypedIds.Catalog;
@@ -13,6 +14,6 @@ public interface IAccountReads
Task ExistsByIdAsync(AccountId id, CancellationToken ct = default);
Task ExistsByUsernameAsync(string username, CancellationToken ct = default);
Task ViewedProductsByIdAsync(AccountId id, CancellationToken ct = default);
- Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default);
+ Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default);
Task CountAsync(CancellationToken ct = default);
}
diff --git a/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs b/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs
index 5ffb9d063..ec76eb4ba 100644
--- a/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs
+++ b/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs
@@ -6,6 +6,7 @@ namespace CustomCADs.Modules.Accounts.Domain.Repositories.Writes;
public interface IAccountWrites
{
Task AddAsync(Account entity, CancellationToken ct = default);
- Task ViewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default);
+ Task ViewProductAsync(AccountId id, ProductId productId, DateTimeOffset viewedAt, CancellationToken ct = default);
+ Task UnviewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default);
void Remove(Account entity);
}
diff --git a/src/Modules/Accounts/Persistence/AccountsContext.cs b/src/Modules/Accounts/Persistence/AccountsContext.cs
index 4121f53b7..34f811979 100644
--- a/src/Modules/Accounts/Persistence/AccountsContext.cs
+++ b/src/Modules/Accounts/Persistence/AccountsContext.cs
@@ -1,6 +1,6 @@
using CustomCADs.Modules.Accounts.Domain.Accounts;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using CustomCADs.Modules.Accounts.Domain.Roles;
-using CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
using CustomCADs.Shared.Persistence;
namespace CustomCADs.Modules.Accounts.Persistence;
diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs
index 1dea47536..39ae3bc2c 100644
--- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs
+++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs
@@ -12,6 +12,7 @@ public void Configure(EntityTypeBuilder builder)
.SetStronglyTypedIds()
.SetIndexes()
.SetValidations()
- .SetSeeding();
+ .SetSeeding()
+ .SetFilters();
}
}
diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs
index b04bd1203..2930ead32 100644
--- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs
+++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs
@@ -5,7 +5,6 @@
namespace CustomCADs.Modules.Accounts.Persistence.Configurations.Accounts;
using static AccountConstants;
-using static DomainConstants.Roles;
using static DomainConstants.Users;
internal static class Utilities
@@ -70,19 +69,36 @@ internal EntityTypeBuilder SetValidations()
.IsRequired()
.HasColumnName(nameof(Account.CreatedAt));
+ builder
+ .Property(x => x.IsDeleted)
+ .IsRequired()
+ .HasColumnName(nameof(Account.IsDeleted));
+
+ builder
+ .Property(x => x.DeletedAt)
+ .HasColumnName(nameof(Account.DeletedAt));
+
return builder;
}
internal EntityTypeBuilder SetSeeding()
{
builder.HasData([
- Account.CreateWithId(AccountId.New(CustomerAccountId), Customer, CustomerUsername, CustomerEmail, new DateTimeOffset(2025, 05, 10, 19, 23, 12, 123, TimeSpan.FromHours(3))),
- Account.CreateWithId(AccountId.New(ContributorAccountId), Contributor, ContributorUsername, ContributorEmail, new DateTimeOffset(2025, 05, 13, 17, 42, 57, 456, TimeSpan.FromHours(3))),
- Account.CreateWithId(AccountId.New(DesignerAccountId), Designer, DesignerUsername, DesignerEmail, new DateTimeOffset(2025, 01, 09, 13, 15, 28, 789, TimeSpan.FromHours(3))),
- Account.CreateWithId(AccountId.New(AdminAccountId), Admin, AdminUsername, AdminEmail, new DateTimeOffset(2024, 03, 17, 02, 45, 13, 000, TimeSpan.FromHours(3))),
+ Account.CreateWithId(CustomerAccountId, CustomerRole, CustomerUsername, CustomerEmail, new DateTimeOffset(2025, 05, 10, 19, 23, 12, 123, TimeSpan.FromHours(3))),
+ Account.CreateWithId(ContributorAccountId, ContributorRole, ContributorUsername, ContributorEmail, new DateTimeOffset(2025, 05, 13, 17, 42, 57, 456, TimeSpan.FromHours(3))),
+ Account.CreateWithId(DesignerAccountId, DesignerRole, DesignerUsername, DesignerEmail, new DateTimeOffset(2025, 01, 09, 13, 15, 28, 789, TimeSpan.FromHours(3))),
+ Account.CreateWithId(HeadDesignerAccountId, DesignerRole, HeadDesignerUsername, HeadDesignerEmail, new DateTimeOffset(2024, 03, 17, 02, 17, 32, 789, TimeSpan.FromHours(3))),
+ Account.CreateWithId(AdminAccountId, AdminRole, AdminUsername, AdminEmail, new DateTimeOffset(2024, 03, 17, 02, 45, 13, 000, TimeSpan.FromHours(3))),
]);
return builder;
}
+
+ internal EntityTypeBuilder SetFilters()
+ {
+ builder.HasQueryFilter(x => !x.IsDeleted);
+
+ return builder;
+ }
}
}
diff --git a/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs
index b25a57608..a7eec3902 100644
--- a/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs
+++ b/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs
@@ -1,10 +1,8 @@
using CustomCADs.Modules.Accounts.Domain.Roles;
-using CustomCADs.Shared.Domain;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CustomCADs.Modules.Accounts.Persistence.Configurations.Roles;
-using static DomainConstants.Roles;
using static RoleConstants;
internal static class Utilities
@@ -48,10 +46,26 @@ internal EntityTypeBuilder SetValidations()
internal EntityTypeBuilder SetSeeding()
{
builder.HasData([
- Role.CreateWithId(RoleId.New(1), Customer, CustomerDescription),
- Role.CreateWithId(RoleId.New(2), Contributor, ContributorDescription),
- Role.CreateWithId(RoleId.New(3), Designer, DesignerDescription),
- Role.CreateWithId(RoleId.New(4), Admin, AdminDescription),
+ Role.CreateWithId(
+ id: Domain.Constants.Roles.CustomerId,
+ name: Shared.Domain.DomainConstants.Users.CustomerRole,
+ description: Domain.Constants.Roles.CustomerDescription
+ ),
+ Role.CreateWithId(
+ id: Domain.Constants.Roles.ContributorId,
+ name: Shared.Domain.DomainConstants.Users.ContributorRole,
+ description: Domain.Constants.Roles.ContributorDescription
+ ),
+ Role.CreateWithId(
+ id: Domain.Constants.Roles.DesignerId,
+ name: Shared.Domain.DomainConstants.Users.DesignerRole,
+ description: Domain.Constants.Roles.DesignerDescription
+ ),
+ Role.CreateWithId(
+ id: Domain.Constants.Roles.AdminId,
+ name: Shared.Domain.DomainConstants.Users.AdminRole,
+ description: Domain.Constants.Roles.AdminDescription
+ ),
]);
return builder;
diff --git a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs
index 030e2659c..df93f1db7 100644
--- a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs
+++ b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs
@@ -1,4 +1,4 @@
-using CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CustomCADs.Modules.Accounts.Persistence.Configurations.ViewedProducts;
diff --git a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs
index 5deeca6b5..b7626c271 100644
--- a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs
+++ b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs
@@ -1,4 +1,4 @@
-using CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using CustomCADs.Shared.Domain.TypedIds.Catalog;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -19,7 +19,7 @@ internal EntityTypeBuilder SetForeignKeys()
{
builder
.HasOne(x => x.Account)
- .WithMany()
+ .WithMany(x => x.ViewedProducts)
.HasForeignKey(x => x.AccountId)
.OnDelete(DeleteBehavior.Cascade);
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs
new file mode 100644
index 000000000..abc4bc0a3
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs
@@ -0,0 +1,204 @@
+//
+using System;
+using CustomCADs.Modules.Accounts.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations
+{
+ [DbContext(typeof(AccountsContext))]
+ [Migration("20260424222313_Added_HeadDesigner_Account")]
+ partial class Added_HeadDesigner_Account
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Accounts")
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("CreatedAt");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("Email");
+
+ b.Property("FirstName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("FirstName");
+
+ b.Property("LastName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("LastName");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("RoleName");
+
+ b.Property("TrackViewedProducts")
+ .HasColumnType("boolean")
+ .HasColumnName("TrackViewedProducts");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("Username");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Accounts", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanzlatinov006@gmail.com",
+ RoleName = "Customer",
+ TrackViewedProducts = true,
+ Username = "For7a7a"
+ },
+ new
+ {
+ Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "PDMatsaliev20@codingburgas.bg",
+ RoleName = "Contributor",
+ TrackViewedProducts = true,
+ Username = "PDMatsaliev20"
+ },
+ new
+ {
+ Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "john.cad@gmail.com",
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "John_CAD"
+ },
+ new
+ {
+ Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "boriskolev2006@gmail.com",
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "Oracle3000"
+ },
+ new
+ {
+ Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanangelov414@gmail.com",
+ RoleName = "Administrator",
+ TrackViewedProducts = true,
+ Username = "NinjataBG"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("Description");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("Name");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.",
+ Name = "Customer"
+ },
+ new
+ {
+ Id = 2,
+ Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.",
+ Name = "Contributor"
+ },
+ new
+ {
+ Id = 3,
+ Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.",
+ Name = "Designer"
+ },
+ new
+ {
+ Id = 4,
+ Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.",
+ Name = "Administrator"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.HasKey("AccountId", "ProductId");
+
+ b.ToTable("ViewedProducts", "Accounts");
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
+ {
+ b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account")
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs
new file mode 100644
index 000000000..6a0d3505d
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs
@@ -0,0 +1,46 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations;
+
+///
+public partial class Added_HeadDesigner_Account : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ column: "CreatedAt",
+ value: new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)));
+
+ migrationBuilder.InsertData(
+ schema: "Accounts",
+ table: "Accounts",
+ columns: new[] { "Id", "CreatedAt", "Email", "FirstName", "LastName", "RoleName", "TrackViewedProducts", "Username" },
+ values: new object[] { new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), "john.cad@gmail.com", null, null, "Designer", true, "John_CAD" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DeleteData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"));
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ column: "CreatedAt",
+ value: new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)));
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs
new file mode 100644
index 000000000..9b0665c16
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs
@@ -0,0 +1,217 @@
+//
+using System;
+using CustomCADs.Modules.Accounts.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations
+{
+ [DbContext(typeof(AccountsContext))]
+ [Migration("20260425173905_Implemented_Accounts_SoftDelete")]
+ partial class Implemented_Accounts_SoftDelete
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Accounts")
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("CreatedAt");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("DeletedAt");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("Email");
+
+ b.Property("FirstName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("FirstName");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("LastName");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("RoleName");
+
+ b.Property("TrackViewedProducts")
+ .HasColumnType("boolean")
+ .HasColumnName("TrackViewedProducts");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("Username");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Accounts", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanzlatinov006@gmail.com",
+ IsDeleted = false,
+ RoleName = "Customer",
+ TrackViewedProducts = true,
+ Username = "For7a7a"
+ },
+ new
+ {
+ Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "PDMatsaliev20@codingburgas.bg",
+ IsDeleted = false,
+ RoleName = "Contributor",
+ TrackViewedProducts = true,
+ Username = "PDMatsaliev20"
+ },
+ new
+ {
+ Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "john.cad@gmail.com",
+ IsDeleted = false,
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "John_CAD"
+ },
+ new
+ {
+ Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "boriskolev2006@gmail.com",
+ IsDeleted = false,
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "Oracle3000"
+ },
+ new
+ {
+ Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanangelov414@gmail.com",
+ IsDeleted = false,
+ RoleName = "Administrator",
+ TrackViewedProducts = true,
+ Username = "NinjataBG"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("Description");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("Name");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.",
+ Name = "Customer"
+ },
+ new
+ {
+ Id = 2,
+ Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.",
+ Name = "Contributor"
+ },
+ new
+ {
+ Id = 3,
+ Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.",
+ Name = "Designer"
+ },
+ new
+ {
+ Id = 4,
+ Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.",
+ Name = "Administrator"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.HasKey("AccountId", "ProductId");
+
+ b.ToTable("ViewedProducts", "Accounts");
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
+ {
+ b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account")
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs
new file mode 100644
index 000000000..d3d12358a
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs
@@ -0,0 +1,82 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations;
+
+///
+public partial class Implemented_Accounts_SoftDelete : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "DeletedAt",
+ schema: "Accounts",
+ table: "Accounts",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "IsDeleted",
+ schema: "Accounts",
+ table: "Accounts",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ columns: new[] { "DeletedAt", "IsDeleted" },
+ values: new object[] { null, false });
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"),
+ columns: new[] { "DeletedAt", "IsDeleted" },
+ values: new object[] { null, false });
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"),
+ columns: new[] { "DeletedAt", "IsDeleted" },
+ values: new object[] { null, false });
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"),
+ columns: new[] { "DeletedAt", "IsDeleted" },
+ values: new object[] { null, false });
+
+ migrationBuilder.UpdateData(
+ schema: "Accounts",
+ table: "Accounts",
+ keyColumn: "Id",
+ keyValue: new Guid("e995039c-a535-4f20-8288-7aadcb71b252"),
+ columns: new[] { "DeletedAt", "IsDeleted" },
+ values: new object[] { null, false });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "DeletedAt",
+ schema: "Accounts",
+ table: "Accounts");
+
+ migrationBuilder.DropColumn(
+ name: "IsDeleted",
+ schema: "Accounts",
+ table: "Accounts");
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs
new file mode 100644
index 000000000..9cc69e0bf
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs
@@ -0,0 +1,225 @@
+//
+using System;
+using CustomCADs.Modules.Accounts.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations
+{
+ [DbContext(typeof(AccountsContext))]
+ [Migration("20260501233422_Added_ViewedProducts_ViewedAt")]
+ partial class Added_ViewedProducts_ViewedAt
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("Accounts")
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("CreatedAt");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("DeletedAt");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("Email");
+
+ b.Property("FirstName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("FirstName");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("IsDeleted");
+
+ b.Property("LastName")
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("LastName");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("RoleName");
+
+ b.Property("TrackViewedProducts")
+ .HasColumnType("boolean")
+ .HasColumnName("TrackViewedProducts");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(62)
+ .HasColumnType("character varying(62)")
+ .HasColumnName("Username");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Accounts", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanzlatinov006@gmail.com",
+ IsDeleted = false,
+ RoleName = "Customer",
+ TrackViewedProducts = true,
+ Username = "For7a7a"
+ },
+ new
+ {
+ Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "PDMatsaliev20@codingburgas.bg",
+ IsDeleted = false,
+ RoleName = "Contributor",
+ TrackViewedProducts = true,
+ Username = "PDMatsaliev20"
+ },
+ new
+ {
+ Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "john.cad@gmail.com",
+ IsDeleted = false,
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "John_CAD"
+ },
+ new
+ {
+ Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "boriskolev2006@gmail.com",
+ IsDeleted = false,
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "Oracle3000"
+ },
+ new
+ {
+ Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "ivanangelov414@gmail.com",
+ IsDeleted = false,
+ RoleName = "Administrator",
+ TrackViewedProducts = true,
+ Username = "NinjataBG"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.Property("ViewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("AccountId", "ProductId");
+
+ b.ToTable("ViewedProducts", "Accounts");
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("Description");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("Name");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles", "Accounts");
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.",
+ Name = "Customer"
+ },
+ new
+ {
+ Id = 2,
+ Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.",
+ Name = "Contributor"
+ },
+ new
+ {
+ Id = 3,
+ Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.",
+ Name = "Designer"
+ },
+ new
+ {
+ Id = 4,
+ Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.",
+ Name = "Administrator"
+ });
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b =>
+ {
+ b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account")
+ .WithMany("ViewedProducts")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+ });
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b =>
+ {
+ b.Navigation("ViewedProducts");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs
new file mode 100644
index 000000000..4ff31e4ae
--- /dev/null
+++ b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CustomCADs.Modules.Accounts.Persistence.Migrations;
+
+///
+public partial class Added_ViewedProducts_ViewedAt : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ViewedAt",
+ schema: "Accounts",
+ table: "ViewedProducts",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ViewedAt",
+ schema: "Accounts",
+ table: "ViewedProducts");
+ }
+}
diff --git a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs
index c4d019dfb..d842d34f9 100644
--- a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs
+++ b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs
@@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Accounts")
- .HasAnnotation("ProductVersion", "9.0.4")
+ .HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -33,6 +33,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("timestamp with time zone")
.HasColumnName("CreatedAt");
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("DeletedAt");
+
b.Property("Email")
.IsRequired()
.HasColumnType("text")
@@ -43,6 +47,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("character varying(62)")
.HasColumnName("FirstName");
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("IsDeleted");
+
b.Property("LastName")
.HasMaxLength(62)
.HasColumnType("character varying(62)")
@@ -79,6 +87,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"),
CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
Email = "ivanzlatinov006@gmail.com",
+ IsDeleted = false,
RoleName = "Customer",
TrackViewedProducts = true,
Username = "For7a7a"
@@ -88,15 +97,27 @@ protected override void BuildModel(ModelBuilder modelBuilder)
Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"),
CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
Email = "PDMatsaliev20@codingburgas.bg",
+ IsDeleted = false,
RoleName = "Contributor",
TrackViewedProducts = true,
Username = "PDMatsaliev20"
},
new
{
- Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"),
CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
+ Email = "john.cad@gmail.com",
+ IsDeleted = false,
+ RoleName = "Designer",
+ TrackViewedProducts = true,
+ Username = "John_CAD"
+ },
+ new
+ {
+ Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"),
+ CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
Email = "boriskolev2006@gmail.com",
+ IsDeleted = false,
RoleName = "Designer",
TrackViewedProducts = true,
Username = "Oracle3000"
@@ -106,12 +127,29 @@ protected override void BuildModel(ModelBuilder modelBuilder)
Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"),
CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)),
Email = "ivanangelov414@gmail.com",
+ IsDeleted = false,
RoleName = "Administrator",
TrackViewedProducts = true,
Username = "NinjataBG"
});
});
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("ProductId")
+ .HasColumnType("uuid");
+
+ b.Property("ViewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("AccountId", "ProductId");
+
+ b.ToTable("ViewedProducts", "Accounts");
+ });
+
modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b =>
{
b.Property("Id")
@@ -163,29 +201,21 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
});
- modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
- {
- b.Property("AccountId")
- .HasColumnType("uuid");
-
- b.Property("ProductId")
- .HasColumnType("uuid");
-
- b.HasKey("AccountId", "ProductId");
-
- b.ToTable("ViewedProducts", "Accounts");
- });
-
- modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b =>
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b =>
{
b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account")
- .WithMany()
+ .WithMany("ViewedProducts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
});
+
+ modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b =>
+ {
+ b.Navigation("ViewedProducts");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs
index 0af52b114..4cb45fe90 100644
--- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs
+++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs
@@ -1,4 +1,5 @@
using CustomCADs.Modules.Accounts.Domain.Accounts;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using CustomCADs.Modules.Accounts.Domain.Repositories.Reads;
using CustomCADs.Shared.Domain.Querying;
using CustomCADs.Shared.Domain.TypedIds.Catalog;
@@ -57,14 +58,24 @@ public async Task ExistsByUsernameAsync(string username, CancellationToken
.ConfigureAwait(false);
public async Task ViewedProductsByIdAsync(AccountId id, CancellationToken ct = default)
- => await context.ViewedProducts
- .GetViewedProductsByAccountIdAsync(id, ct)
- .ConfigureAwait(false);
+ => [.. await context.Accounts
+ .Where(x => x.Id == id)
+ .SelectMany(x => x.ViewedProducts)
+ .Select(x => x.ProductId)
+ .ToArrayAsync(ct)
+ .ConfigureAwait(false)
+ ?? []
+ ];
- public async Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default)
- => await context.ViewedProducts
- .GetViewedProductsByAccountUsernrameAsync(username, ct)
- .ConfigureAwait(false);
+ public async Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default)
+ => [.. await context.Accounts
+ .Where(x => x.Username == username)
+ .SelectMany(x => x.ViewedProducts)
+ .OrderByDescending(x => x.ViewedAt)
+ .ToArrayAsync(ct)
+ .ConfigureAwait(false)
+ ?? []
+ ];
public async Task CountAsync(CancellationToken ct = default)
=> await context.Accounts
diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs
index 751a35d98..adeb6a913 100644
--- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs
+++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs
@@ -1,8 +1,6 @@
using CustomCADs.Modules.Accounts.Domain.Accounts;
using CustomCADs.Modules.Accounts.Domain.Accounts.Enums;
-using CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
using CustomCADs.Shared.Domain.Extensions;
-using CustomCADs.Shared.Domain.TypedIds.Catalog;
using CustomCADs.Shared.Domain.ValueObjects;
namespace CustomCADs.Modules.Accounts.Persistence.Repositories.Accounts;
@@ -56,22 +54,4 @@ internal IQueryable WithSorting(Sorting? sorting =
_ => query,
};
}
-
- extension(DbSet set)
- {
- internal async Task GetViewedProductsByAccountIdAsync(AccountId id, CancellationToken ct = default)
- => await set
- .Where(x => x.AccountId == id)
- .Select(x => x.ProductId)
- .ToArrayAsync(ct)
- .ConfigureAwait(false);
-
- internal async Task GetViewedProductsByAccountUsernrameAsync(string username, CancellationToken ct = default)
- => await set
- .Include(x => x.Account)
- .Where(x => x.Account.Username == username)
- .Select(x => x.ProductId)
- .ToArrayAsync(ct)
- .ConfigureAwait(false);
- }
}
diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs
index efd66590a..841217d37 100644
--- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs
+++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs
@@ -1,6 +1,6 @@
using CustomCADs.Modules.Accounts.Domain.Accounts;
+using CustomCADs.Modules.Accounts.Domain.Accounts.Entities;
using CustomCADs.Modules.Accounts.Domain.Repositories.Writes;
-using CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
using CustomCADs.Shared.Domain.TypedIds.Catalog;
namespace CustomCADs.Modules.Accounts.Persistence.Repositories.Accounts;
@@ -10,12 +10,24 @@ public class Writes(AccountsContext context) : IAccountWrites
public async Task AddAsync(Account entity, CancellationToken ct = default)
=> (await context.Accounts.AddAsync(entity, ct).ConfigureAwait(false)).Entity;
- public async Task ViewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default)
+ public async Task ViewProductAsync(AccountId id, ProductId productId, DateTimeOffset viewedAt, CancellationToken ct = default)
=> await context.ViewedProducts.AddAsync(
- entity: ViewedProduct.Create(id, productId),
+ entity: ViewedProduct.Create(id, productId, viewedAt),
cancellationToken: ct
).ConfigureAwait(false);
+ public async Task UnviewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default)
+ {
+ ViewedProduct? viewedProduct = await context.ViewedProducts
+ .FirstOrDefaultAsync(x => x.AccountId == id && x.ProductId == productId, ct)
+ .ConfigureAwait(false);
+
+ if (viewedProduct is not null)
+ {
+ context.ViewedProducts.Remove(viewedProduct);
+ }
+ }
+
public void Remove(Account entity)
- => context.Accounts.Remove(entity);
+ => entity.Delete();
}
diff --git a/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs b/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs
deleted file mode 100644
index a4665cda6..000000000
--- a/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using CustomCADs.Modules.Accounts.Domain.Accounts;
-using CustomCADs.Shared.Domain.TypedIds.Catalog;
-
-namespace CustomCADs.Modules.Accounts.Persistence.ShadowEntities;
-
-public class ViewedProduct
-{
- private ViewedProduct() { }
-
- public AccountId AccountId { get; set; }
- public ProductId ProductId { get; set; }
- public Account Account { get; set; } = null!;
-
- public static ViewedProduct Create(AccountId id, ProductId productId)
- => new() { AccountId = id, ProductId = productId };
-}
diff --git a/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs b/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs
index 3ca7481f8..a6d060a3f 100644
--- a/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs
+++ b/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Carts.API.ActiveCarts.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class ActiveCartsGroup : Group
{
@@ -9,7 +9,7 @@ public ActiveCartsGroup()
{
Configure(Paths.ActiveCarts, x =>
{
- x.Roles(Customer);
+ x.Roles(CustomerRole);
x.Description(x => x.WithTags(Tags[Paths.ActiveCarts]));
});
}
diff --git a/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs b/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs
index fd1cc71fc..4471a191a 100644
--- a/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs
+++ b/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Carts.API.PurchasedCarts.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class PurchasedCartsGroup : Group
{
@@ -9,7 +9,7 @@ public PurchasedCartsGroup()
{
Configure(Paths.PurchasedCarts, x =>
{
- x.Roles(Customer);
+ x.Roles(CustomerRole);
x.Description(x => x.WithTags(Tags[Paths.PurchasedCarts]));
});
}
diff --git a/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs b/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs
index 8b8284b94..8fd1f6d6d 100644
--- a/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs
+++ b/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Catalog.API.Categories.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class CategoriesGroup : Group
{
@@ -9,7 +9,7 @@ public CategoriesGroup()
{
Configure(Paths.Categories, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(x => x.WithTags(Tags[Paths.Categories]));
});
}
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs
index 93dac952f..db157542a 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Admin;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class AdminGroup : SubGroup
{
@@ -9,7 +9,7 @@ public AdminGroup()
{
Configure(Paths.Admin, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Admin}"]));
});
}
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs
index 838c938c9..a865f1cb2 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Creator;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class CreatorGroup : SubGroup
{
@@ -9,7 +9,7 @@ public CreatorGroup()
{
Configure(Paths.Creator, x =>
{
- x.Roles(Contributor, Designer);
+ x.Roles(ContributorRole, DesignerRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Creator}"]));
});
}
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs
index 73ecaff9f..0f94f18f5 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Designer;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class DesignerGroup : SubGroup
{
@@ -9,7 +9,7 @@ public DesignerGroup()
{
Configure(Paths.Designer, x =>
{
- x.Roles(Designer);
+ x.Roles(DesignerRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Designer}"]));
});
}
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs
index c6f8ec914..104e45906 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs
@@ -17,10 +17,13 @@ public override void Configure()
public override async Task HandleAsync(GetGalleryProductRequest req, CancellationToken ct)
{
+ string? viewed = HttpContext.Request.Headers["X-Viewed-Product"];
+
GalleryGetProductByIdDto product = await sender.SendQueryAsync(
query: new GalleryGetProductByIdQuery(
Id: ProductId.New(req.Id),
- CallerId: User.AccountId
+ CallerId: User.AccountId,
+ Viewed: string.Equals(viewed, "true", StringComparison.CurrentCultureIgnoreCase)
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs
index 48aeb3268..078d86b6f 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs
@@ -2,7 +2,7 @@
namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Gallery.Patch.AddTag;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class AddProductTagEndpoint(IRequestSender sender)
: Endpoint
@@ -11,7 +11,7 @@ public override void Configure()
{
Patch("tags/add");
Group();
- Roles(Admin);
+ Roles(AdminRole);
Description(x => x
.WithSummary("Add Tag")
.WithDescription("Adds a Tag to a Product")
diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs
index 29f13f895..564ba36c5 100644
--- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs
+++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs
@@ -2,7 +2,7 @@
namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Gallery.Patch.RemoveTag;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class RemoveProductTagEndpoint(IRequestSender sender)
: Endpoint
@@ -11,7 +11,7 @@ public override void Configure()
{
Patch("tags/remove");
Group();
- Roles(Admin);
+ Roles(AdminRole);
Description(x => x
.WithSummary("Remove Tag")
.WithDescription("Removes a Tag from a Product")
diff --git a/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs b/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs
index 18e289920..07c0c50c1 100644
--- a/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs
+++ b/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Catalog.API.Tags.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class TagGroup : Group
{
@@ -9,7 +9,7 @@ public TagGroup()
{
Configure(Paths.Tags, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(x => x.WithTags(Tags[Paths.Tags]));
});
}
diff --git a/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs b/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs
index 9e89fb32e..0ec5e3060 100644
--- a/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs
+++ b/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs
@@ -59,7 +59,7 @@ public async Task Handle(CreateProductCommand req, CancellationToken
ct: ct
).ConfigureAwait(false);
- if (role is Roles.Designer)
+ if (role is Users.DesignerRole)
{
product.Validate(req.CallerId);
}
@@ -71,7 +71,7 @@ await raiser.RaiseApplicationEventAsync(
TagIds: TagId.Filter(new()
{
[Tags.NewId] = true,
- [Tags.ProfessionalId] = role is Roles.Designer,
+ [Tags.ProfessionalId] = role is Users.DesignerRole,
[Tags.PrintableId] = await sender.SendQueryAsync(
query: new IsCadPrintableByIdQuery(req.CadId),
ct: ct
diff --git a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs
index a0512c2b4..dfdfe9784 100644
--- a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs
+++ b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs
@@ -5,5 +5,6 @@ namespace CustomCADs.Modules.Catalog.Application.Products.Events.Application.Pro
public record ProductViewedApplicationEvent(
ProductId Id,
- AccountId AccountId
+ AccountId AccountId,
+ DateTimeOffset ViewedAt
) : BaseApplicationEvent;
diff --git a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs
index 4ac355d0f..ac559af70 100644
--- a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs
+++ b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs
@@ -11,30 +11,23 @@ public class ProductViewedHandler(IProductReads reads, IUnitOfWork uow, IRequest
{
public async Task HandleAsync(ProductViewedApplicationEvent ae)
{
- Product product = await reads.SingleByIdAsync(ae.Id).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ById(ae.Id);
-
- string username = await sender.SendQueryAsync(
- query: new GetUsernameByIdQuery(ae.AccountId)
+ bool userAlreadyViewed = await sender.SendQueryAsync(
+ query: new GetAccountViewedProductQuery(ae.AccountId, ae.Id)
).ConfigureAwait(false);
+ if (userAlreadyViewed) return;
- (DateTimeOffset _, bool UserTracksViewedProducts, string? _, string? _) = await sender.SendQueryAsync(
- query: new GetAccountInfoByUsernameQuery(username)
+ var account = await sender.SendQueryAsync(
+ query: new GetAccountInfoByUsernameQuery(
+ Username: await sender.SendQueryAsync(
+ query: new GetUsernameByIdQuery(ae.AccountId)
+ ).ConfigureAwait(false)
+ )
).ConfigureAwait(false);
- if (!UserTracksViewedProducts)
- {
- return;
- }
+ if (!account.TrackViewedProducts) return;
- bool userAlreadyViewed = await sender.SendQueryAsync(
- query: new GetAccountViewedProductQuery(ae.AccountId, ae.Id)
- ).ConfigureAwait(false);
-
- if (userAlreadyViewed)
- {
- return;
- }
+ Product product = await reads.SingleByIdAsync(ae.Id).ConfigureAwait(false)
+ ?? throw CustomNotFoundException.ById(ae.Id);
product.AddToViewCount();
await uow.SaveChangesAsync().ConfigureAwait(false);
@@ -42,7 +35,8 @@ public async Task HandleAsync(ProductViewedApplicationEvent ae)
await raiser.RaiseApplicationEventAsync(
@event: new UserViewedProductApplicationEvent(
Id: ae.Id,
- AccountId: ae.AccountId
+ AccountId: ae.AccountId,
+ ViewedAt: ae.ViewedAt
)
).ConfigureAwait(false);
}
diff --git a/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs b/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs
index 847cca584..b18764781 100644
--- a/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs
+++ b/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs
@@ -5,6 +5,8 @@
namespace CustomCADs.Modules.Catalog.Application.Products.Policies;
+using static DomainConstants;
+
public class ProductCadUploadPolicy(IRequestSender sender) : IFileUploadPolicy
{
public FileContextType Type => FileContextType.Product;
@@ -15,7 +17,7 @@ public async Task EnsureUploadGrantedAsync(IFileUploadPolicy.FileContext
query: new GetUserRoleByIdQuery(context.CallerId)
).ConfigureAwait(false);
- if (role is not (DomainConstants.Roles.Contributor or DomainConstants.Roles.Designer))
+ if (role is not (Users.ContributorRole or Users.DesignerRole))
{
throw CustomAuthorizationException.Custom("Must be a Contributor/Designer to upload Product CADs");
}
diff --git a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs
index d9dade8e8..44d1e294c 100644
--- a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs
+++ b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs
@@ -21,12 +21,13 @@ public async Task Handle(GalleryGetProductByIdQuery re
throw CustomStatusException.ById(req.Id);
}
- if (!req.CallerId.IsEmpty())
+ if (req.Viewed && !req.CallerId.IsEmpty())
{
await raiser.RaiseApplicationEventAsync(
@event: new ProductViewedApplicationEvent(
Id: req.Id,
- AccountId: req.CallerId
+ AccountId: req.CallerId,
+ ViewedAt: DateTimeOffset.UtcNow
)
).ConfigureAwait(false);
}
diff --git a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs
index 59a25f2f0..141d7861c 100644
--- a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs
+++ b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs
@@ -4,5 +4,6 @@ namespace CustomCADs.Modules.Catalog.Application.Products.Queries.Internal.Galle
public sealed record GalleryGetProductByIdQuery(
ProductId Id,
- AccountId CallerId
+ AccountId CallerId,
+ bool Viewed
) : IQuery;
diff --git a/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs
index c4dd05b8c..774ae62d8 100644
--- a/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs
+++ b/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Admins;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class AdminGroup : SubGroup
{
@@ -9,7 +9,7 @@ public AdminGroup()
{
Configure(Paths.Admin, x =>
{
- x.Roles(Admin);
+ x.Roles(AdminRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Admin}"]));
});
}
diff --git a/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs
index e2695418d..14d71af2b 100644
--- a/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs
+++ b/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Customers;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class CustomerGroup : SubGroup
{
@@ -9,7 +9,7 @@ public CustomerGroup()
{
Configure(Paths.Customer, x =>
{
- x.Roles(Customer);
+ x.Roles(CustomerRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Customer}"]));
});
}
diff --git a/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs
index 074721819..2227158b5 100644
--- a/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs
+++ b/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Designer;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class DesignerGroup : SubGroup
{
@@ -9,7 +9,7 @@ public DesignerGroup()
{
Configure(Paths.Designer, x =>
{
- x.Roles(Designer);
+ x.Roles(DesignerRole);
x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Designer}"]));
});
}
diff --git a/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs b/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs
index c8c41199c..e948fad58 100644
--- a/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs
+++ b/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs
@@ -45,7 +45,7 @@ await raiser.RaiseApplicationEventAsync(
Link: Notifications.Links.CustomCreated,
AuthorId: custom.BuyerId,
ReceiverIds: [.. await sender.SendQueryAsync(
- query: new GetAccountIdsByRoleQuery(DomainConstants.Roles.Designer),
+ query: new GetAccountIdsByRoleQuery(DomainConstants.Users.DesignerRole),
ct: ct
).ConfigureAwait(false)]
)
diff --git a/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs b/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs
index 5ecaf5e8f..4b472ab16 100644
--- a/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs
+++ b/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs
@@ -21,11 +21,11 @@ public async Task EnsureDownloadGrantedAsync(IFileDownloadPolicy.FileCont
switch (role)
{
- case DomainConstants.Roles.Customer:
+ case DomainConstants.Users.CustomerRole:
await EnsureCustomerDownloadGrantedAsync(context).ConfigureAwait(false);
break;
- case DomainConstants.Roles.Designer:
+ case DomainConstants.Users.DesignerRole:
await EnsureDesignerDownloadGrantedAsync(context).ConfigureAwait(false);
break;
diff --git a/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs b/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs
index fc1003a21..f78ae3340 100644
--- a/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs
+++ b/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs
@@ -5,6 +5,8 @@
namespace CustomCADs.Modules.Customs.Application.Customs.Policies;
+using static DomainConstants;
+
public class CustomCadUploadPolicy(IRequestSender sender) : IFileUploadPolicy
{
public FileContextType Type => FileContextType.Custom;
@@ -15,7 +17,7 @@ public async Task EnsureUploadGrantedAsync(IFileUploadPolicy.FileContext
query: new GetUserRoleByIdQuery(context.CallerId)
).ConfigureAwait(false);
- if (role is not DomainConstants.Roles.Designer)
+ if (role is not Users.DesignerRole)
{
throw CustomAuthorizationException.Custom("Must be a Designer to upload a Custom's CAD.");
}
diff --git a/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs b/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs
index cacb2b89a..c478d33df 100644
--- a/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs
+++ b/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs
@@ -2,7 +2,7 @@
namespace CustomCADs.Modules.Delivery.API.Shipments.Endpoints.Get.Waybill;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class GetShipmentWaybillEndpoint(IRequestSender sender)
: Endpoint
@@ -11,7 +11,7 @@ public override void Configure()
{
Get("{id}/waybill");
Group();
- Roles(Designer);
+ Roles(DesignerRole);
Description(x => x
.WithSummary("Waybill")
.WithDescription("Download this Shipment's waybill")
diff --git a/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs b/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs
index ce8965c1e..4feced555 100644
--- a/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs
+++ b/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Delivery.API.Shipments.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class ShipmentsGroup : Group
{
@@ -9,7 +9,7 @@ public ShipmentsGroup()
{
Configure(Paths.Shipments, x =>
{
- x.Roles(Customer);
+ x.Roles(CustomerRole);
x.Description(x => x.WithTags(Tags[Paths.Shipments]));
});
}
diff --git a/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs b/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs
index 635691ef4..fb73eac62 100644
--- a/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs
+++ b/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs
@@ -15,19 +15,19 @@ public async Task Handle(CancelShipmentCommand req, CancellationToken ct)
{
Shipment shipment = await reads.SingleByIdAsync(req.Id, track: false, ct).ConfigureAwait(false)
?? throw CustomNotFoundException.ById(req.Id);
+ shipment.Cancel();
- if (shipment is not { Status: ShipmentStatus.Active, Reference.Id: not null })
+ if (shipment is not { Reference.Id: not null })
{
throw CustomStatusException.ById(req.Id);
}
- shipment.Cancel();
- await uow.SaveChangesAsync(ct).ConfigureAwait(false);
-
await delivery.CancelAsync(
shipmentId: shipment.Reference.Id,
comment: req.Comment,
ct: ct
).ConfigureAwait(false);
+
+ await uow.SaveChangesAsync(ct).ConfigureAwait(false);
}
}
diff --git a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs
index 682b11be0..f6150d019 100644
--- a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs
+++ b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs
@@ -16,8 +16,7 @@ public async Task Handle(GetShipmentWaybillQuery req, CancellationToken
Shipment shipment = await reads.SingleByIdAsync(req.Id, track: false, ct: ct).ConfigureAwait(false)
?? throw CustomNotFoundException.ById(req.Id);
- Guid headDesignerId = Guid.Parse(DesignerAccountId);
- if (req.CallerId.Value != headDesignerId)
+ if (req.CallerId != HeadDesignerAccountId)
{
throw CustomAuthorizationException.ById(req.Id);
}
diff --git a/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs b/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs
index 10db667c9..9a0a6c703 100644
--- a/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs
+++ b/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Files.API.Cads.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class CadsGroup : Group
{
@@ -9,7 +9,7 @@ public CadsGroup()
{
Configure(Paths.Cads, x =>
{
- x.Roles(Customer, Contributor, Designer, Admin);
+ x.Roles(CustomerRole, ContributorRole, DesignerRole, AdminRole);
x.Description(x => x.WithTags(Tags[Paths.Cads]));
});
}
diff --git a/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs b/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs
index 396319778..2994771a5 100644
--- a/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs
+++ b/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs
@@ -1,7 +1,7 @@
namespace CustomCADs.Modules.Files.API.Images.Endpoints;
using static APIConstants;
-using static DomainConstants.Roles;
+using static DomainConstants.Users;
public class ImagesGroup : Group
{
@@ -9,7 +9,7 @@ public ImagesGroup()
{
Configure(Paths.Images, x =>
{
- x.Roles(Customer, Contributor, Designer, Admin);
+ x.Roles(CustomerRole, ContributorRole, DesignerRole, AdminRole);
x.Description(x => x.WithTags(Tags[Paths.Images]));
});
}
diff --git a/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs b/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs
deleted file mode 100644
index e868fc6d0..000000000
--- a/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using CustomCADs.Shared.Domain.Querying;
-
-namespace CustomCADs.Modules.Files.Domain.Repositories.Reads;
-
-public record ImageQuery(
- Pagination Pagination,
- ImageId[]? Ids = null
-);
diff --git a/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs b/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs
index e89fc684f..4e7934cac 100644
--- a/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs
+++ b/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs
@@ -58,7 +58,7 @@ internal EntityTypeBuilder SetValidaitons()
internal EntityTypeBuilder SetSeeding()
{
- AccountId adminId = AccountId.New(Guid.Parse(DomainConstants.Users.AdminAccountId));
+ AccountId adminId = DomainConstants.Users.AdminAccountId;
builder.HasData([
Image.CreateWithId(PLA, "textures/pla.webp", "image/webp", adminId),
Image.CreateWithId(ABS, "textures/abs.webp", "image/webp", adminId),
diff --git a/src/Modules/Identity/API/DependencyInjection.cs b/src/Modules/Identity/API/DependencyInjection.cs
index 7df22110b..aa4b4abd8 100644
--- a/src/Modules/Identity/API/DependencyInjection.cs
+++ b/src/Modules/Identity/API/DependencyInjection.cs
@@ -60,26 +60,16 @@ await context.HttpContext.RequestServices
.GetRequiredService()
.UnauthorizedResponseAsync(
context: context.HttpContext,
- ex: new UnauthorizedAccessException()
+ ex: new UnauthorizedAccessException("User needs an account to access this resource!")
).ConfigureAwait(false);
},
- OnForbidden = async context =>
- {
- await context.HttpContext.RequestServices
+ OnForbidden = async context => await context.HttpContext.RequestServices
.GetRequiredService()
.ForbiddenResponseAsync(
context: context.HttpContext,
- ex: new AccessViolationException()
- ).ConfigureAwait(false);
- },
-
- OnTokenValidated = context =>
- {
- ClaimsIdentity claimsIdentity = new(context.Principal?.Claims ?? [], AuthScheme);
- context.HttpContext.User = new ClaimsPrincipal(claimsIdentity);
- return Task.CompletedTask;
- },
+ ex: new UnauthorizedAccessException("User doesn't have permission to access this resource!")
+ ).ConfigureAwait(false),
};
});
}
diff --git a/src/Modules/Identity/API/Dtos/FingerprintResponse.cs b/src/Modules/Identity/API/Dtos/FingerprintResponse.cs
new file mode 100644
index 000000000..6172672bd
--- /dev/null
+++ b/src/Modules/Identity/API/Dtos/FingerprintResponse.cs
@@ -0,0 +1,9 @@
+namespace CustomCADs.Modules.Identity.API.Dtos;
+
+public record FingerprintResponse(
+ Guid Id,
+ string Device,
+ string? Location,
+ bool DeleteAllowed,
+ DateTimeOffset IssuedAt
+);
diff --git a/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs b/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs
new file mode 100644
index 000000000..5325c19f3
--- /dev/null
+++ b/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs
@@ -0,0 +1,3 @@
+namespace CustomCADs.Modules.Identity.API.Dtos;
+
+public record ViewedProductResponse(Guid Id, DateTimeOffset ViewedAt);
diff --git a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs
index 077390dc1..ef54d3abe 100644
--- a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs
@@ -12,7 +12,6 @@ public override void Configure()
Delete("");
Group();
Description(x => x
- .WithName(IdentityNames.DeleteAccount)
.WithSummary("Delete")
.WithDescription("Delete your account")
);
@@ -21,7 +20,7 @@ public override void Configure()
public override async Task HandleAsync(CancellationToken ct)
{
await sender.SendCommandAsync(
- command: new DeleteUserCommand(Username: User.Name),
+ command: new DeleteUserCommand(User.AccountId),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs
new file mode 100644
index 000000000..680e4362c
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs
@@ -0,0 +1,33 @@
+using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints;
+using CustomCADs.Shared.Domain.TypedIds.Identity;
+
+namespace CustomCADs.Modules.Identity.API.Identity.Delete.Fingerprints;
+
+public sealed class DeleteFingerprintsEndpoint(IRequestSender sender)
+ : Endpoint
+{
+ public override void Configure()
+ {
+ Delete("fingerprint");
+ Group();
+ Description(x => x
+ .WithSummary("Remove Fingerprint")
+ .WithDescription("Remove a Fingerprint")
+ );
+ }
+
+ public override async Task HandleAsync(DeleteFingerprintsRequest req, CancellationToken ct)
+ {
+ await sender.SendCommandAsync(
+ command: new DeleteFingerprintCommand(
+ RefreshTokenId: RefreshTokenId.New(req.RefreshTokenId),
+ CurrentRefreshToken: HttpContext.RefreshTokenCookie,
+ CallerId: User.AccountId
+ ),
+ ct: ct
+ ).ConfigureAwait(false);
+
+ await Send.NoContentAsync().ConfigureAwait(false);
+ }
+}
+
diff --git a/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs
new file mode 100644
index 000000000..9f6f22bc9
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs
@@ -0,0 +1,3 @@
+namespace CustomCADs.Modules.Identity.API.Identity.Delete.Fingerprints;
+
+public sealed record DeleteFingerprintsRequest(Guid RefreshTokenId);
diff --git a/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs
new file mode 100644
index 000000000..95c1eed1d
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs
@@ -0,0 +1,31 @@
+using CustomCADs.Shared.Application.UseCases.Accounts.Commands;
+using CustomCADs.Shared.Domain.TypedIds.Catalog;
+
+namespace CustomCADs.Modules.Identity.API.Identity.Delete.ViewedProducts;
+
+public sealed class DeleteViewedProductEndpoint(IRequestSender sender)
+ : Endpoint
+{
+ public override void Configure()
+ {
+ Delete("viewed-product");
+ Group();
+ Description(x => x
+ .WithSummary("Remove Viewed Product")
+ .WithDescription("Remove a Viewed Product")
+ );
+ }
+
+ public override async Task HandleAsync(DeleteViewedProductRequest req, CancellationToken ct)
+ {
+ await sender.SendCommandAsync(
+ command: new DeleteViewedProductCommand(
+ ProductId: ProductId.New(req.ProductId),
+ CallerId: User.AccountId
+ ),
+ ct: ct
+ ).ConfigureAwait(false);
+
+ await Send.NoContentAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs
new file mode 100644
index 000000000..9f79a962f
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs
@@ -0,0 +1,3 @@
+namespace CustomCADs.Modules.Identity.API.Identity.Delete.ViewedProducts;
+
+public sealed record DeleteViewedProductRequest(Guid ProductId);
diff --git a/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs b/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs
index 008027556..689fa94cc 100644
--- a/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs
@@ -9,7 +9,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.Authentication)
.WithSummary("AuthN")
.WithDescription("See if you're logged in")
);
diff --git a/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs b/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs
index da4fae1e5..517bed875 100644
--- a/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs
@@ -9,7 +9,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.Authorization)
.WithSummary("AuthZ")
.WithDescription("See what Role you're logged in with")
);
diff --git a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs
index b0f2038ae..1582bb8fd 100644
--- a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs
@@ -11,7 +11,6 @@ public override void Configure()
Get("download-info");
Group();
Description(x => x
- .WithName(IdentityNames.DownloadInfo)
.WithSummary("Download Info")
.WithDescription("Download all your persisted info")
);
@@ -20,7 +19,7 @@ public override void Configure()
public override async Task HandleAsync(CancellationToken ct)
{
GetUserByUsernameDto user = await sender.SendQueryAsync(
- query: new GetUserByUsernameQuery(User.Name),
+ query: new GetUserByUsernameQuery(User.AccountId, HttpContext.RefreshTokenCookie),
ct: ct
).ConfigureAwait(false);
@@ -37,7 +36,8 @@ await JsonSerializer.SerializeAsync(
firstName = user.FirstName,
lastName = user.LastName,
trackViewedProducts = user.TrackViewedProducts,
- viewedProductIds = user.ViewedProductIds.Select(x => x.Value),
+ fingerprints = user.Fingerprints.Select(x => new { id = x.Id.Value, device = x.Device, location = x.Location, issuedAt = x.IssuedAt }),
+ viewedProducts = user.ViewedProducts.Select(x => new { id = x.Id.Value, viewedAt = x.ViewedAt }),
},
options: new() { WriteIndented = true }
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs
index b55e3334c..a55d5ddfb 100644
--- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs
@@ -10,7 +10,6 @@ public override void Configure()
Get("my-account");
Group();
Description(x => x
- .WithName(IdentityNames.MyAccount)
.WithSummary("My Account")
.WithDescription("See your Account's details")
);
@@ -19,7 +18,7 @@ public override void Configure()
public override async Task HandleAsync(CancellationToken ct)
{
GetUserByUsernameDto user = await sender.SendQueryAsync(
- query: new GetUserByUsernameQuery(User.Name),
+ query: new GetUserByUsernameQuery(User.AccountId, HttpContext.RefreshTokenCookie),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs
index 43cb5ec20..28a4eac76 100644
--- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs
+++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs
@@ -1,4 +1,7 @@
+using CustomCADs.Modules.Identity.API.Dtos;
+using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername;
+using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
namespace CustomCADs.Modules.Identity.API.Identity.Get.MyAccount;
@@ -13,6 +16,23 @@ public override MyAccountResponse FromEntity(GetUserByUsernameDto user)
LastName: user.LastName,
Email: user.Email.Value,
TrackViewedProducts: user.TrackViewedProducts,
- CreatedAt: user.CreatedAt
+ CreatedAt: user.CreatedAt,
+ ViewedProducts: [.. user.ViewedProducts.Select(ToResponse)],
+ Fingerprints: [.. user.Fingerprints.Select(ToResponse)]
+ );
+
+ private static ViewedProductResponse ToResponse(ViewedProductDto product)
+ => new(
+ Id: product.Id.Value,
+ ViewedAt: product.ViewedAt
+ );
+
+ private static FingerprintResponse ToResponse(FingerprintDto fingerprint)
+ => new(
+ Id: fingerprint.Id.Value,
+ Device: fingerprint.Device,
+ Location: fingerprint.Location,
+ DeleteAllowed: fingerprint.DeleteAllowed,
+ IssuedAt: fingerprint.IssuedAt
);
}
diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs
index 2d6ff7322..fcc974ab3 100644
--- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs
+++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs
@@ -1,3 +1,5 @@
+using CustomCADs.Modules.Identity.API.Dtos;
+
namespace CustomCADs.Modules.Identity.API.Identity.Get.MyAccount;
public record MyAccountResponse(
@@ -8,5 +10,7 @@ public record MyAccountResponse(
string? LastName,
string Email,
bool TrackViewedProducts,
- DateTimeOffset CreatedAt
+ DateTimeOffset CreatedAt,
+ ViewedProductResponse[] ViewedProducts,
+ FingerprintResponse[] Fingerprints
);
diff --git a/src/Modules/Identity/API/Identity/IdentityNames.cs b/src/Modules/Identity/API/Identity/IdentityNames.cs
deleted file mode 100644
index a420d38bf..000000000
--- a/src/Modules/Identity/API/Identity/IdentityNames.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace CustomCADs.Modules.Identity.API.Identity;
-
-public static class IdentityNames
-{
- public const string Register = "Register";
- public const string ConfirmEmail = "Confirm Email";
- public const string RetryConfirmEmail = "Retry Confirm Email";
-
- public const string Login = "Login";
- public const string Refresh = "Refresh";
- public const string ForgotPassword = "Forgot Password";
- public const string ResetPassword = "Reset Password";
- public const string Logout = "Logout";
- public const string ChangeUsername = "Change Username";
- public const string ToggleViewedProductsTracking = "Toggle Viewed Products Tracking";
- public const string DeleteAccount = "Delete Account";
-
- public const string Authentication = "Authentication";
- public const string Authorization = "Authorization";
- public const string MyAccount = "My Account";
- public const string DownloadInfo = "Download Info";
-}
diff --git a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs b/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs
deleted file mode 100644
index 94d1c73ea..000000000
--- a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace CustomCADs.Modules.Identity.API.Identity.Patch.ChangeUsername;
-
-public sealed record ChangeUsernameRequest(
- string Username
-);
diff --git a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs
similarity index 50%
rename from src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs
rename to src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs
index 038a31396..42716b5f7 100644
--- a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs
@@ -1,27 +1,28 @@
using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername;
-namespace CustomCADs.Modules.Identity.API.Identity.Patch.ChangeUsername;
+namespace CustomCADs.Modules.Identity.API.Identity.Patch.Names;
-public sealed class ChangeUsernameEndpoint(IRequestSender sender)
- : Endpoint
+public sealed class ChangeNamesEndpoint(IRequestSender sender)
+ : Endpoint
{
public override void Configure()
{
- Patch("username");
+ Patch("names");
Group();
Description(x => x
- .WithName(IdentityNames.ChangeUsername)
.WithSummary("Change Username")
.WithDescription("Change your Username")
);
}
- public override async Task HandleAsync(ChangeUsernameRequest req, CancellationToken ct)
+ public override async Task HandleAsync(ChangeNamesRequest req, CancellationToken ct)
{
await sender.SendCommandAsync(
command: new ChangeUsernameCommand(
- Username: User.Name,
- NewUsername: req.Username
+ Id: User.AccountId,
+ Username: req.Username,
+ FirstName: req.FirstName,
+ LastName: req.LastName
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs b/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs
new file mode 100644
index 000000000..6187bd24e
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs
@@ -0,0 +1,7 @@
+namespace CustomCADs.Modules.Identity.API.Identity.Patch.Names;
+
+public sealed record ChangeNamesRequest(
+ string Username,
+ string? FirstName,
+ string? LastName
+);
diff --git a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs
index bb216759d..9ca86b99f 100644
--- a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs
@@ -10,7 +10,6 @@ public override void Configure()
Patch("viewed-products");
Group();
Description(x => x
- .WithName(IdentityNames.ToggleViewedProductsTracking)
.WithSummary("Viewed Products Tracking")
.WithDescription("Toggle whether the Products you View get Tracked")
);
@@ -20,7 +19,8 @@ public override async Task HandleAsync(CancellationToken ct)
{
await sender.SendCommandAsync(
command: new ToggleViewedProductsTrackingCommand(
- Username: User.Name
+ Username: User.Name,
+ CallerId: User.AccountId
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs b/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs
index 878a78694..38f94c914 100644
--- a/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs
@@ -12,7 +12,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.ForgotPassword)
.WithSummary("Reset Password Email")
.WithDescription("Receive an Email with a link to reset your Password")
.WithMetadata(new SkipIdempotencyAttribute())
diff --git a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs
index a14147887..33558dd2c 100644
--- a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs
@@ -1,11 +1,12 @@
-using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login;
+using CustomCADs.Modules.Identity.Application.Contracts;
+using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login;
using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Shared.API.Attributes;
using Microsoft.Extensions.Options;
namespace CustomCADs.Modules.Identity.API.Identity.Post.Login;
-public sealed class LoginEndpoint(IRequestSender sender, IOptions settings)
+public sealed class LoginEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings)
: Endpoint
{
public override void Configure()
@@ -14,7 +15,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.Login)
.WithSummary("Login")
.WithDescription("Log in to your account")
.WithMetadata(new SkipIdempotencyAttribute())
@@ -27,7 +27,8 @@ public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
command: new LoginUserCommand(
Username: req.Username,
Password: req.Password,
- LongerExpireTime: req.RememberMe ?? false
+ LongerExpireTime: req.RememberMe ?? false,
+ Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary)
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs
index 8fa45935c..b2fe18a25 100644
--- a/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs
@@ -13,7 +13,6 @@ public override void Configure()
Post("logout");
Group();
Description(x => x
- .WithName(IdentityNames.Logout)
.WithSummary("Log out")
.WithDescription("Log out of your account")
.WithMetadata(new SkipIdempotencyAttribute())
diff --git a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs
index 046eb6015..bfe25e195 100644
--- a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs
@@ -1,11 +1,12 @@
-using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh;
+using CustomCADs.Modules.Identity.Application.Contracts;
+using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh;
using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Shared.API.Attributes;
using Microsoft.Extensions.Options;
namespace CustomCADs.Modules.Identity.API.Identity.Post.RefreshToken;
-public sealed class RefreshTokenEndpoint(IRequestSender sender, IOptions settings)
+public sealed class RefreshTokenEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings)
: EndpointWithoutRequest
{
public override void Configure()
@@ -14,7 +15,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.Refresh)
.WithSummary("Refresh")
.WithDescription("Refresh your login")
.WithMetadata(new SkipIdempotencyAttribute())
@@ -25,7 +25,8 @@ public override async Task HandleAsync(CancellationToken ct)
{
TokensDto tokens = await sender.SendCommandAsync(
command: new RefreshUserCommand(
- Token: HttpContext.RefreshTokenCookie
+ Token: HttpContext.RefreshTokenCookie,
+ Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary)
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs
index f0e5019c9..ae4d3a373 100644
--- a/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs
@@ -13,7 +13,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.Register)
.WithSummary("Register")
.WithDescription("Register an Account")
.WithMetadata(new SkipIdempotencyAttribute())
diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs
deleted file mode 100644
index f83271ea4..000000000
--- a/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using CustomCADs.Shared.Application;
-using CustomCADs.Shared.Domain;
-using FluentValidation;
-
-namespace CustomCADs.Modules.Identity.API.Identity.Post.Register;
-
-using static ApplicationConstants.FluentMessages;
-using static DomainConstants;
-
-public class RegisterRequestValidator : Validator
-{
- public RegisterRequestValidator()
- {
- RuleFor(x => x.Role)
- .Must(x => x is Roles.Customer or Roles.Contributor);
-
- RuleFor(x => x.ConfirmPassword)
- .NotEmpty().WithMessage(RequiredError)
- .Equal(x => x.Password).WithMessage("Passwords must be equal!");
- }
-}
diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs
new file mode 100644
index 000000000..fb313056b
--- /dev/null
+++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs
@@ -0,0 +1,35 @@
+using CustomCADs.Modules.Identity.Application.Contracts;
+using CustomCADs.Shared.Application;
+using CustomCADs.Shared.Domain;
+using FluentValidation;
+
+namespace CustomCADs.Modules.Identity.API.Identity.Post.Register;
+
+using static ApplicationConstants.FluentMessages;
+using static DomainConstants.Users;
+
+public class RegisterRequestValidator : Validator
+{
+ public RegisterRequestValidator()
+ {
+ RuleFor(x => x.Role)
+ .Must(x => x is CustomerRole or ContributorRole).WithMessage("""must be either "Customer" or "Contributor" """);
+
+
+ RuleFor(x => x)
+ .MustAsync(
+ async (command, ct) => !await Resolve()
+ .GetExistsByUsernameAsync(command.Username)
+ .ConfigureAwait(false)
+ ).WithMessage("Cannot register a User with a Duplicate Username")
+ .MustAsync(
+ async (command, ct) => !await Resolve()
+ .GetExistsByEmailAsync(command.Email)
+ .ConfigureAwait(false)
+ ).WithMessage("Cannot register a User with a Duplicate Email");
+
+ RuleFor(x => x.ConfirmPassword)
+ .NotEmpty().WithMessage(RequiredError)
+ .Equal(x => x.Password).WithMessage("must be equal to Password!");
+ }
+}
diff --git a/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs b/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs
index 105029972..f3eb214f6 100644
--- a/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs
@@ -12,7 +12,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.ResetPassword)
.WithSummary("Reset Password")
.WithDescription("Reset your Password with the token from the email")
.WithMetadata(new SkipIdempotencyAttribute())
diff --git a/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs b/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs
index 490f2bc3a..9f15451c0 100644
--- a/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs
@@ -12,7 +12,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.RetryConfirmEmail)
.WithSummary("Retry Send Email")
.WithDescription("Receive another verification email")
.WithMetadata(new SkipIdempotencyAttribute())
diff --git a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs
index 77fcb07a2..bf66cb17e 100644
--- a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs
+++ b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs
@@ -1,11 +1,12 @@
-using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail;
+using CustomCADs.Modules.Identity.Application.Contracts;
+using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail;
using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Shared.API.Attributes;
using Microsoft.Extensions.Options;
namespace CustomCADs.Modules.Identity.API.Identity.Post.VerifyEmail;
-public sealed class ConfirmEmailEndpoint(IRequestSender sender, IOptions settings)
+public sealed class ConfirmEmailEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings)
: Endpoint
{
public override void Configure()
@@ -14,7 +15,6 @@ public override void Configure()
Group();
AllowAnonymous();
Description(x => x
- .WithName(IdentityNames.ConfirmEmail)
.WithSummary("Confirm Email")
.WithDescription("Confirm the verification email")
.WithMetadata(new SkipIdempotencyAttribute())
@@ -26,7 +26,8 @@ public override async Task HandleAsync(ConfirmEmailRequest req, CancellationToke
TokensDto tokens = await sender.SendCommandAsync(
command: new VerifyUserEmailCommand(
Username: req.Username,
- Token: req.Token.Replace(' ', '+')
+ Token: req.Token.Replace(' ', '+'),
+ Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary)
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/API/SSODependencyInjection.cs b/src/Modules/Identity/API/SSODependencyInjection.cs
index b2cfdb80a..fd1779582 100644
--- a/src/Modules/Identity/API/SSODependencyInjection.cs
+++ b/src/Modules/Identity/API/SSODependencyInjection.cs
@@ -1,4 +1,5 @@
using CustomCADs.Modules.Identity.API;
+using CustomCADs.Modules.Identity.Application.Contracts;
using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.SSO.Register;
using CustomCADs.Modules.Identity.Application.Users.Dtos;
using Microsoft.AspNetCore.Authentication;
@@ -66,12 +67,18 @@ internal void Configure(SSOClientSettings sso, string domain, string provider, s
IServiceProvider sp = ctx.Request.HttpContext.RequestServices;
if (ctx.Principal is null) throw new Exception("Claims required");
- ctx.Principal.ExtractUserFromSSO(out string email, out string username);
+ ctx.Principal.ExtractUserFromSSO(
+ out string email,
+ out string username,
+ out string? firstName,
+ out string? lastName
+ );
string? role = null;
ctx.Properties?.Items.TryGetValue("role", out role);
IRequestSender sender = sp.GetRequiredService();
+ IFingerprintService fingerprint = sp.GetRequiredService();
CookieSettings cookie = sp.GetRequiredService>().Value;
ctx.HttpContext.SaveAllCookies(
@@ -80,9 +87,12 @@ internal void Configure(SSOClientSettings sso, string domain, string provider, s
tokens: await sender.SendCommandAsync(
command: new SingleSignOnUserCommand(
Role: role,
+ FirstName: firstName,
+ LastName: lastName,
Username: username,
Email: email,
- Provider: provider
+ Provider: provider,
+ Fingerprint: fingerprint.GetFingerprint(ctx.Request.HeadersDictionary)
),
ct: ct
).ConfigureAwait(false)
diff --git a/src/Modules/Identity/Application/Contracts/IFingerprintService.cs b/src/Modules/Identity/Application/Contracts/IFingerprintService.cs
new file mode 100644
index 000000000..bf660d8eb
--- /dev/null
+++ b/src/Modules/Identity/Application/Contracts/IFingerprintService.cs
@@ -0,0 +1,8 @@
+namespace CustomCADs.Modules.Identity.Application.Contracts;
+
+using Domain.Users.ValueObjects;
+
+public interface IFingerprintService
+{
+ Fingerprint GetFingerprint(Dictionary headers);
+}
diff --git a/src/Modules/Identity/Application/Contracts/IUserService.cs b/src/Modules/Identity/Application/Contracts/IUserService.cs
index b9a7d7870..b8b1944c3 100644
--- a/src/Modules/Identity/Application/Contracts/IUserService.cs
+++ b/src/Modules/Identity/Application/Contracts/IUserService.cs
@@ -6,6 +6,7 @@ namespace CustomCADs.Modules.Identity.Application.Contracts;
public interface IUserService
{
#region GetUserByX
+ Task GetByAccountIdAsync(AccountId accountId);
Task GetByUsernameAsync(string username);
Task GetByEmailAsync(string email);
Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(string token);
@@ -14,20 +15,21 @@ public interface IUserService
#region GetX
Task GetExistsByUsernameAsync(string username);
Task GetExistsByEmailAsync(string email);
- Task GetAccountIdAsync(string username);
+ Task GetIsSSOByEmailAsync(string email);
Task GetIsLockedOutAsync(string username);
#endregion
#region Lifecycle
Task CreateAsync(User user, string password);
Task CreateSSOAsync(User user, string provider);
- Task DeleteAsync(string username);
+ Task DeleteAsync(AccountId id);
#endregion
#region Mutation
Task CheckPasswordAsync(string username, string password);
Task UpdateUsernameAsync(UserId id, string username);
Task SaveRefreshTokensAsync(User user);
+ Task RevokeRefreshTokenAsync(RefreshTokenId refreshTokenId);
Task RevokeRefreshTokenAsync(string token);
#endregion
diff --git a/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs b/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs
new file mode 100644
index 000000000..07e93f6ac
--- /dev/null
+++ b/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs
@@ -0,0 +1,15 @@
+using CustomCADs.Shared.Application.Exceptions;
+
+namespace CustomCADs.Modules.Identity.Application.Extensions;
+
+public static class CustomAuthorizationExceptionExtensions
+{
+ extension(CustomAuthorizationException ex)
+ {
+ public static CustomAuthorizationException NoRefreshToken()
+ => CustomAuthorizationException.Custom("No Refresh Token found.");
+
+ public static CustomAuthorizationException RefreshTokenExpired()
+ => CustomAuthorizationException.Custom("Refresh Token found, but expired.");
+ }
+}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs
index 28d272ef6..fbe56d7e5 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs
@@ -1,6 +1,10 @@
-namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername;
+using CustomCADs.Shared.Domain.TypedIds.Accounts;
+
+namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername;
public sealed record ChangeUsernameCommand(
+ AccountId Id,
string Username,
- string NewUsername
+ string? FirstName,
+ string? LastName
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs
index 3690a97fa..4962f973b 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs
@@ -10,15 +10,16 @@ IEventRaiser raiser
{
public async Task Handle(ChangeUsernameCommand req, CancellationToken ct)
{
- User user = await service.GetByUsernameAsync(req.Username).ConfigureAwait(false);
+ User user = await service.GetByAccountIdAsync(req.Id).ConfigureAwait(false);
- user.SetUsername(req.NewUsername);
+ user.SetUsername(req.Username);
await service.UpdateUsernameAsync(user.Id, user.Username).ConfigureAwait(false);
await raiser.RaiseApplicationEventAsync(
@event: new UserEditedApplicationEvent(
Id: user.AccountId,
- Username: user.Username
+ Username: user.Username,
+ Names: new(req.FirstName, req.LastName)
)
).ConfigureAwait(false);
}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs
new file mode 100644
index 000000000..17597c5b9
--- /dev/null
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs
@@ -0,0 +1,43 @@
+using CustomCADs.Shared.Application.Abstractions.Requests.Sender;
+using CustomCADs.Shared.Application.Abstractions.Requests.Validator;
+using CustomCADs.Shared.Application.Exceptions;
+using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
+using FluentValidation;
+
+namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername;
+
+public sealed class ChangeUsernameValidator : CommandValidator
+{
+ public ChangeUsernameValidator(IRequestSender sender)
+ {
+ RuleFor(x => x)
+ .MustAsync(async (command, ct) =>
+ {
+ bool usernameIsDuplicate = await sender.SendQueryAsync(
+ query: new GetAccountExistsByUsernameQuery(command.Username),
+ ct: ct
+ ).ConfigureAwait(false);
+
+ return !usernameIsDuplicate;
+ })
+ .WhenAsync(async (command, ct) =>
+ {
+ try
+ {
+ var info = await sender.SendQueryAsync(
+ query: new GetAccountInfoByUsernameQuery(command.Username),
+ ct: ct
+ ).ConfigureAwait(false);
+
+ // Don't run if the account with this username is the user's own
+ return info.Id != command.Id;
+ }
+ catch (Exception ex) when (ex.GetType().IsGenericType && ex.GetType().GetGenericTypeDefinition() == typeof(CustomNotFoundException<>))
+ {
+ // Don't run if the account with this username doesn't exist
+ return false;
+ }
+ })
+ .WithMessage("Username already taken");
+ }
+}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs
index 59f0dc1f5..3fcb63cb3 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs
@@ -1,5 +1,5 @@
+using CustomCADs.Shared.Domain.TypedIds.Accounts;
+
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete;
-public sealed record DeleteUserCommand(
- string Username
-) : ICommand;
+public sealed record DeleteUserCommand(AccountId CallerId) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs
index ce3546f6c..c6862c2cc 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs
@@ -1,6 +1,5 @@
using CustomCADs.Shared.Application.Abstractions.Events;
using CustomCADs.Shared.Application.Events.Identity;
-using CustomCADs.Shared.Domain.TypedIds.Accounts;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete;
@@ -11,11 +10,10 @@ IEventRaiser raiser
{
public async Task Handle(DeleteUserCommand req, CancellationToken ct = default)
{
- AccountId accountId = await service.GetAccountIdAsync(req.Username).ConfigureAwait(false);
- await service.DeleteAsync(req.Username).ConfigureAwait(false);
+ await service.DeleteAsync(req.CallerId).ConfigureAwait(false);
await raiser.RaiseApplicationEventAsync(
- @event: new UserDeletedApplicationEvent(accountId)
+ @event: new UserDeletedApplicationEvent(req.CallerId)
).ConfigureAwait(false);
}
}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs
new file mode 100644
index 000000000..9fbe80cc8
--- /dev/null
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs
@@ -0,0 +1,9 @@
+using CustomCADs.Shared.Domain.TypedIds.Accounts;
+
+namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints;
+
+public record DeleteFingerprintCommand(
+ RefreshTokenId RefreshTokenId,
+ string? CurrentRefreshToken,
+ AccountId CallerId
+) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs
new file mode 100644
index 000000000..ded224345
--- /dev/null
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs
@@ -0,0 +1,28 @@
+using CustomCADs.Modules.Identity.Application.Extensions;
+using CustomCADs.Modules.Identity.Domain.Users.Entities;
+using CustomCADs.Shared.Application.Exceptions;
+
+namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints;
+
+public class DeleteFingerprintHandler(IUserService service) : ICommandHandler
+{
+ public async Task Handle(DeleteFingerprintCommand req, CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(req.CurrentRefreshToken))
+ {
+ throw CustomAuthorizationException.NoRefreshToken();
+ }
+
+ (User user, RefreshToken refreshToken) = await service.GetByRefreshTokenAsync(req.CurrentRefreshToken).ConfigureAwait(false);
+ if (req.CallerId != user.AccountId)
+ {
+ throw CustomAuthorizationException.ById(user.AccountId);
+ }
+
+ if (refreshToken.Id == req.RefreshTokenId)
+ {
+ throw new CustomException("Cannot delete User's current RefreshToken");
+ }
+ await service.RevokeRefreshTokenAsync(req.RefreshTokenId).ConfigureAwait(false);
+ }
+}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs
index 7fa489722..f4e32773b 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs
@@ -1,9 +1,11 @@
using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login;
public sealed record LoginUserCommand(
string Username,
string Password,
- bool LongerExpireTime
+ bool LongerExpireTime,
+ Fingerprint Fingerprint
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs
index 92ede72b5..93b3b8267 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs
@@ -38,7 +38,7 @@ public async Task Handle(LoginUserCommand req, CancellationToken ct)
}
RefreshToken rt = tokenService.IssueRefreshToken(
- createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false)
+ createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false)
);
await service.SaveRefreshTokensAsync(user).ConfigureAwait(false);
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs
index d48057538..b57d1bb9e 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs
@@ -1,6 +1,8 @@
using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh;
public sealed record RefreshUserCommand(
- string? Token
+ string? Token,
+ Fingerprint Fingerprint
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs
index 0e5bcc32a..091052b5c 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs
@@ -1,4 +1,5 @@
-using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Application.Extensions;
+using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Modules.Identity.Domain.Users.Entities;
using CustomCADs.Shared.Application.Exceptions;
@@ -13,20 +14,15 @@ public async Task Handle(RefreshUserCommand req, CancellationToken ct
{
if (string.IsNullOrEmpty(req.Token))
{
- throw CustomAuthorizationException.Custom("No Refresh Token found.");
+ throw CustomAuthorizationException.NoRefreshToken();
}
(User User, RefreshToken RefreshToken) = await service.GetByRefreshTokenAsync(req.Token).ConfigureAwait(false);
if (RefreshToken.ExpiresAt < DateTime.UtcNow)
{
- throw CustomAuthorizationException.Custom("Refresh Token found, but expired.");
+ throw CustomAuthorizationException.RefreshTokenExpired();
}
- RefreshToken rt = tokenService.IssueRefreshToken(
- createRefreshToken: (token) => User.AddRefreshToken(token, longerSession: false)
- );
- await service.SaveRefreshTokensAsync(User).ConfigureAwait(false);
-
- return tokenService.IssueTokens(User, rt);
+ return tokenService.IssueTokens(User, RefreshToken);
}
}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs
new file mode 100644
index 000000000..1414ad7ea
--- /dev/null
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs
@@ -0,0 +1,16 @@
+using CustomCADs.Shared.Application.Abstractions.Requests.Validator;
+using FluentValidation;
+
+namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ResetPasswordEmail;
+
+public sealed class ResetPasswordEmailValidator : CommandValidator
+{
+ public ResetPasswordEmailValidator(IUserService service)
+ {
+ RuleFor(x => x)
+ .MustAsync(
+ async (command, ct) => !await service.GetIsSSOByEmailAsync(command.Email).ConfigureAwait(false)
+ )
+ .WithMessage("Accounts with SSO have no Password to Reset.");
+ }
+}
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs
index 37de01244..84946191f 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs
@@ -1,10 +1,14 @@
using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.SSO.Register;
public sealed record SingleSignOnUserCommand(
string? Role,
+ string? FirstName,
+ string? LastName,
string Username,
string Email,
- string Provider
+ string Provider,
+ Fingerprint Fingerprint
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs
index 002f17a8e..8d395f757 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs
@@ -18,7 +18,7 @@ public async Task Handle(SingleSignOnUserCommand req, CancellationTok
User user = await GetUserAsync(req, ct).ConfigureAwait(false);
RefreshToken rt = tokenService.IssueRefreshToken(
- createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false)
+ createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false)
);
await service.SaveRefreshTokensAsync(user).ConfigureAwait(false);
@@ -38,16 +38,16 @@ private async Task GetUserAsync(SingleSignOnUserCommand req, CancellationT
(true, true) => await service.GetByUsernameAsync(req.Username).ConfigureAwait(false),
};
- async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct)
+ async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct, string defaultRole = DomainConstants.Users.CustomerRole)
{
- string role = req.Role ?? DomainConstants.Roles.Customer;
+ string role = string.IsNullOrWhiteSpace(req.Role) ? defaultRole : req.Role;
AccountId accountId = await sender.SendCommandAsync(
command: new CreateAccountCommand(
Role: role,
Username: req.Username,
Email: req.Email,
- FirstName: null,
- LastName: null
+ FirstName: req.FirstName,
+ LastName: req.LastName
),
ct: ct
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs
index 4b0f3e242..24cc7aea0 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs
@@ -1,5 +1,8 @@
+using CustomCADs.Shared.Domain.TypedIds.Accounts;
+
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ToggleViewedProductsTracking;
public sealed record ToggleViewedProductsTrackingCommand(
- string Username
+ string Username,
+ AccountId CallerId
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs
index 7cdaead2e..c8a2035a1 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs
@@ -2,19 +2,16 @@
using CustomCADs.Shared.Application.Abstractions.Requests.Sender;
using CustomCADs.Shared.Application.Events.Identity;
using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
-using CustomCADs.Shared.Domain.TypedIds.Accounts;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ToggleViewedProductsTracking;
public sealed class ToggleViewedProductsTrackingHandler(
- IUserService service,
IRequestSender sender,
IEventRaiser raiser
) : ICommandHandler
{
public async Task Handle(ToggleViewedProductsTrackingCommand req, CancellationToken ct = default)
{
- AccountId accountId = await service.GetAccountIdAsync(req.Username).ConfigureAwait(false);
AccountInfoDto info = await sender.SendQueryAsync(
query: new GetAccountInfoByUsernameQuery(req.Username),
ct: ct
@@ -22,7 +19,7 @@ public async Task Handle(ToggleViewedProductsTrackingCommand req, CancellationTo
await raiser.RaiseApplicationEventAsync(
@event: new UserEditedApplicationEvent(
- Id: accountId,
+ Id: req.CallerId,
TrackViewedProducts: !info.TrackViewedProducts
)
).ConfigureAwait(false);
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs
index 2718a7700..d5e3a5f7e 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs
@@ -1,8 +1,10 @@
using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail;
public sealed record VerifyUserEmailCommand(
string Username,
- string Token
+ string Token,
+ Fingerprint Fingerprint
) : ICommand;
diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs
index e2d718ff4..49888f9b0 100644
--- a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs
+++ b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs
@@ -20,7 +20,7 @@ public async Task Handle(VerifyUserEmailCommand req, CancellationToke
await service.ConfirmEmailAsync(req.Username, req.Token).ConfigureAwait(false);
RefreshToken rt = tokenService.IssueRefreshToken(
- createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false)
+ createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false)
);
await service.SaveRefreshTokensAsync(user).ConfigureAwait(false);
diff --git a/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs b/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs
new file mode 100644
index 000000000..ddf3f0dfb
--- /dev/null
+++ b/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs
@@ -0,0 +1,9 @@
+namespace CustomCADs.Modules.Identity.Application.Users.Dtos;
+
+public record FingerprintDto(
+ RefreshTokenId Id,
+ string Device,
+ string? Location,
+ bool DeleteAllowed,
+ DateTimeOffset IssuedAt
+);
diff --git a/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs b/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs
index 71cbbc203..3b8c832ba 100644
--- a/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs
+++ b/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs
@@ -6,6 +6,6 @@ public class UserDeletedHandler(IUserService service)
{
public async Task HandleAsync(AccountDeletedApplicationEvent ae)
{
- await service.DeleteAsync(ae.Username).ConfigureAwait(false);
+ await service.DeleteAsync(ae.Id).ConfigureAwait(false);
}
}
diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs
index f2bd49cd0..10a1a2cce 100644
--- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs
+++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs
@@ -1,5 +1,6 @@
+using CustomCADs.Modules.Identity.Application.Users.Dtos;
using CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
-using CustomCADs.Shared.Domain.TypedIds.Catalog;
+using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername;
@@ -12,5 +13,6 @@ public sealed record GetUserByUsernameDto(
bool TrackViewedProducts,
Email Email,
DateTimeOffset CreatedAt,
- ProductId[] ViewedProductIds
+ ViewedProductDto[] ViewedProducts,
+ FingerprintDto[] Fingerprints
);
diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs
index 121314789..0b5b72a83 100644
--- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs
+++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs
@@ -1,7 +1,8 @@
+using CustomCADs.Modules.Identity.Application.Users.Dtos;
+using CustomCADs.Modules.Identity.Domain.Users.Entities;
using CustomCADs.Shared.Application.Abstractions.Requests.Queries;
using CustomCADs.Shared.Application.Abstractions.Requests.Sender;
using CustomCADs.Shared.Application.UseCases.Accounts.Queries;
-using CustomCADs.Shared.Domain.TypedIds.Catalog;
namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername;
@@ -10,15 +11,15 @@ public sealed class GetUserByUsernameHandler(IUserService service, IRequestSende
{
public async Task Handle(GetUserByUsernameQuery req, CancellationToken ct = default)
{
- User user = await service.GetByUsernameAsync(req.Username).ConfigureAwait(false);
+ User user = await service.GetByAccountIdAsync(req.Id).ConfigureAwait(false);
AccountInfoDto info = await sender.SendQueryAsync(
- query: new GetAccountInfoByUsernameQuery(req.Username),
+ query: new GetAccountInfoByUsernameQuery(user.Username),
ct: ct
).ConfigureAwait(false);
- ProductId[] viewedProductIds = await sender.SendQueryAsync(
- query: new GetAccountViewedProductsByUsernameQuery(req.Username),
+ ViewedProductDto[] viewedProducts = await sender.SendQueryAsync(
+ query: new GetAccountViewedProductsByUsernameQuery(user.Username),
ct: ct
).ConfigureAwait(false);
@@ -31,7 +32,20 @@ public async Task Handle(GetUserByUsernameQuery req, Cance
CreatedAt: info.CreatedAt,
FirstName: info.FirstName,
LastName: info.LastName,
- ViewedProductIds: viewedProductIds
+ ViewedProducts: viewedProducts,
+ Fingerprints: [.. user.RefreshTokens
+ .Select(x => ToFingerprintDto(x, x.Value == req.RefreshToken))
+ .OrderByDescending(x => x.IssuedAt)
+ ]
);
}
+
+ private static FingerprintDto ToFingerprintDto(RefreshToken refreshToken, bool isCurrent)
+ => new(
+ Id: refreshToken.Id,
+ Device: refreshToken.Fingerprint.Device,
+ Location: refreshToken.Fingerprint.Location,
+ IssuedAt: refreshToken.IssuedAt,
+ DeleteAllowed: !isCurrent
+ );
}
diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs
index 2ff5a2d7a..21456faec 100644
--- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs
+++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs
@@ -1,7 +1,9 @@
using CustomCADs.Shared.Application.Abstractions.Requests.Queries;
+using CustomCADs.Shared.Domain.TypedIds.Accounts;
namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername;
public sealed record GetUserByUsernameQuery(
- string Username
+ AccountId Id,
+ string? RefreshToken
) : IQuery;
diff --git a/src/Modules/Identity/Domain/Constants.cs b/src/Modules/Identity/Domain/Constants.cs
new file mode 100644
index 000000000..fe3a8d0e5
--- /dev/null
+++ b/src/Modules/Identity/Domain/Constants.cs
@@ -0,0 +1,15 @@
+using CustomCADs.Shared.Domain.TypedIds.Catalog;
+using CustomCADs.Shared.Domain.TypedIds.Files;
+using System.Text.RegularExpressions;
+
+namespace CustomCADs.Modules.Identity.Domain;
+
+public static partial class Constants
+{
+ public static class Tokens
+ {
+ public const int JwtDurationInMinutes = 15;
+ public const int RtDurationInDays = 7;
+ public const int LongerRtDurationInDays = 15;
+ }
+}
diff --git a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs
index 06b56b5b2..2f83d53bb 100644
--- a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs
+++ b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs
@@ -1,25 +1,27 @@
-using CustomCADs.Shared.Domain;
using CustomCADs.Shared.Domain.Bases.Entities;
namespace CustomCADs.Modules.Identity.Domain.Users.Entities;
-using static DomainConstants.Tokens;
+using ValueObjects;
+using static Constants.Tokens;
public class RefreshToken : BaseEntity
{
private RefreshToken() { }
- private RefreshToken(string value, UserId userId, bool longerSession)
+ private RefreshToken(string value, Fingerprint fingerprint, UserId userId, bool longerSession)
{
Value = value;
+ Fingerprint = fingerprint;
UserId = userId;
IssuedAt = DateTimeOffset.UtcNow;
ExpiresAt = IssuedAt.AddDays(
longerSession ? LongerRtDurationInDays : RtDurationInDays
);
}
- private RefreshToken(string value, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt)
+ private RefreshToken(string value, Fingerprint fingerprint, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
Value = value;
+ Fingerprint = fingerprint;
UserId = userId;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
@@ -29,19 +31,20 @@ private RefreshToken(string value, UserId userId, DateTimeOffset issuedAt, DateT
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public string Value { get; private set; } = string.Empty;
+ public Fingerprint Fingerprint { get; private set; } = new();
public UserId UserId { get; private set; }
- public static RefreshToken Create(string value, UserId userId, bool longerSession)
- => new(value, userId, longerSession);
+ public static RefreshToken Create(string value, Fingerprint fingerprint, UserId userId, bool longerSession)
+ => new(value, fingerprint, userId, longerSession);
- public static RefreshToken Create(RefreshTokenId id, string value, UserId userId, bool longerSession)
- => new(value, userId, longerSession)
+ public static RefreshToken Create(RefreshTokenId id, string value, Fingerprint fingerprint, UserId userId, bool longerSession)
+ => new(value, fingerprint, userId, longerSession)
{
Id = id,
};
- public static RefreshToken Create(RefreshTokenId id, string value, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt)
- => new(value, userId, issuedAt, expiresAt)
+ public static RefreshToken Create(RefreshTokenId id, string value, Fingerprint fingerprint, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt)
+ => new(value, fingerprint, userId, issuedAt, expiresAt)
{
Id = id,
};
diff --git a/src/Modules/Identity/Domain/Users/User.cs b/src/Modules/Identity/Domain/Users/User.cs
index 5ecb160df..0782242fd 100644
--- a/src/Modules/Identity/Domain/Users/User.cs
+++ b/src/Modules/Identity/Domain/Users/User.cs
@@ -53,9 +53,9 @@ public void SetUsername(string username)
this.ValidateUsername();
}
- public RefreshToken AddRefreshToken(string token, bool longerSession)
+ public RefreshToken AddRefreshToken(string token, Fingerprint fingerprint, bool longerSession)
{
- RefreshToken rt = RefreshToken.Create(token, this.Id, longerSession);
+ RefreshToken rt = RefreshToken.Create(token, fingerprint, this.Id, longerSession);
refreshTokens.Add(rt);
return rt;
@@ -63,6 +63,6 @@ public RefreshToken AddRefreshToken(string token, bool longerSession)
public bool RemoveRefreshToken(RefreshToken rt)
{
- return refreshTokens.Remove(rt);
+ return refreshTokens.RemoveAll(x => x.Id == rt.Id) == 1;
}
}
diff --git a/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs b/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs
new file mode 100644
index 000000000..d8a9a5c55
--- /dev/null
+++ b/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs
@@ -0,0 +1,3 @@
+namespace CustomCADs.Modules.Identity.Domain.Users.ValueObjects;
+
+public record Fingerprint(string Device = "", string? Location = null);
diff --git a/src/Modules/Identity/Infrastructure/Constants.cs b/src/Modules/Identity/Infrastructure/Constants.cs
new file mode 100644
index 000000000..2400d76fe
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/Constants.cs
@@ -0,0 +1,21 @@
+namespace CustomCADs.Modules.Identity.Infrastructure;
+
+public static class Constants
+{
+ public static class Roles
+ {
+ public static readonly Guid CustomerId = new("762ddec2-25c9-4183-9891-72a19d84a839");
+ public static readonly Guid ContributorId = new("e1101e2c-32cc-456f-9c82-4f1d1a65d141");
+ public static readonly Guid DesignerId = new("f3ad41d3-ee90-4988-9195-8b2a8f4f2733");
+ public static readonly Guid AdminId = new("fad1b19d-5333-4633-bd84-d67c64649f65");
+ }
+
+ public static class Users
+ {
+ public static readonly Guid CustomerId = new("e38c495f-b1f3-4226-d289-08dd11623eb9");
+ public static readonly Guid ContributorId = new("af840410-f3f2-4a3b-d28a-08dd11623eb9");
+ public static readonly Guid DesignerId = new("a8145f5f-a3a4-4f06-9461-9f24b9f23fde");
+ public static readonly Guid HeadDesignerId = new("4337a774-2c5c-4c27-d28b-08dd11623eb9");
+ public static readonly Guid AdminId = new("cb7749fb-3fff-4902-d28c-08dd11623eb9");
+ }
+}
diff --git a/src/Modules/Identity/Infrastructure/DependencyInjection.cs b/src/Modules/Identity/Infrastructure/DependencyInjection.cs
index 0039dfd39..1753d329f 100644
--- a/src/Modules/Identity/Infrastructure/DependencyInjection.cs
+++ b/src/Modules/Identity/Infrastructure/DependencyInjection.cs
@@ -1,8 +1,9 @@
#pragma warning disable IDE0130
using CustomCADs.Modules.Identity.Application.Contracts;
using CustomCADs.Modules.Identity.Infrastructure.BackgroundJobs;
-using CustomCADs.Modules.Identity.Infrastructure.Identity;
+using CustomCADs.Modules.Identity.Infrastructure.Fingerprints;
using CustomCADs.Modules.Identity.Infrastructure.Identity.Context;
+using CustomCADs.Modules.Identity.Infrastructure.Identity.Services;
using CustomCADs.Modules.Identity.Infrastructure.Tokens;
using Microsoft.EntityFrameworkCore;
using Npgsql;
@@ -25,8 +26,11 @@ public async Task UpdateIdentityContextAsync()
extension(IServiceCollection services)
{
+ public IServiceCollection AddFingerprintsService()
+ => services.AddScoped();
+
public IServiceCollection AddTokensService()
- => services.AddScoped();
+ => services.AddScoped();
public IServiceCollection AddIdentityServices(string connectionString)
=> services
@@ -36,9 +40,13 @@ public IServiceCollection AddIdentityServices(string connectionString)
private IServiceCollection AddContext(string connectionString)
{
- services.AddDbContext(options =>
+ services.AddSingleton(
+ sp => new NpgsqlDataSourceBuilder(connectionString).EnableDynamicJson().Build()
+ );
+
+ services.AddDbContext((sp, options) =>
options.UseNpgsql(
- dataSource: new NpgsqlDataSourceBuilder(connectionString).EnableDynamicJson().Build(),
+ dataSource: sp.GetRequiredService(),
npgsqlOptionsAction: opt => opt.MigrationsHistoryTable("__EFMigrationsHistory", IdentityContext.Schema)
)
);
diff --git a/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs b/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs
new file mode 100644
index 000000000..95ef0ce19
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs
@@ -0,0 +1,49 @@
+using DeviceDetectorNET;
+using DeviceDetectorNET.Parser;
+using DeviceDetectorNET.Results;
+using DeviceDetectorNET.Results.Client;
+
+namespace CustomCADs.Modules.Identity.Infrastructure.Fingerprints;
+
+using Application.Contracts;
+using Domain.Users.ValueObjects;
+
+public class DeviceDetectorFingerprintService : IFingerprintService
+{
+ public DeviceDetectorFingerprintService()
+ {
+ DeviceDetector.SetVersionTruncation(VersionTruncation.VERSION_TRUNCATION_MINOR);
+ }
+
+ public Fingerprint GetFingerprint(Dictionary headers)
+ => new(
+ Device: GetDevice(headers),
+ Location: GetLocation(headers)
+ );
+
+ private static string GetDevice(Dictionary headers)
+ {
+ DeviceDetector detector = new(
+ userAgent: headers["User-Agent"],
+ clientHints: ClientHints.Factory(headers)
+ );
+ detector.Parse();
+
+ if (detector.IsBot())
+ {
+ IEnumerable botNames = detector.GetBot().Matches.Select(x => x.Name);
+ return string.Join("; ", botNames);
+ }
+
+ ClientMatchResult clientInfo = detector.GetClient().Match;
+ OsMatchResult osInfo = detector.GetOs().Match;
+
+ string os = $"{osInfo.Name} {osInfo.Platform}";
+ string client = $"{clientInfo.Name} {clientInfo.Version}";
+
+ return $"{os} running {client}";
+ }
+
+ private static string? GetLocation(Dictionary headers, string locationHeaderName = "CF-IPCountry")
+ => headers.GetValueOrDefault(locationHeaderName);
+}
diff --git a/src/Modules/Identity/Infrastructure/GlobalUsings.cs b/src/Modules/Identity/Infrastructure/GlobalUsings.cs
new file mode 100644
index 000000000..d01f7e4ac
--- /dev/null
+++ b/src/Modules/Identity/Infrastructure/GlobalUsings.cs
@@ -0,0 +1 @@
+global using AppUserRole = Microsoft.AspNetCore.Identity.IdentityUserRole;
diff --git a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs b/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs
deleted file mode 100644
index 4e84c15f3..000000000
--- a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs
+++ /dev/null
@@ -1,222 +0,0 @@
-using CustomCADs.Modules.Identity.Application.Contracts;
-using CustomCADs.Modules.Identity.Domain.Users;
-using CustomCADs.Modules.Identity.Domain.Users.Entities;
-using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities;
-using CustomCADs.Shared.Application.Exceptions;
-using CustomCADs.Shared.Domain.TypedIds.Accounts;
-using CustomCADs.Shared.Domain.TypedIds.Identity;
-using Microsoft.AspNetCore.Identity;
-using Microsoft.EntityFrameworkCore;
-
-namespace CustomCADs.Modules.Identity.Infrastructure.Identity;
-
-public class AppUserService(UserManager manager) : IUserService
-{
- #region GetUserByX
- public async Task GetByUsernameAsync(string username)
- {
- AppUser appUser = await manager.Users
- .Include(x => x.RefreshTokens)
- .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username))
- .ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- return await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false);
- }
-
- public async Task GetByEmailAsync(string email)
- {
- AppUser appUser = await manager.Users
- .Include(x => x.RefreshTokens)
- .FirstOrDefaultAsync(x => x.Email == email)
- .ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(email), email);
-
- return await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false);
- }
-
- public async Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(string token)
- {
- AppUser appUser = await manager.Users
- .Include(x => x.RefreshTokens)
- .FirstOrDefaultAsync(x => x.RefreshTokens.Any(x => x.Value == token))
- .ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(token), token);
-
- return (
- User: await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false),
- RefreshToken: appUser.RefreshTokens.First(x => x.Value == token).ToRefreshToken()
- );
- }
- #endregion
-
- #region GetPropertyX
- public async Task GetExistsByUsernameAsync(string username)
- => await manager.Users
- .AnyAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username))
- .ConfigureAwait(false);
-
- public async Task GetExistsByEmailAsync(string email)
- => await manager.Users
- .AnyAsync(x => x.Email == email)
- .ConfigureAwait(false);
-
- public async Task GetAccountIdAsync(string username)
- {
- AccountId accountId = await manager.Users
- .Where(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username))
- .Select(x => x.AccountId)
- .FirstOrDefaultAsync()
- .ConfigureAwait(false);
-
- if (accountId.IsEmpty())
- {
- throw CustomNotFoundException.ByProp(nameof(username), username);
- }
-
- return accountId;
- }
-
- public async Task GetIsLockedOutAsync(string username)
- {
- AppUser appUser = await manager.Users
- .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username))
- .ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- bool isLockedOut = await manager.IsLockedOutAsync(appUser).ConfigureAwait(false);
- if (!isLockedOut)
- {
- return null;
- }
-
- return appUser.LockoutEnd;
- }
- #endregion
-
- #region Lifecycle
- public async Task CreateAsync(User user, string password)
- {
- AppUser appUser = user.ToAppUser();
- await manager.CreateAsync(appUser, password).ConfigureAwait(false);
-
- IdentityResult result = await manager.AddToRoleAsync(appUser, user.Role).ConfigureAwait(false);
- if (!result.Succeeded)
- {
- throw new CustomException($"Couldn't create an account for: {user.Username}.");
- }
- }
-
- public async Task CreateSSOAsync(User user, string provider)
- {
- AppUser appUser = user.ToAppUser(provider);
- await manager.CreateAsync(appUser).ConfigureAwait(false);
-
- IdentityResult result = await manager.AddToRoleAsync(appUser, user.Role).ConfigureAwait(false);
- if (!result.Succeeded)
- {
- throw new CustomException($"Couldn't create an account for: {user.Username}.");
- }
- }
-
- public async Task DeleteAsync(string username)
- {
- AppUser appUser = await manager.FindByIdAsync(username).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- await manager.DeleteAsync(appUser).ConfigureAwait(false);
- }
- #endregion
-
- #region Mutation
- public async Task CheckPasswordAsync(string username, string password)
- {
- AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- bool success = await manager.CheckPasswordAsync(appUser, password).ConfigureAwait(false);
- if (success)
- {
- await manager.ResetAccessFailedCountAsync(appUser).ConfigureAwait(false);
- }
- else
- {
- await manager.AccessFailedAsync(appUser).ConfigureAwait(false);
- }
-
- return success;
- }
-
- public async Task UpdateUsernameAsync(UserId id, string username)
- {
- AppUser appUser = await manager.FindByIdAsync(id.ToString()).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(id), id);
-
- appUser.Username = username;
- await manager.UpdateAsync(appUser).ConfigureAwait(false);
- }
-
- public async Task SaveRefreshTokensAsync(User user)
- {
- AppUser appUser = await manager.FindByIdAsync(user.Id.ToString()).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(user.Id), user.Id);
-
- appUser.FillRefreshTokens([.. user.RefreshTokens.Select(x => x.ToAppRefreshToken())]);
- await manager.UpdateAsync(appUser).ConfigureAwait(false);
- }
-
- public async Task RevokeRefreshTokenAsync(string token)
- {
- (User User, RefreshToken RefreshToken) = await GetByRefreshTokenAsync(token).ConfigureAwait(false);
- User.RemoveRefreshToken(RefreshToken);
-
- AppUser appUser = await manager.FindByIdAsync(User.Id.ToString()).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(User.Id), User.Id);
-
- appUser.FillRefreshTokens([.. User.RefreshTokens.Select(x => x.ToAppRefreshToken())]);
- await manager.UpdateAsync(appUser).ConfigureAwait(false);
- }
- #endregion
-
- #region Token Generation
- public async Task GenerateEmailConfirmationTokenAsync(string username)
- {
- AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- return await manager.GenerateEmailConfirmationTokenAsync(appUser).ConfigureAwait(false);
- }
-
- public async Task ConfirmEmailAsync(string username, string token)
- {
- AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false)
- ?? throw CustomNotFoundException.ByProp(nameof(username), username);
-
- IdentityResult result = await manager.ConfirmEmailAsync(appUser, token).ConfigureAwait(false);
- if (!result.Succeeded)
- {
- throw CustomAuthorizationException.Custom($"Error confirming Account: {username}'s email.");
- }
- }
-
- public async Task GeneratePasswordResetTokenAsync(string email)
- {
- AppUser appUser = await manager.FindByEmailAsync(email).ConfigureAwait(false)
- ?? throw CustomNotFoundException