From 35955600585fc53af981e6b56733a9e2b1ad4727 Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Mon, 13 Jan 2025 00:30:16 +0530 Subject: [PATCH] feat: define Role aggregate and add_role_to_store {cmd,event&service} --- ...474de188981176005664ad53e39460319d34f.json | 46 +++ ...be8f9452cf458a000842f5104c7b65e8ed5f2.json | 28 ++ ...72a336b8b2346c5dd7a55e445373fff4bf61.json} | 10 +- ...6406fd0dc9c5378146f996ce6d8e8e9b43c8.json} | 5 +- ...bd183fa8fa607f9b50377b2d1edbd4ec97472.json | 22 + ...2a1f8c8ff1ffc5e724c5ceee68e49513246c.json} | 10 +- ...78492191cb240271b6600bc9ab96dcb959035.json | 23 ++ ...66ddefeafed4cb8f2d5f97ec3646f30ca2e78.json | 18 + ...35730372a03557b703d07bab7ab90e7fe4d1.json} | 5 +- ...71dfb143547e5022b391a411bbb065a29dda0.json | 19 + ...3327d6fdd548ecc0219230d529383297e3c40.json | 40 ++ migrations/20241007085926_employee_query.sql | 2 + ...0250112171332_cqrs_identity_role_query.sql | 21 + .../output/db/postgres/employee_view.rs | 25 +- .../output/db/postgres/get_roles_for_store.rs | 101 +++++ .../adapters/output/db/postgres/mod.rs | 4 + .../output/db/postgres/role_id_exists.rs | 85 ++++ .../db/postgres/role_name_exists_for_store.rs | 83 ++++ .../adapters/output/db/postgres/role_view.rs | 390 ++++++++++++++++++ .../application/port/output/db/errors.rs | 2 + .../port/output/db/get_roles_for_store.rs | 59 +++ .../application/port/output/db/mod.rs | 3 + .../port/output/db/role_id_exists.rs | 53 +++ .../output/db/role_name_exists_for_store.rs | 60 +++ .../services/add_role_to_store_service.rs | 186 +++++++++ src/identity/domain/add_role_command.rs | 39 ++ src/identity/domain/mod.rs | 7 + src/identity/domain/role_added_event.rs | 35 ++ src/identity/domain/role_aggregate.rs | 115 ++++++ 29 files changed, 1485 insertions(+), 11 deletions(-) create mode 100644 .sqlx/query-3a2efcc806c9a4cf7bd00b3eae4474de188981176005664ad53e39460319d34f.json create mode 100644 .sqlx/query-4df5ca308db869c3488227a26fabe8f9452cf458a000842f5104c7b65e8ed5f2.json rename .sqlx/{query-7c2fd6e897bf18b1f2229eec5fd12932a86d4e88f2fd4ab8ac32246a55303b03.json => query-5cf15aaa2223dd2e68681e529a1972a336b8b2346c5dd7a55e445373fff4bf61.json} (79%) rename .sqlx/{query-796be4344e585654ea27252b02239158ed4691448b33d4427bf70717aad41263.json => query-610f776a0591cba4d7af0bb4d4ef6406fd0dc9c5378146f996ce6d8e8e9b43c8.json} (68%) create mode 100644 .sqlx/query-70211e169bab5fc0f9240e26c14bd183fa8fa607f9b50377b2d1edbd4ec97472.json rename .sqlx/{query-848f7c8250f7aba08fcf11491ee1a80c9fd0bfb8e37ca1051604bc2bb25d5356.json => query-7137685170920291b99261bb95042a1f8c8ff1ffc5e724c5ceee68e49513246c.json} (79%) create mode 100644 .sqlx/query-79e633fecc51e6691730f40831678492191cb240271b6600bc9ab96dcb959035.json create mode 100644 .sqlx/query-a93291e26fe69c7945bef30c84066ddefeafed4cb8f2d5f97ec3646f30ca2e78.json rename .sqlx/{query-4b8bf25b161a8337bc1ee7bcfba5a065417280cbae1527d3363f6f7561cb50c3.json => query-b753a03fcb9f40976b0dd179da0c35730372a03557b703d07bab7ab90e7fe4d1.json} (79%) create mode 100644 .sqlx/query-d0bda8d2ac1ede3becdc0aa642671dfb143547e5022b391a411bbb065a29dda0.json create mode 100644 .sqlx/query-e3e3b18f95cf920b423ea3c63db3327d6fdd548ecc0219230d529383297e3c40.json create mode 100644 migrations/20250112171332_cqrs_identity_role_query.sql create mode 100644 src/identity/adapters/output/db/postgres/get_roles_for_store.rs create mode 100644 src/identity/adapters/output/db/postgres/role_id_exists.rs create mode 100644 src/identity/adapters/output/db/postgres/role_name_exists_for_store.rs create mode 100644 src/identity/adapters/output/db/postgres/role_view.rs create mode 100644 src/identity/application/port/output/db/get_roles_for_store.rs create mode 100644 src/identity/application/port/output/db/role_id_exists.rs create mode 100644 src/identity/application/port/output/db/role_name_exists_for_store.rs create mode 100644 src/identity/application/services/add_role_to_store_service.rs create mode 100644 src/identity/domain/add_role_command.rs create mode 100644 src/identity/domain/role_added_event.rs create mode 100644 src/identity/domain/role_aggregate.rs diff --git a/.sqlx/query-3a2efcc806c9a4cf7bd00b3eae4474de188981176005664ad53e39460319d34f.json b/.sqlx/query-3a2efcc806c9a4cf7bd00b3eae4474de188981176005664ad53e39460319d34f.json new file mode 100644 index 0000000..b73b9bd --- /dev/null +++ b/.sqlx/query-3a2efcc806c9a4cf7bd00b3eae4474de188981176005664ad53e39460319d34f.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n created_time,\n name,\n role_id,\n store_id,\n deleted\n FROM\n cqrs_identity_role_query\n WHERE\n role_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "role_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "store_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "3a2efcc806c9a4cf7bd00b3eae4474de188981176005664ad53e39460319d34f" +} diff --git a/.sqlx/query-4df5ca308db869c3488227a26fabe8f9452cf458a000842f5104c7b65e8ed5f2.json b/.sqlx/query-4df5ca308db869c3488227a26fabe8f9452cf458a000842f5104c7b65e8ed5f2.json new file mode 100644 index 0000000..3c9b3b5 --- /dev/null +++ b/.sqlx/query-4df5ca308db869c3488227a26fabe8f9452cf458a000842f5104c7b65e8ed5f2.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n role_id, version\n FROM\n cqrs_identity_role_query\n WHERE\n role_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "role_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "4df5ca308db869c3488227a26fabe8f9452cf458a000842f5104c7b65e8ed5f2" +} diff --git a/.sqlx/query-7c2fd6e897bf18b1f2229eec5fd12932a86d4e88f2fd4ab8ac32246a55303b03.json b/.sqlx/query-5cf15aaa2223dd2e68681e529a1972a336b8b2346c5dd7a55e445373fff4bf61.json similarity index 79% rename from .sqlx/query-7c2fd6e897bf18b1f2229eec5fd12932a86d4e88f2fd4ab8ac32246a55303b03.json rename to .sqlx/query-5cf15aaa2223dd2e68681e529a1972a336b8b2346c5dd7a55e445373fff4bf61.json index f3f7002..0032ddd 100644 --- a/.sqlx/query-7c2fd6e897bf18b1f2229eec5fd12932a86d4e88f2fd4ab8ac32246a55303b03.json +++ b/.sqlx/query-5cf15aaa2223dd2e68681e529a1972a336b8b2346c5dd7a55e445373fff4bf61.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT \n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n deleted\n FROM\n cqrs_identity_employee_query\n WHERE\n emp_id = $1;", + "query": "SELECT \n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n role_id,\n deleted\n FROM\n cqrs_identity_employee_query\n WHERE\n emp_id = $1;", "describe": { "columns": [ { @@ -45,6 +45,11 @@ }, { "ordinal": 8, + "name": "role_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, "name": "deleted", "type_info": "Bool" } @@ -63,8 +68,9 @@ false, false, false, + true, false ] }, - "hash": "7c2fd6e897bf18b1f2229eec5fd12932a86d4e88f2fd4ab8ac32246a55303b03" + "hash": "5cf15aaa2223dd2e68681e529a1972a336b8b2346c5dd7a55e445373fff4bf61" } diff --git a/.sqlx/query-796be4344e585654ea27252b02239158ed4691448b33d4427bf70717aad41263.json b/.sqlx/query-610f776a0591cba4d7af0bb4d4ef6406fd0dc9c5378146f996ce6d8e8e9b43c8.json similarity index 68% rename from .sqlx/query-796be4344e585654ea27252b02239158ed4691448b33d4427bf70717aad41263.json rename to .sqlx/query-610f776a0591cba4d7af0bb4d4ef6406fd0dc9c5378146f996ce6d8e8e9b43c8.json index 0a7a842..b6418ff 100644 --- a/.sqlx/query-796be4344e585654ea27252b02239158ed4691448b33d4427bf70717aad41263.json +++ b/.sqlx/query-610f776a0591cba4d7af0bb4d4ef6406fd0dc9c5378146f996ce6d8e8e9b43c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO cqrs_identity_employee_query (\n version,\n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n deleted\n\n\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n );", + "query": "INSERT INTO cqrs_identity_employee_query (\n version,\n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n role_id,\n deleted\n\n\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11\n );", "describe": { "columns": [], "parameters": { @@ -14,10 +14,11 @@ "Int8", "Int4", "Bool", + "Uuid", "Bool" ] }, "nullable": [] }, - "hash": "796be4344e585654ea27252b02239158ed4691448b33d4427bf70717aad41263" + "hash": "610f776a0591cba4d7af0bb4d4ef6406fd0dc9c5378146f996ce6d8e8e9b43c8" } diff --git a/.sqlx/query-70211e169bab5fc0f9240e26c14bd183fa8fa607f9b50377b2d1edbd4ec97472.json b/.sqlx/query-70211e169bab5fc0f9240e26c14bd183fa8fa607f9b50377b2d1edbd4ec97472.json new file mode 100644 index 0000000..698aa6b --- /dev/null +++ b/.sqlx/query-70211e169bab5fc0f9240e26c14bd183fa8fa607f9b50377b2d1edbd4ec97472.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_identity_role_query\n WHERE\n role_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "70211e169bab5fc0f9240e26c14bd183fa8fa607f9b50377b2d1edbd4ec97472" +} diff --git a/.sqlx/query-848f7c8250f7aba08fcf11491ee1a80c9fd0bfb8e37ca1051604bc2bb25d5356.json b/.sqlx/query-7137685170920291b99261bb95042a1f8c8ff1ffc5e724c5ceee68e49513246c.json similarity index 79% rename from .sqlx/query-848f7c8250f7aba08fcf11491ee1a80c9fd0bfb8e37ca1051604bc2bb25d5356.json rename to .sqlx/query-7137685170920291b99261bb95042a1f8c8ff1ffc5e724c5ceee68e49513246c.json index c5c5e41..f7a9238 100644 --- a/.sqlx/query-848f7c8250f7aba08fcf11491ee1a80c9fd0bfb8e37ca1051604bc2bb25d5356.json +++ b/.sqlx/query-7137685170920291b99261bb95042a1f8c8ff1ffc5e724c5ceee68e49513246c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT \n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n deleted\n\n FROM\n cqrs_identity_employee_query\n WHERE\n emp_id = $1;", + "query": "SELECT \n created_time,\n store_id,\n emp_id,\n first_name,\n last_name,\n phone_number_number,\n phone_number_country_code,\n phone_verified,\n role_id,\n deleted\n\n FROM\n cqrs_identity_employee_query\n WHERE\n emp_id = $1;", "describe": { "columns": [ { @@ -45,6 +45,11 @@ }, { "ordinal": 8, + "name": "role_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, "name": "deleted", "type_info": "Bool" } @@ -63,8 +68,9 @@ false, false, false, + true, false ] }, - "hash": "848f7c8250f7aba08fcf11491ee1a80c9fd0bfb8e37ca1051604bc2bb25d5356" + "hash": "7137685170920291b99261bb95042a1f8c8ff1ffc5e724c5ceee68e49513246c" } diff --git a/.sqlx/query-79e633fecc51e6691730f40831678492191cb240271b6600bc9ab96dcb959035.json b/.sqlx/query-79e633fecc51e6691730f40831678492191cb240271b6600bc9ab96dcb959035.json new file mode 100644 index 0000000..3655806 --- /dev/null +++ b/.sqlx/query-79e633fecc51e6691730f40831678492191cb240271b6600bc9ab96dcb959035.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_identity_role_query\n WHERE\n name = $1\n AND\n store_id = $2\n AND\n deleted = false\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "79e633fecc51e6691730f40831678492191cb240271b6600bc9ab96dcb959035" +} diff --git a/.sqlx/query-a93291e26fe69c7945bef30c84066ddefeafed4cb8f2d5f97ec3646f30ca2e78.json b/.sqlx/query-a93291e26fe69c7945bef30c84066ddefeafed4cb8f2d5f97ec3646f30ca2e78.json new file mode 100644 index 0000000..997b58d --- /dev/null +++ b/.sqlx/query-a93291e26fe69c7945bef30c84066ddefeafed4cb8f2d5f97ec3646f30ca2e78.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_identity_role_query\n SET\n version = $1,\n\n created_time = $2,\n store_id = $3,\n name = $4,\n deleted = $5;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "a93291e26fe69c7945bef30c84066ddefeafed4cb8f2d5f97ec3646f30ca2e78" +} diff --git a/.sqlx/query-4b8bf25b161a8337bc1ee7bcfba5a065417280cbae1527d3363f6f7561cb50c3.json b/.sqlx/query-b753a03fcb9f40976b0dd179da0c35730372a03557b703d07bab7ab90e7fe4d1.json similarity index 79% rename from .sqlx/query-4b8bf25b161a8337bc1ee7bcfba5a065417280cbae1527d3363f6f7561cb50c3.json rename to .sqlx/query-b753a03fcb9f40976b0dd179da0c35730372a03557b703d07bab7ab90e7fe4d1.json index 4468b3c..11cecf2 100644 --- a/.sqlx/query-4b8bf25b161a8337bc1ee7bcfba5a065417280cbae1527d3363f6f7561cb50c3.json +++ b/.sqlx/query-b753a03fcb9f40976b0dd179da0c35730372a03557b703d07bab7ab90e7fe4d1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE\n cqrs_identity_employee_query\n SET\n version = $1,\n\n created_time = $2,\n store_id = $3,\n first_name = $4,\n last_name = $5,\n phone_number_number = $6,\n phone_number_country_code = $7,\n phone_verified = $8,\n\n\n deleted = $9;", + "query": "UPDATE\n cqrs_identity_employee_query\n SET\n version = $1,\n\n created_time = $2,\n store_id = $3,\n first_name = $4,\n last_name = $5,\n phone_number_number = $6,\n phone_number_country_code = $7,\n phone_verified = $8,\n role_id = $9,\n\n\n deleted = $10;", "describe": { "columns": [], "parameters": { @@ -13,10 +13,11 @@ "Int8", "Int4", "Bool", + "Uuid", "Bool" ] }, "nullable": [] }, - "hash": "4b8bf25b161a8337bc1ee7bcfba5a065417280cbae1527d3363f6f7561cb50c3" + "hash": "b753a03fcb9f40976b0dd179da0c35730372a03557b703d07bab7ab90e7fe4d1" } diff --git a/.sqlx/query-d0bda8d2ac1ede3becdc0aa642671dfb143547e5022b391a411bbb065a29dda0.json b/.sqlx/query-d0bda8d2ac1ede3becdc0aa642671dfb143547e5022b391a411bbb065a29dda0.json new file mode 100644 index 0000000..12cabf1 --- /dev/null +++ b/.sqlx/query-d0bda8d2ac1ede3becdc0aa642671dfb143547e5022b391a411bbb065a29dda0.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_identity_role_query (\n version,\n created_time,\n store_id,\n role_id,\n name,\n deleted\n\n\n ) VALUES (\n $1, $2, $3, $4, $5, $6\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Uuid", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "d0bda8d2ac1ede3becdc0aa642671dfb143547e5022b391a411bbb065a29dda0" +} diff --git a/.sqlx/query-e3e3b18f95cf920b423ea3c63db3327d6fdd548ecc0219230d529383297e3c40.json b/.sqlx/query-e3e3b18f95cf920b423ea3c63db3327d6fdd548ecc0219230d529383297e3c40.json new file mode 100644 index 0000000..246f984 --- /dev/null +++ b/.sqlx/query-e3e3b18f95cf920b423ea3c63db3327d6fdd548ecc0219230d529383297e3c40.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n name, role_id, store_id, deleted\n FROM \n cqrs_identity_role_query\n WHERE\n store_id = $1\n AND\n deleted = false;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "role_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "store_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e3e3b18f95cf920b423ea3c63db3327d6fdd548ecc0219230d529383297e3c40" +} diff --git a/migrations/20241007085926_employee_query.sql b/migrations/20241007085926_employee_query.sql index 4e98039..92e6daf 100644 --- a/migrations/20241007085926_employee_query.sql +++ b/migrations/20241007085926_employee_query.sql @@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS cqrs_identity_employee_query store_id UUID DEFAULT NULL, + role_id UUID DEFAULT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (emp_id) diff --git a/migrations/20250112171332_cqrs_identity_role_query.sql b/migrations/20250112171332_cqrs_identity_role_query.sql new file mode 100644 index 0000000..f556c9e --- /dev/null +++ b/migrations/20250112171332_cqrs_identity_role_query.sql @@ -0,0 +1,21 @@ +-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS cqrs_identity_role_query +( + version bigint CHECK (version >= 0) NOT NULL, + + created_time timestamp with time zone DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + + name TEXT NOT NULL, + + role_id UUID NOT NULL UNIQUE, + + store_id UUID NOT NULL, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE(name,store_id), + + PRIMARY KEY (role_id) +); diff --git a/src/identity/adapters/output/db/postgres/employee_view.rs b/src/identity/adapters/output/db/postgres/employee_view.rs index 8a918ce..7638f7e 100644 --- a/src/identity/adapters/output/db/postgres/employee_view.rs +++ b/src/identity/adapters/output/db/postgres/employee_view.rs @@ -33,6 +33,8 @@ pub struct EmployeeView { phone_number_country_code: i32, phone_number_number: i64, + role_id: Option, + phone_verified: bool, store_id: Option, deleted: bool, @@ -51,6 +53,7 @@ impl From for Employee { .unwrap(), ) .emp_id(v.emp_id) + .role_id(v.role_id) .phone_verified(v.phone_verified) .store_id(v.store_id) .deleted(v.deleted) @@ -68,6 +71,7 @@ impl Default for EmployeeView { last_name: e.last_name().clone(), phone_number_number: *e.phone_number().number() as i64, phone_number_country_code: *e.phone_number().country_code() as i32, + role_id: e.role_id().clone(), phone_verified: *e.phone_verified(), emp_id: *e.emp_id(), store_id: e.store_id().clone(), @@ -85,6 +89,7 @@ impl EmployeeView { self.phone_verified = *e.phone_verified(); self.emp_id = *e.emp_id(); self.store_id = e.store_id().clone(); + self.role_id = e.role_id().clone(); self.deleted = *e.deleted(); } } @@ -104,7 +109,14 @@ impl View for EmployeeView { IdentityEvent::PhoneNumberChanged(e) => unimplemented!(), // IdentityEvent::InviteAccepted(e) => self.store_id = Some(*e.store_id()), IdentityEvent::OrganizationExited(e) => self.store_id = None, - + IdentityEvent::OwnerAddedEmployeeToStore(e) => { + self.store_id = Some(*e.store_id()); + self.role_id = None; + } + IdentityEvent::OwnerRemovedEmployeeFromStore(e) => { + self.store_id = None; + self.role_id = None; + } _ => (), } } @@ -129,6 +141,7 @@ impl ViewRepository for DBOutPostgresAdapter { phone_number_number, phone_number_country_code, phone_verified, + role_id, deleted FROM cqrs_identity_employee_query @@ -162,6 +175,7 @@ impl ViewRepository for DBOutPostgresAdapter { phone_number_number, phone_number_country_code, phone_verified, + role_id, deleted FROM @@ -216,11 +230,12 @@ impl ViewRepository for DBOutPostgresAdapter { phone_number_number, phone_number_country_code, phone_verified, + role_id, deleted ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 );", version, view.created_time, @@ -231,6 +246,7 @@ impl ViewRepository for DBOutPostgresAdapter { view.phone_number_number, view.phone_number_country_code, view.phone_verified, + view.role_id, view.deleted, ) .execute(&self.pool) @@ -252,9 +268,10 @@ impl ViewRepository for DBOutPostgresAdapter { phone_number_number = $6, phone_number_country_code = $7, phone_verified = $8, + role_id = $9, - deleted = $9;", + deleted = $10;", version, view.created_time, view.store_id, @@ -263,6 +280,7 @@ impl ViewRepository for DBOutPostgresAdapter { view.phone_number_number, view.phone_number_country_code, view.phone_verified, + view.role_id, view.deleted, ) .execute(&self.pool) @@ -441,6 +459,7 @@ mod tests { let emp = employee_query.load(&emp_id_str).await.unwrap().unwrap(); assert!(emp.phone_verified); + // TODO: test OwnerAddedEmployeeToStore and OwnerRemovedEmployeeFromStore settings.drop_db().await; } } diff --git a/src/identity/adapters/output/db/postgres/get_roles_for_store.rs b/src/identity/adapters/output/db/postgres/get_roles_for_store.rs new file mode 100644 index 0000000..d43290a --- /dev/null +++ b/src/identity/adapters/output/db/postgres/get_roles_for_store.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::DBOutPostgresAdapter; +use crate::identity::application::port::output::db::{errors::*, get_roles_for_store::*}; +use crate::identity::domain::role_aggregate::*; + +struct DBRole { + name: String, + role_id: Uuid, + store_id: Uuid, + deleted: bool, +} + +impl From for Role { + fn from(v: DBRole) -> Self { + RoleBuilder::default() + .role_id(v.role_id) + .store_id(v.store_id) + .name(v.name) + .deleted(v.deleted) + .build() + .unwrap() + } +} + +#[async_trait::async_trait] +impl GetRolesForStoreDBPort for DBOutPostgresAdapter { + async fn get_roles_for_store(&self, store_id: Uuid) -> OutDBPortResult> { + let mut res = sqlx::query_as!( + DBRole, + "SELECT + name, role_id, store_id, deleted + FROM + cqrs_identity_role_query + WHERE + store_id = $1 + AND + deleted = false;", + store_id, + ) + .fetch_all(&self.pool) + .await?; + Ok(res.drain(0..).map(|r| r.into()).collect()) + } +} + +#[cfg(test)] +pub mod tests { + + use uuid::Uuid; + + use super::*; + use crate::identity::adapters::output::db::postgres::role_id_exists::tests::create_dummy_role_record; + use crate::utils::uuid::tests::UUID; + + #[actix_rt::test] + async fn test_postgres_role_exists() { + let role_id = Uuid::new_v4(); + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::DBOutPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let role = RoleBuilder::default() + .name("role_name".into()) + .store_id(UUID) + .role_id(role_id) + .build() + .unwrap(); + // state doesn't exist + assert!(db.get_roles_for_store(UUID).await.unwrap().is_empty()); + + create_dummy_role_record(&role, &db).await; + + // state exists + assert_eq!( + db.get_roles_for_store(*role.store_id()).await.unwrap(), + vec![role.clone()] + ); + + // Set role.deleted = true; now db.role_name_exists_for_store must return false + sqlx::query!( + "UPDATE cqrs_identity_role_query SET deleted = true WHERE role_id = $1;", + role.role_id(), + ) + .execute(&db.pool) + .await + .unwrap(); + assert!(db.get_roles_for_store(UUID).await.unwrap().is_empty()); + + settings.drop_db().await; + } +} diff --git a/src/identity/adapters/output/db/postgres/mod.rs b/src/identity/adapters/output/db/postgres/mod.rs index 9738fc7..f58fc10 100644 --- a/src/identity/adapters/output/db/postgres/mod.rs +++ b/src/identity/adapters/output/db/postgres/mod.rs @@ -15,6 +15,7 @@ pub mod email_exists; pub mod employee_view; mod errors; pub mod get_verification_secret; +pub mod role_view; pub mod store_view; pub mod user_id_exists; pub mod user_view; @@ -27,8 +28,11 @@ pub mod delete_verification_otp; pub mod emp_id_exists; pub mod get_emp_id_from_phone_number; pub mod get_login_otp; +mod get_roles_for_store; pub mod get_verification_otp; pub mod phone_exists; +mod role_id_exists; +mod role_name_exists_for_store; //pub mod get_invite; //pub mod invite_id_exists; diff --git a/src/identity/adapters/output/db/postgres/role_id_exists.rs b/src/identity/adapters/output/db/postgres/role_id_exists.rs new file mode 100644 index 0000000..7d38b84 --- /dev/null +++ b/src/identity/adapters/output/db/postgres/role_id_exists.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::DBOutPostgresAdapter; +use crate::identity::application::port::output::db::{errors::*, role_id_exists::*}; +use crate::identity::domain::role_aggregate::*; + +#[async_trait::async_trait] +impl RoleIDExistsDBPort for DBOutPostgresAdapter { + async fn role_id_exists(&self, role_id: &Uuid) -> OutDBPortResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_identity_role_query + WHERE + role_id = $1 + );", + role_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + use uuid::Uuid; + + use crate::utils::uuid::tests::UUID; + + use super::*; + + pub async fn create_dummy_role_record(s: &Role, db: &DBOutPostgresAdapter) { + sqlx::query!( + "INSERT INTO cqrs_identity_role_query + (version, name, role_id, store_id, deleted) + VALUES ($1, $2, $3, $4, $5);", + 1, + s.name(), + s.role_id(), + s.store_id(), + false + ) + .execute(&db.pool) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn test_postgres_role_exists() { + let role_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::DBOutPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let role = RoleBuilder::default() + .name("role_name".into()) + .store_id(UUID) + .role_id(role_id) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.role_id_exists(role.role_id()).await.unwrap()); + + create_dummy_role_record(&role, &db).await; + + // state exists + assert!(db.role_id_exists(role.role_id()).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/identity/adapters/output/db/postgres/role_name_exists_for_store.rs b/src/identity/adapters/output/db/postgres/role_name_exists_for_store.rs new file mode 100644 index 0000000..4b0fe76 --- /dev/null +++ b/src/identity/adapters/output/db/postgres/role_name_exists_for_store.rs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::DBOutPostgresAdapter; +use crate::identity::application::port::output::db::{errors::*, role_name_exists_for_store::*}; +use crate::identity::domain::role_aggregate::*; + +#[async_trait::async_trait] +impl RoleNameExistsForStoreDBPort for DBOutPostgresAdapter { + async fn role_name_exists_for_store(&self, s: &Role) -> OutDBPortResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_identity_role_query + WHERE + name = $1 + AND + store_id = $2 + AND + deleted = false + );", + s.name(), + s.store_id(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + + use uuid::Uuid; + + use super::*; + use crate::identity::adapters::output::db::postgres::role_id_exists::tests::create_dummy_role_record; + use crate::utils::uuid::tests::UUID; + + #[actix_rt::test] + async fn test_postgres_role_exists() { + let role_id = Uuid::new_v4(); + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::DBOutPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let role = RoleBuilder::default() + .name("role_name".into()) + .store_id(UUID) + .role_id(role_id) + .build() + .unwrap(); + // state doesn't exist + assert!(!db.role_name_exists_for_store(&role).await.unwrap()); + + create_dummy_role_record(&role, &db).await; + + // state exists + assert!(db.role_name_exists_for_store(&role).await.unwrap()); + + // Set role.deleted = true; now db.role_name_exists_for_store must return false + sqlx::query!( + "UPDATE cqrs_identity_role_query SET deleted = true WHERE role_id = $1;", + role.role_id(), + ) + .execute(&db.pool) + .await + .unwrap(); + assert!(!db.role_name_exists_for_store(&role).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/identity/adapters/output/db/postgres/role_view.rs b/src/identity/adapters/output/db/postgres/role_view.rs new file mode 100644 index 0000000..ae0c191 --- /dev/null +++ b/src/identity/adapters/output/db/postgres/role_view.rs @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::errors::*; +use super::DBOutPostgresAdapter; +use crate::identity::adapters::types::{IdentityRoleCqrsExec, IdentityRoleCqrsView}; +use crate::identity::application::services::{events::IdentityEvent, IdentityServicesObj}; +use crate::identity::domain::role_aggregate::*; +use crate::types::currency::{self, Currency, PriceBuilder}; +use crate::utils::parse_aggregate_id::parse_aggregate_id; + +pub const NEW_ROLE_NON_UUID: &str = "identity_new_bill_non_uuid-asdfa"; + +// The view for a Role query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Serialize, Deserialize)] +pub struct RoleView { + created_time: OffsetDateTime, + name: String, + role_id: Uuid, + store_id: Uuid, + + deleted: bool, +} + +impl From for Role { + fn from(v: RoleView) -> Self { + RoleBuilder::default() + .name(v.name) + .role_id(v.role_id) + .store_id(v.store_id) + .deleted(v.deleted) + .build() + .unwrap() + } +} + +impl Default for RoleView { + fn default() -> Self { + let e = Role::default(); + Self { + created_time: OffsetDateTime::now_utc(), + name: e.name().clone(), + role_id: *e.role_id(), + store_id: e.store_id().clone(), + deleted: false, + } + } +} + +impl RoleView { + fn merge(&mut self, e: &Role) { + self.name = e.name().clone(); + self.role_id = *e.role_id(); + self.store_id = e.store_id().clone(); + self.deleted = *e.deleted(); + } +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for RoleView { + fn update(&mut self, event: &EventEnvelope) { + match &event.payload { + // IdentityEvent::OrganizationExited(e) => self.store_id = None, + _ => (), + } + } +} + +#[async_trait] +impl ViewRepository for DBOutPostgresAdapter { + async fn load(&self, role_id: &str) -> Result, PersistenceError> { + let role_id = match parse_aggregate_id(role_id, NEW_ROLE_NON_UUID)? { + Some((val, _)) => return Ok(Some(val)), + None => Uuid::parse_str(role_id).unwrap(), + }; + + let res = sqlx::query_as!( + RoleView, + "SELECT + created_time, + name, + role_id, + store_id, + deleted + FROM + cqrs_identity_role_query + WHERE + role_id = $1;", + role_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + Ok(Some(res)) + } + + async fn load_with_context( + &self, + role_id: &str, + ) -> Result, PersistenceError> { + let role_id = match parse_aggregate_id(role_id, NEW_ROLE_NON_UUID)? { + Some(val) => return Ok(Some(val)), + None => Uuid::parse_str(role_id).unwrap(), + }; + + let res = sqlx::query_as!( + RoleView, + "SELECT + created_time, + name, + role_id, + store_id, + deleted + FROM + cqrs_identity_role_query + WHERE + role_id = $1;", + &role_id, + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + role_id: Uuid, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + role_id, version + FROM + cqrs_identity_role_query + WHERE + role_id = $1;", + role_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.role_id.to_string(), ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: RoleView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_identity_role_query ( + version, + created_time, + store_id, + role_id, + name, + deleted + + + ) VALUES ( + $1, $2, $3, $4, $5, $6 + );", + version, + view.created_time, + view.store_id, + view.role_id, + view.name, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_identity_role_query + SET + version = $1, + + created_time = $2, + store_id = $3, + name = $4, + deleted = $5;", + version, + view.created_time, + view.store_id, + view.name, + view.deleted + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +#[async_trait] +impl Query for DBOutPostgresAdapter { + async fn dispatch(&self, role_id: &str, events: &[EventEnvelope]) { + let res = self + .load_with_context(role_id) + .await + .unwrap_or_else(|_| Some((RoleView::default(), ViewContext::new(role_id.into(), 0)))); + let (mut view, view_context): (RoleView, ViewContext) = res.unwrap(); + for event in events { + view.update(event); + } + self.update_view(view, view_context).await.unwrap(); + } +} + +pub fn init_cqrs( + db: DBOutPostgresAdapter, + services: IdentityServicesObj, +) -> (IdentityRoleCqrsExec, IdentityRoleCqrsView) { + let queries: Vec>> = vec![Box::new(db.clone())]; + + let pool = db.pool.clone(); + + ( + Arc::new(postgres_es::postgres_cqrs(pool.clone(), queries, services)), + Arc::new(db.clone()), + ) +} +// +//#[cfg(test)] +//mod tests { +// use super::*; +// +// use postgres_es::PostgresCqrs; +// +// use crate::{ +// db::migrate::*, +// identity::{ +// application::{ +// port::output::{ +// db::get_verification_otp::GetVerificationOTPOutDBPort, +// phone::account_validation_otp::mock_account_validation_otp_phone_port, +// }, +// services::{ +// employee_register_service::*, employee_verify_phone_number_service::*, +// IdentityCommand, MockIdentityServicesInterface, +// }, +// }, +// domain::{ +// employee_aggregate::*, employee_register_command::*, verify_phone_number_command::*, +// }, +// }, +// tests::bdd::*, +// utils::{random_number::tests::mock_generate_random_number, uuid::tests::*}, +// }; +// use std::sync::Arc; +// +// #[actix_rt::test] +// async fn pg_query_identity_employee_view() { +// let settings = crate::settings::tests::get_settings().await; +// //let settings = crate::settings::Settings::new().unwrap(); +// settings.create_db().await; +// +// let db = crate::db::sqlx_postgres::Postgres::init(&settings.database.url).await; +// db.migrate().await; +// let db = DBOutPostgresAdapter::new(db.pool.clone()); +// +// let queries: Vec>> = vec![Box::new(db.clone())]; +// +// let mut mock_services = MockIdentityServicesInterface::new(); +// +// //let store = Store::default(); +// //crate::identity::adapters::output::db::postgres::store_id_exists::tests::create_dummy_store_record(&store, &db).await; +// +// let db2 = Arc::new(db.clone()); +// mock_services +// .expect_employee_register_service() +// .times(IS_CALLED_ONLY_ONCE.unwrap()) +// .returning(move || { +// Arc::new( +// RoleRegisterUserServiceBuilder::default() +// .db_role_id_exists_adapter(db2.clone()) +// .db_create_verification_otp_adapter(db2.clone()) +// .db_phone_exists_adapter(db2.clone()) +// .random_number_adapter(mock_generate_random_number( +// IS_CALLED_ONLY_ONCE, +// 999, +// )) +// .phone_account_validation_otp_adapter( +// mock_account_validation_otp_phone_port(IS_CALLED_ONLY_ONCE), +// ) +// .build() +// .unwrap(), +// ) +// }); +// +// let db2 = Arc::new(db.clone()); +// mock_services +// .expect_employee_verify_phone_number_service() +// .times(IS_CALLED_ONLY_ONCE.unwrap()) +// .returning(move || { +// Arc::new( +// RoleVerifyPhoneNumberServiceBuilder::default() +// .db_get_role_id_from_phone_number_adapter(db2.clone()) +// .db_delete_verification_otp(db2.clone()) +// .db_get_verification_otp(db2.clone()) +// .build() +// .unwrap(), +// ) +// }); +// +// let (cqrs, employee_query): ( +// Arc>, +// Arc>, +// ) = ( +// Arc::new(postgres_es::postgres_cqrs( +// db.pool.clone(), +// queries, +// Arc::new(mock_services), +// )), +// Arc::new(db.clone()), +// ); +// +// let cmd = RoleRegisterCommandBuilder::default() +// .name("foooint".into()) +// .last_name("foooint".into()) +// .phone_number(PhoneNumber::default()) +// .role_id(Uuid::new_v4()) +// .build() +// .unwrap(); +// +// let role_id_str = cmd.role_id().to_string(); +// +// cqrs.execute(&role_id_str, IdentityCommand::RoleRegister(cmd.clone())) +// .await +// .unwrap(); +// let emp = employee_query.load(&role_id_str).await.unwrap().unwrap(); +// let emp: Role = emp.into(); +// assert_eq!(emp.name(), cmd.name()); +// assert_eq!(emp.last_name(), cmd.last_name()); +// assert_eq!(emp.role_id(), cmd.role_id()); +// assert_eq!(emp.phone_number(), cmd.phone_number()); +// assert!(!*emp.phone_verified()); +// assert!(!*emp.deleted()); +// assert!(emp.store_id().is_none()); +// +// let otp = db +// .get_verification_otp(emp.phone_number()) +// .await +// .unwrap() +// .unwrap(); +// cqrs.execute( +// &role_id_str, +// IdentityCommand::RoleVerifyPhoneNumber( +// VerifyPhoneNumberCommandBuilder::default() +// .otp(otp.into()) +// .phone_number(emp.phone_number().clone()) +// .build() +// .unwrap(), +// ), +// ) +// .await +// .unwrap(); +// +// let emp = employee_query.load(&role_id_str).await.unwrap().unwrap(); +// assert!(emp.phone_verified); +// +// settings.drop_db().await; +// } +//} diff --git a/src/identity/application/port/output/db/errors.rs b/src/identity/application/port/output/db/errors.rs index 4b4eeb7..a1e0887 100644 --- a/src/identity/application/port/output/db/errors.rs +++ b/src/identity/application/port/output/db/errors.rs @@ -21,4 +21,6 @@ pub enum OutDBPortError { DuplicateStoreName, DuplicateStoreID, StoreIDNotFound, + RoleIDNotFound, + DuplicateRoleName, } diff --git a/src/identity/application/port/output/db/get_roles_for_store.rs b/src/identity/application/port/output/db/get_roles_for_store.rs new file mode 100644 index 0000000..12950f2 --- /dev/null +++ b/src/identity/application/port/output/db/get_roles_for_store.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use crate::identity::domain::role_aggregate::Role; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait GetRolesForStoreDBPort: Send + Sync { + async fn get_roles_for_store(&self, store_id: Uuid) -> OutDBPortResult>; +} + +pub type GetRolesForStoreDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_get_roles_for_store_db_port_empty( + times: Option, + ) -> GetRolesForStoreDBPortObj { + let mut m = MockGetRolesForStoreDBPort::new(); + if let Some(times) = times { + m.expect_get_roles_for_store() + .times(times) + .return_const(Ok(Vec::default())); + } else { + m.expect_get_roles_for_store() + .return_const(Ok(Vec::default())); + } + + Arc::new(m) + } + + pub fn mock_get_roles_for_store_db_port(times: Option) -> GetRolesForStoreDBPortObj { + let mut m = MockGetRolesForStoreDBPort::new(); + if let Some(times) = times { + m.expect_get_roles_for_store() + .times(times) + .return_const(Ok(vec![Role::default()])); + } else { + m.expect_get_roles_for_store() + .return_const(Ok(vec![Role::default()])); + } + + Arc::new(m) + } +} diff --git a/src/identity/application/port/output/db/mod.rs b/src/identity/application/port/output/db/mod.rs index 264bc84..2cf12c4 100644 --- a/src/identity/application/port/output/db/mod.rs +++ b/src/identity/application/port/output/db/mod.rs @@ -22,4 +22,7 @@ pub mod store_id_exists; pub mod store_name_exists; pub mod user_id_exists; //pub mod verification_otp_exists; +pub mod get_roles_for_store; +pub mod role_id_exists; +pub mod role_name_exists_for_store; pub mod verification_secret_exists; diff --git a/src/identity/application/port/output/db/role_id_exists.rs b/src/identity/application/port/output/db/role_id_exists.rs new file mode 100644 index 0000000..e076ce0 --- /dev/null +++ b/src/identity/application/port/output/db/role_id_exists.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait RoleIDExistsDBPort: Send + Sync { + async fn role_id_exists(&self, role_id: &Uuid) -> OutDBPortResult; +} + +pub type RoleIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_role_id_exists_db_port_false(times: Option) -> RoleIDExistsDBPortObj { + let mut m = MockRoleIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_role_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_role_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_role_id_exists_db_port_true(times: Option) -> RoleIDExistsDBPortObj { + let mut m = MockRoleIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_role_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_role_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/identity/application/port/output/db/role_name_exists_for_store.rs b/src/identity/application/port/output/db/role_name_exists_for_store.rs new file mode 100644 index 0000000..63bcf2e --- /dev/null +++ b/src/identity/application/port/output/db/role_name_exists_for_store.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; + +use crate::identity::domain::role_aggregate::Role; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait RoleNameExistsForStoreDBPort: Send + Sync { + async fn role_name_exists_for_store(&self, n: &Role) -> OutDBPortResult; +} + +pub type RoleNameExistsForStoreDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_role_name_exists_for_store_db_port_false( + times: Option, + ) -> RoleNameExistsForStoreDBPortObj { + let mut m = MockRoleNameExistsForStoreDBPort::new(); + if let Some(times) = times { + m.expect_role_name_exists_for_store() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_role_name_exists_for_store() + .returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_role_name_exists_for_store_db_port_true( + times: Option, + ) -> RoleNameExistsForStoreDBPortObj { + let mut m = MockRoleNameExistsForStoreDBPort::new(); + if let Some(times) = times { + m.expect_role_name_exists_for_store() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_role_name_exists_for_store() + .returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/identity/application/services/add_role_to_store_service.rs b/src/identity/application/services/add_role_to_store_service.rs new file mode 100644 index 0000000..8321399 --- /dev/null +++ b/src/identity/application/services/add_role_to_store_service.rs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use crate::identity::application::port::output::db::{ + role_id_exists::*, role_name_exists_for_store::*, store_id_exists::*, +}; +use crate::identity::domain::role_aggregate::RoleBuilder; +use crate::identity::domain::{add_role_command::*, role_added_event::*}; + +use super::errors::*; + +#[automock] +#[async_trait::async_trait] +pub trait AddRoleToStoreUseCase: Send + Sync { + async fn add_role_to_store(&self, cmd: AddRoleCommand) -> IdentityResult; +} + +pub type AddRoleToStoreServiceObj = std::sync::Arc; + +#[derive(Clone, Builder)] +pub struct AddRoleToStoreService { + db_store_id_exists_adapter: StoreIDExistsDBPortObj, + db_role_id_exists_adapter: RoleIDExistsDBPortObj, + db_role_name_exists_for_store_adapter: RoleNameExistsForStoreDBPortObj, +} + +#[async_trait::async_trait] +impl AddRoleToStoreUseCase for AddRoleToStoreService { + async fn add_role_to_store(&self, cmd: AddRoleCommand) -> IdentityResult { + if !self + .db_store_id_exists_adapter + .store_id_exists(cmd.store_id()) + .await? + { + return Err(IdentityError::StoreNotFound); + } + + if self + .db_role_id_exists_adapter + .role_id_exists(cmd.role_id()) + .await? + { + return Err(IdentityError::DuplicateRoleID); + } + + let role = RoleBuilder::default() + .name(cmd.name().trim().to_lowercase()) + .role_id(*cmd.role_id()) + .store_id(*cmd.store_id()) + .build() + .unwrap(); + + if self + .db_role_name_exists_for_store_adapter + .role_name_exists_for_store(&role) + .await? + { + return Err(IdentityError::DuplicateRoleName); + } + + Ok(RoleAddedEventBuilder::default() + .name(role.name().clone()) + .store_id(*role.store_id()) + .role_id(*role.role_id()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +mod tests { + use crate::{tests::bdd::*, utils::uuid::tests::*}; + + use super::*; + + impl AddRoleToStoreService { + pub fn mock_service(times: Option, cmd: AddRoleCommand) -> AddRoleToStoreServiceObj { + let res = RoleAddedEvent::get_event(&cmd); + let mut m = MockAddRoleToStoreUseCase::default(); + + if let Some(times) = times { + m.expect_add_role_to_store() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_role_to_store() + .returning(move |_| Ok(res.clone())); + } + + std::sync::Arc::new(m) + } + } + + #[actix_rt::test] + async fn test_service() { + let s = AddRoleToStoreServiceBuilder::default() + .db_store_id_exists_adapter(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_role_id_exists_adapter(mock_role_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_role_name_exists_for_store_adapter(mock_role_name_exists_for_store_db_port_false( + IS_CALLED_ONLY_ONCE, + )) + .build() + .unwrap(); + + { + let cmd = AddRoleCommandBuilder::default() + .role_id(UUID) + .store_id(UUID) + .name("foo".into()) + .build() + .unwrap(); + + let res = s.add_role_to_store(cmd.clone()).await.unwrap(); + assert_eq!(*res.role_id(), *cmd.role_id()); + assert_eq!(res.name(), cmd.name()); + assert_eq!(*res.store_id(), *cmd.store_id()); + } + } + + #[actix_rt::test] + async fn test_service_store_no_exist() { + let s = AddRoleToStoreServiceBuilder::default() + .db_store_id_exists_adapter(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_role_id_exists_adapter(mock_role_id_exists_db_port_false(IS_NEVER_CALLED)) + .db_role_name_exists_for_store_adapter(mock_role_name_exists_for_store_db_port_false( + IS_NEVER_CALLED, + )) + .build() + .unwrap(); + + { + let cmd = AddRoleCommand::get_cmd(); + + assert_eq!( + s.add_role_to_store(cmd.clone()).await.err(), + Some(IdentityError::StoreNotFound) + ); + } + } + + #[actix_rt::test] + async fn test_service_role_id_exist() { + let s = AddRoleToStoreServiceBuilder::default() + .db_store_id_exists_adapter(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_role_id_exists_adapter(mock_role_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_role_name_exists_for_store_adapter(mock_role_name_exists_for_store_db_port_false( + IS_NEVER_CALLED, + )) + .build() + .unwrap(); + + { + let cmd = AddRoleCommand::get_cmd(); + + assert_eq!( + s.add_role_to_store(cmd.clone()).await.err(), + Some(IdentityError::DuplicateRoleID) + ); + } + } + + #[actix_rt::test] + async fn test_service_role_exists_for_store() { + let s = AddRoleToStoreServiceBuilder::default() + .db_store_id_exists_adapter(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_role_id_exists_adapter(mock_role_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_role_name_exists_for_store_adapter(mock_role_name_exists_for_store_db_port_true( + IS_CALLED_ONLY_ONCE, + )) + .build() + .unwrap(); + + { + let cmd = AddRoleCommand::get_cmd(); + + assert_eq!( + s.add_role_to_store(cmd.clone()).await.err(), + Some(IdentityError::DuplicateRoleName) + ); + } + } +} diff --git a/src/identity/domain/add_role_command.rs b/src/identity/domain/add_role_command.rs new file mode 100644 index 0000000..e4e9335 --- /dev/null +++ b/src/identity/domain/add_role_command.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use events::IdentityEvent; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::identity::application::services::{errors::*, *}; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct AddRoleCommand { + name: String, + role_id: Uuid, + store_id: Uuid, +} + +#[cfg(test)] +pub mod tests { + use crate::utils::uuid::tests::UUID; + + use super::*; + + impl AddRoleCommand { + pub fn get_cmd() -> Self { + Self { + name: "some role".into(), + role_id: UUID, + store_id: UUID, + } + } + } +} diff --git a/src/identity/domain/mod.rs b/src/identity/domain/mod.rs index e79ba9e..5c1af8b 100644 --- a/src/identity/domain/mod.rs +++ b/src/identity/domain/mod.rs @@ -4,6 +4,7 @@ pub mod aggregate; pub mod employee_aggregate; +pub mod role_aggregate; //pub mod employee_commands; pub mod store_aggregate; // pub mod invite; @@ -14,9 +15,12 @@ pub mod employee_registered_event; //pub mod invite_accepted_event; pub mod login_otp_sent_event; pub mod organization_exited_event; +pub mod owner_added_employee_to_store_event; +pub mod owner_removed_employee_from_store_event; pub mod phone_number_changed_event; pub mod phone_number_verified_event; pub mod resend_login_otp_event; +pub mod role_added_event; pub mod store_added_event; pub mod store_updated_event; pub mod verification_otp_resent_event; @@ -24,11 +28,14 @@ pub mod verification_otp_sent_event; // commands //pub mod accept_invite_command; +pub mod add_role_command; pub mod add_store_command; pub mod change_phone_number_command; pub mod employee_login_command; pub mod employee_register_command; pub mod exit_organization_command; +pub mod owner_add_employee_to_store_command; +pub mod owner_remove_employee_from_store_command; pub mod resend_login_otp_command; pub mod resend_verification_otp_command; pub mod update_store_command; diff --git a/src/identity/domain/role_added_event.rs b/src/identity/domain/role_added_event.rs new file mode 100644 index 0000000..4911c7e --- /dev/null +++ b/src/identity/domain/role_added_event.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::role_aggregate::*; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct RoleAddedEvent { + name: String, + role_id: Uuid, + store_id: Uuid, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{identity::domain::add_role_command::AddRoleCommand, utils::uuid::tests::UUID}; + + impl RoleAddedEvent { + pub fn get_event(cmd: &AddRoleCommand) -> Self { + Self { + name: cmd.name().clone(), + role_id: *cmd.role_id(), + store_id: *cmd.store_id(), + } + } + } +} diff --git a/src/identity/domain/role_aggregate.rs b/src/identity/domain/role_aggregate.rs new file mode 100644 index 0000000..d0772b0 --- /dev/null +++ b/src/identity/domain/role_aggregate.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use events::IdentityEvent; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::identity::application::services::{errors::*, *}; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Role { + name: String, + role_id: Uuid, + store_id: Uuid, + #[builder(default = "false")] + deleted: bool, +} + +impl Default for Role { + fn default() -> Self { + Role { + name: "".to_string(), + store_id: Uuid::new_v4(), + role_id: Uuid::new_v4(), + deleted: false, + } + } +} + +#[async_trait] +impl Aggregate for Role { + type Command = IdentityCommand; + type Event = IdentityEvent; + type Error = IdentityError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "identity.role".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + IdentityCommand::AddRole(cmd) => Ok(vec![IdentityEvent::RoleAdded( + services.add_role_to_store().add_role_to_store(cmd).await?, + )]), + _ => Ok(Vec::new()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + IdentityEvent::RoleAdded(event) => { + self.name = event.name().clone(); + self.role_id = *self.role_id(); + self.store_id = *self.store_id(); + self.deleted = false; + } + _ => (), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cqrs_es::test::TestFramework; + + use crate::identity::domain::{ + add_role_command::*, + // accept_invite_command::AcceptInviteCommand, + role_added_event::*, + }; + use add_role_to_store_service::*; + + use super::*; + use crate::tests::bdd::*; + + type RoleTestFramework = TestFramework; + + #[test] + fn test_role_aggregate_add_role() { + let cmd = AddRoleCommand::get_cmd(); + let expected = RoleAddedEvent::get_event(&cmd); + let expected = IdentityEvent::RoleAdded(expected); + + let mut services = MockIdentityServicesInterface::new(); + services + .expect_add_role_to_store() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(AddRoleToStoreService::mock_service( + IS_CALLED_ONLY_ONCE, + cmd.clone(), + )); + + RoleTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(IdentityCommand::AddRole(cmd)) + .then_expect_events(vec![expected]); + } +}