Compare commits

..

159 Commits

Author SHA1 Message Date
khwezi da5f233c3b Merge pull request 'refactored incoming signature validator to use form fields instead of httprequest' (#111) from payments into master
Reviewed-on: #111
2026-06-13 15:58:58 +02:00
Khwezi Mngoma 02d89eec4f refactored incoming signature validator to use form fields instead of httprequest
continuous-integration/drone/pr Build is passing
2026-06-13 15:58:30 +02:00
khwezi 95dc2e2da2 Merge pull request 'payments' (#110) from payments into master
Reviewed-on: #110
2026-06-13 15:50:20 +02:00
Khwezi Mngoma 59fc0432b4 ensure alphabetical sorting
continuous-integration/drone/pr Build is passing
2026-06-13 15:49:45 +02:00
Khwezi Mngoma 99c0508f6f Implemented separate signature validator 2026-06-13 15:45:59 +02:00
Khwezi Mngoma b984dab2be Updated valid payfast addresses 2026-06-13 12:08:23 +02:00
khwezi 157f097dfb Merge pull request 'Catering for service registration of non-UI apps' (#109) from payments into master
Reviewed-on: #109
2026-06-13 10:46:11 +02:00
Khwezi Mngoma 630e74814b Catering for service registration of non-UI apps
continuous-integration/drone/pr Build is passing
2026-06-13 10:45:31 +02:00
khwezi 6248d03ead Merge pull request 'Removed automatic service registration for the CartService' (#108) from payments into master
Reviewed-on: #108
2026-06-13 10:22:52 +02:00
Khwezi Mngoma 9b474a398b Removed automatic service registration for the CartService
continuous-integration/drone/pr Build is passing
2026-06-13 10:22:24 +02:00
khwezi 3deae15f5a Merge pull request 'Removed automatic LocalStorageService registration' (#107) from payments into master
Reviewed-on: #107
2026-06-13 10:19:13 +02:00
Khwezi Mngoma 8e1df7938b Removed automatic LocalStorageService registration
continuous-integration/drone/pr Build is passing
2026-06-13 10:18:42 +02:00
khwezi d9f2d32c76 Merge pull request 'Refactored registration of Features service from Scoped to Transient' (#106) from payments into master
Reviewed-on: #106
2026-06-13 10:07:27 +02:00
Khwezi Mngoma 9296f0331e Refactored registration of Features service from Scoped to Transient
continuous-integration/drone/pr Build is passing
2026-06-13 10:06:54 +02:00
khwezi 1ace61baa5 Merge pull request 'Honoring the mandatory field sequence' (#105) from payments into master
Reviewed-on: #105
2026-06-12 23:30:43 +02:00
Khwezi Mngoma e3e49b8db2 Honoring the mandatory field sequence
continuous-integration/drone/pr Build is passing
2026-06-12 23:30:13 +02:00
khwezi 2ed15b548f Merge pull request 'Refactored PayfastService.GenerateSignature()' (#104) from payments into master
Reviewed-on: #104
2026-06-12 23:27:21 +02:00
Khwezi Mngoma 7d2bc7f1f2 Refactored PayfastService.GenerateSignature()
continuous-integration/drone/pr Build is passing
2026-06-12 23:26:54 +02:00
khwezi ef2428f8e3 Merge pull request 'Refactored GenerateSignature' (#103) from payments into master
Reviewed-on: #103
2026-06-12 23:20:08 +02:00
Khwezi Mngoma 5edff5e272 Refactored GenerateSignature
continuous-integration/drone/pr Build is passing
2026-06-12 23:19:40 +02:00
khwezi b424b24c2e Merge pull request 'Changed optional fields on Customer entity' (#102) from payments into master
Reviewed-on: #102
2026-06-12 23:02:02 +02:00
Khwezi Mngoma 310c1237b1 Changed optional fields on Customer entity
continuous-integration/drone/pr Build is passing
2026-06-12 23:00:57 +02:00
khwezi cadc5888cc Merge pull request 'Added new service methods' (#101) from payments into master
Reviewed-on: #101
2026-06-12 22:09:17 +02:00
Khwezi Mngoma 618e57074a Added new service methods
continuous-integration/drone/pr Build is passing
2026-06-12 22:08:54 +02:00
khwezi 92abf6c5be Merge pull request 'Added GetBookByProductId()' (#100) from payments into master
Reviewed-on: #100
2026-06-12 21:58:03 +02:00
Khwezi Mngoma b60b8236af Added GetBookByProductId()
continuous-integration/drone/pr Build is passing
2026-06-12 21:57:34 +02:00
khwezi 37e0d3b93f Merge pull request 'Completed token service' (#99) from payments into master
Reviewed-on: #99
2026-06-12 20:58:57 +02:00
Khwezi Mngoma 4d2b37ace7 Completed token service
continuous-integration/drone/pr Build is passing
2026-06-12 20:48:12 +02:00
khwezi b42c0fcc4f Merge pull request 'Fixd double package reference to Mediator.SourceGenerator' (#98) from payments into master
Reviewed-on: #98
2026-06-12 16:16:05 +02:00
Khwezi Mngoma 3daf192ce9 Fixd double package reference to Mediator.SourceGenerator
continuous-integration/drone/pr Build is passing
2026-06-12 16:15:44 +02:00
khwezi 9418c77f0e Merge pull request 'Implemented LiteCharms Security TokenService' (#97) from payments into master
Reviewed-on: #97
2026-06-12 16:12:10 +02:00
Khwezi Mngoma a98adea8f3 Implemented LiteCharms Security TokenService
continuous-integration/drone/pr Build is passing
2026-06-12 16:09:51 +02:00
khwezi 2bb1c8c3bc Merge pull request 'Added CartService' (#96) from payments into master
Reviewed-on: #96
2026-06-12 08:44:58 +02:00
Khwezi Mngoma f5f1035598 Added CartService
continuous-integration/drone/pr Build is passing
2026-06-12 08:43:58 +02:00
khwezi 388a5f4c87 Merge pull request 'Removed shopping cart objects' (#95) from payments into master
Reviewed-on: #95
2026-06-09 22:33:20 +02:00
Khwezi Mngoma 40f4656036 Removed shopping cart objects
continuous-integration/drone/pr Build is passing
2026-06-09 22:32:54 +02:00
khwezi c31dd308a4 Merge pull request 'payments' (#94) from payments into master
Reviewed-on: #94
2026-06-09 21:53:52 +02:00
Khwezi Mngoma 11dfd18a44 Ensured the assembly scanner picks up shared and core components
continuous-integration/drone/pr Build is passing
2026-06-09 21:52:31 +02:00
Khwezi Mngoma e7f02eca9b Refactored Shop services assembly scanner to be more generic 2026-06-09 21:43:47 +02:00
khwezi 787507bed9 Merge pull request 'Added CartService and LocalStorageService (browser)' (#93) from payments into master
Reviewed-on: #93
2026-06-09 09:10:32 +02:00
Khwezi Mngoma 59af9a5406 Added CartService and LocalStorageService (browser)
continuous-integration/drone/pr Build is passing
2026-06-09 09:08:46 +02:00
khwezi 5140da2c6c Merge pull request 'Passing token hint during signout' (#92) from payments into master
Reviewed-on: #92
2026-06-07 14:09:31 +02:00
Khwezi Mngoma 02ff14ccc8 Passing token hint during signout
continuous-integration/drone/pr Build is passing
2026-06-07 14:09:02 +02:00
khwezi 0ad410c64e Merge pull request 'payments' (#91) from payments into master
Reviewed-on: #91
2026-06-07 13:57:15 +02:00
Khwezi Mngoma e193aa7c1c Adopted standard logout flow
continuous-integration/drone/pr Build is running
2026-06-07 13:56:49 +02:00
Khwezi Mngoma 840d4568e2 Refactored logout process 2026-06-07 13:55:20 +02:00
khwezi 6e580ecdf6 Merge pull request 'Added token hint to logout event' (#90) from payments into master
Reviewed-on: #90
2026-06-07 13:12:05 +02:00
Khwezi Mngoma 60095057b7 Added token hint to logout event
continuous-integration/drone/pr Build is passing
2026-06-07 13:11:33 +02:00
khwezi 4c194c1141 Merge pull request 'Added AddSecurityEndpoints login endpoint' (#89) from payments into master
Reviewed-on: #89
2026-06-07 12:00:50 +02:00
Khwezi Mngoma b41136e2c7 Added AddSecurityEndpoints login endpoint
continuous-integration/drone/pr Build is passing
2026-06-07 12:00:07 +02:00
khwezi 41eb4daeb4 Merge pull request 'Refactored AddSecurityEndpoints' (#88) from payments into master
Reviewed-on: #88
2026-06-07 11:09:02 +02:00
Khwezi Mngoma c423f04b42 Refactored AddSecurityEndpoints
continuous-integration/drone/pr Build is passing
2026-06-07 11:08:05 +02:00
khwezi 7fe5f7aef3 Merge pull request 'Refactored client auth method' (#87) from payments into master
Reviewed-on: #87
2026-06-07 10:33:33 +02:00
Khwezi Mngoma a567fc7cd7 Refactored client auth method
continuous-integration/drone/pr Build is passing
2026-06-07 10:30:56 +02:00
khwezi 31254932ae Merge pull request 'Enabled api audience verification' (#86) from payments into master
Reviewed-on: #86
2026-06-06 23:54:13 +02:00
Khwezi Mngoma c53434a578 Enabled api audience verification
continuous-integration/drone/pr Build is passing
2026-06-06 23:53:19 +02:00
khwezi 5a0aae8182 Merge pull request 'Disabled audience verification on jwt tokens' (#85) from payments into master
Reviewed-on: #85
2026-06-06 21:50:48 +02:00
Khwezi Mngoma 3f3e0dbe88 Disabled audience verification on jwt tokens
continuous-integration/drone/pr Build is passing
2026-06-06 21:49:32 +02:00
khwezi 8d8ebffabf Merge pull request 'Removed required scope policy, no longer needed, audience covers the intent' (#84) from payments into master
Reviewed-on: #84
2026-06-06 16:44:52 +02:00
Khwezi Mngoma dc4addb43a Removed required scope policy, no longer needed, audience covers the intent
continuous-integration/drone/pr Build is passing
2026-06-06 16:44:22 +02:00
khwezi e2d29261da Merge pull request 'Updated API to use LiteCharms Security' (#83) from payments into master
Reviewed-on: #83
2026-06-06 16:27:16 +02:00
Khwezi Mngoma 5d5b59d610 Updated API to use LiteCharms Security
continuous-integration/drone/pr Build is passing
2026-06-06 16:26:27 +02:00
khwezi f001b02633 Merge pull request 'Refactored to deal with cookie hell' (#82) from payments into master
Reviewed-on: #82
2026-06-05 09:20:17 +02:00
Khwezi Mngoma 90a11dc65e Refactored to deal with cookie hell
continuous-integration/drone/pr Build is passing
2026-06-05 09:19:32 +02:00
khwezi de955a96a8 Merge pull request 'Removed login proto handling' (#81) from payments into master
Reviewed-on: #81
2026-06-05 08:56:17 +02:00
Khwezi Mngoma cdf5cfb5cd Removed login proto handling
continuous-integration/drone/pr Build is passing
2026-06-05 08:55:31 +02:00
khwezi c4d3bb4cdf Merge pull request 'Simplified login process' (#80) from payments into master
Reviewed-on: #80
2026-06-05 08:18:12 +02:00
Khwezi Mngoma 65f102f18a Simplified login process
continuous-integration/drone/pr Build is passing
2026-06-05 08:17:32 +02:00
khwezi cdc80db214 Merge pull request 'Refactored logout endpoint' (#79) from payments into master
Reviewed-on: #79
2026-06-05 08:15:50 +02:00
Khwezi Mngoma 4576b5aa2b Refactored logout endpoint
continuous-integration/drone/pr Build is passing
2026-06-05 08:15:13 +02:00
khwezi 3847927ace Merge pull request 'Added port stripping' (#78) from payments into master
Reviewed-on: #78
2026-06-05 07:37:16 +02:00
Khwezi Mngoma d38d1dd059 Added port stripping
continuous-integration/drone/pr Build is passing
2026-06-05 07:36:41 +02:00
khwezi c27aba1954 Merge pull request 'Forcing login https proto on redirect' (#77) from payments into master
Reviewed-on: #77
2026-06-05 06:40:33 +02:00
Khwezi Mngoma e646d16053 Forcing login https proto on redirect
continuous-integration/drone/pr Build is passing
2026-06-05 06:39:47 +02:00
khwezi 1c946dab26 Merge pull request 'Refactored security components' (#76) from payments into master
Reviewed-on: #76
2026-06-05 05:44:47 +02:00
Khwezi Mngoma 20c3ad9569 Refactored security components
continuous-integration/drone/pr Build is passing
2026-06-05 05:43:56 +02:00
khwezi 9977cf27b9 Merge pull request 'Added a redirect packet attachment to UI signout process' (#75) from payments into master
Reviewed-on: #75
2026-06-04 16:03:06 +02:00
Khwezi Mngoma cf7eed0603 Added a redirect packet attachment to UI signout process
continuous-integration/drone/pr Build is passing
2026-06-04 16:02:29 +02:00
khwezi 8e9ac1e1ad Merge pull request 'Added signout functionality for user authentik link' (#74) from payments into master
Reviewed-on: #74
2026-06-04 14:40:00 +02:00
Khwezi Mngoma fa79bd8021 Added signout functionality for user authentik link
continuous-integration/drone/pr Build is passing
2026-06-04 14:39:14 +02:00
khwezi 16dae7c9fb Merge pull request 'Added UI security' (#73) from payments into master
Reviewed-on: #73
2026-06-04 14:09:08 +02:00
Khwezi Mngoma 5666ffd474 Added UI security
continuous-integration/drone/pr Build is passing
2026-06-04 14:08:27 +02:00
khwezi f8153e86b4 Merge pull request 'Applied required scope policy' (#72) from payments into master
Reviewed-on: #72
2026-06-04 08:58:03 +02:00
Khwezi Mngoma eef1096ec5 Applied required scope policy
continuous-integration/drone/pr Build is passing
2026-06-04 08:57:16 +02:00
khwezi 84d33d3607 Merge pull request 'Refactored authentication' (#71) from payments into master
Reviewed-on: #71
2026-06-04 08:48:15 +02:00
Khwezi Mngoma 8f97d7cf38 Refactored authentication
continuous-integration/drone/pr Build is passing
2026-06-04 08:47:18 +02:00
khwezi f51cc03327 Merge pull request 'Disabled caching' (#70) from payments into master
Reviewed-on: #70
2026-06-03 17:49:04 +02:00
Khwezi Mngoma 652ca82a57 Disabled caching
continuous-integration/drone/pr Build is passing
2026-06-03 17:48:38 +02:00
khwezi aff6fcabf4 Merge pull request 'payments' (#69) from payments into master
Reviewed-on: #69
2026-06-03 17:38:45 +02:00
Khwezi Mngoma a50830ffaa Refactored auth
continuous-integration/drone/pr Build is passing
2026-06-03 17:37:56 +02:00
Khwezi Mngoma ee6f8a283e Refactored oauth registration 2026-06-03 17:37:33 +02:00
khwezi 8140b5fe65 Merge pull request 'Added authentik configuration and service registration' (#68) from payments into master
Reviewed-on: #68
2026-06-03 12:53:55 +02:00
Khwezi Mngoma fda97db5fa Added authentik configuration and service registration
continuous-integration/drone/pr Build is passing
2026-06-03 12:52:59 +02:00
khwezi 9285cedfa9 Merge pull request 'Refactored token message' (#67) from payments into master
Reviewed-on: #67
2026-06-03 12:16:31 +02:00
Khwezi Mngoma 29574f4df0 Refactored token message
continuous-integration/drone/pr Build is passing
2026-06-03 12:15:31 +02:00
khwezi 343874551a Merge pull request 'Added 0.0.0.0 health checks url transformation' (#66) from payments into master
Reviewed-on: #66
2026-06-03 11:24:02 +02:00
Khwezi Mngoma b4a48c9cbf Added 0.0.0.0 health checks url transformation
continuous-integration/drone/pr Build is passing
2026-06-03 11:23:13 +02:00
khwezi 0eac9d533f Merge pull request 'payments' (#65) from payments into master
Reviewed-on: #65
2026-06-03 11:12:10 +02:00
Khwezi Mngoma 961f03c1c7 Added guardrails around the cluster as well as software level
continuous-integration/drone/pr Build is passing
2026-06-03 11:11:22 +02:00
Khwezi Mngoma a0cf847e51 Added job interruption handling 2026-06-03 10:40:29 +02:00
khwezi 24ba609e0c Merge pull request 'Excluded http environment from checkin' (#64) from payments into master
Reviewed-on: #64
2026-06-03 00:51:30 +02:00
Khwezi Mngoma 4bac14881d Excluded http environment from checkin
continuous-integration/drone/pr Build is passing
2026-06-03 00:50:20 +02:00
khwezi 29f6d66c44 Merge pull request 'Fixed tests' (#63) from payments into master
Reviewed-on: #63
2026-06-03 00:41:26 +02:00
Khwezi Mngoma fd6057d691 Fixed tests
continuous-integration/drone/pr Build is passing
2026-06-03 00:41:02 +02:00
khwezi bcfc9ef962 Merge pull request 'Added loopback address whitelisting override' (#62) from payments into master
Reviewed-on: #62
2026-06-03 00:38:29 +02:00
Khwezi Mngoma 7961d934ba Added loopback address whitelisting override
continuous-integration/drone/pr Build is failing
2026-06-03 00:37:59 +02:00
khwezi b4e967acc9 Merge pull request 'payments' (#61) from payments into master
Reviewed-on: #61
2026-06-03 00:23:22 +02:00
Khwezi Mngoma 0a95df4c39 Added midrand shop .http test folder
continuous-integration/drone/pr Build is passing
2026-06-03 00:22:44 +02:00
Khwezi Mngoma ad9fa0ab91 Added http test folder to features test project 2026-06-03 00:21:57 +02:00
Khwezi Mngoma 4df903e456 Added shared api feature 2026-06-03 00:20:46 +02:00
khwezi 205bbb9f3f Merge pull request 'payments' (#60) from payments into master
Reviewed-on: #60
2026-06-02 23:48:29 +02:00
Khwezi Mngoma 763d24f11f Updated nuget packages
continuous-integration/drone/pr Build is passing
2026-06-02 23:47:10 +02:00
Khwezi Mngoma 0ed04211bf Added payment gateway ledger service to payments feature 2026-06-02 23:44:45 +02:00
khwezi 73ef4b04a9 Merge pull request 'Used scope to inject services' (#59) from payments into master
Reviewed-on: #59
2026-06-02 00:03:50 +02:00
Khwezi Mngoma 5ab2d29aac Used scope to inject services
continuous-integration/drone/pr Build is passing
2026-06-02 00:03:01 +02:00
khwezi 780415b6d4 Merge pull request 'Fixed event service scope issue' (#58) from payments into master
Reviewed-on: #58
2026-06-01 23:33:11 +02:00
Khwezi Mngoma 139ca1f866 Fixed event service scope issue
continuous-integration/drone/pr Build is passing
2026-06-01 23:32:35 +02:00
khwezi 879094073a Merge pull request 'Added PayfastPaymentConfirmationReceivedEvent' (#57) from payments into master
Reviewed-on: #57
2026-06-01 22:52:40 +02:00
Khwezi Mngoma 45c2e8310a Added PayfastPaymentConfirmationReceivedEvent
continuous-integration/drone/pr Build is passing
2026-06-01 22:51:49 +02:00
khwezi b369dad452 Merge pull request 'Implemented overload taking in IFormCollection' (#56) from payments into master
Reviewed-on: #56
2026-06-01 17:03:33 +02:00
Khwezi Mngoma ac31c6ada8 Implemented overload taking in IFormCollection
continuous-integration/drone/pr Build is passing
2026-06-01 17:02:30 +02:00
khwezi c938bfec09 Merge pull request 'Refactored shasher payfast confirmation response handling' (#55) from payments into master
Reviewed-on: #55
2026-06-01 16:38:44 +02:00
Khwezi Mngoma 5eb6dbc8b2 Refactored shasher payfast confirmation response handling
continuous-integration/drone/pr Build is passing
2026-06-01 16:36:33 +02:00
khwezi adc14038db Merge pull request 'Fixed mdf file name hasher' (#54) from payments into master
Reviewed-on: #54
2026-06-01 09:27:54 +02:00
Khwezi Mngoma afc984f3bc Fixed mdf file name hasher
continuous-integration/drone/pr Build is passing
2026-06-01 09:25:46 +02:00
khwezi 61cb6c2228 Merge pull request 'payments' (#53) from payments into master
Reviewed-on: #53
2026-06-01 09:21:01 +02:00
Khwezi Mngoma f1ef614cbb Removed passphrase from PaymentGateway
continuous-integration/drone/pr Build is failing
2026-06-01 09:19:08 +02:00
Khwezi Mngoma 8fe129e19c Implemented HashService and tests 2026-06-01 09:15:14 +02:00
Khwezi Mngoma c4f73fd999 Removed comments from function blocks 2026-05-31 19:38:03 +02:00
Khwezi Mngoma 48f4cd45f1 Implemented the HashService and its service registration code 2026-05-31 19:37:19 +02:00
Khwezi Mngoma f88cc42a88 Completed payment service implementation 2026-05-31 18:42:00 +02:00
Khwezi Mngoma 0e21ec283d Added payment database objects 2026-05-31 12:05:59 +02:00
khwezi e7a798b5e9 Merge pull request 'Added search state model' (#52) from midrandshop into master
Reviewed-on: #52
2026-05-30 19:54:57 +02:00
Khwezi Mngoma 494b806744 Added search state model
continuous-integration/drone/pr Build is passing
2026-05-30 19:54:14 +02:00
khwezi 41b6b71b31 Merge pull request 'Redacted Price resolution from GetProductAsync' (#51) from midrandshop into master
Reviewed-on: #51
2026-05-30 18:55:46 +02:00
Khwezi Mngoma 0702caa42d Redacted Price resolution from GetProductAsync
continuous-integration/drone/pr Build is passing
2026-05-30 18:55:23 +02:00
khwezi ee6beef603 Merge pull request 'Redacted Product.Price mapping on filter' (#50) from midrandshop into master
Reviewed-on: #50
2026-05-30 18:49:38 +02:00
Khwezi Mngoma 4f6dbfcd37 Redacted Product.Price mapping on filter
continuous-integration/drone/pr Build is passing
2026-05-30 18:49:15 +02:00
khwezi 1c3f3eaf0d Merge pull request 'Added a way to get the Author by productId' (#49) from midrandshop into master
Reviewed-on: #49
2026-05-30 18:20:21 +02:00
Khwezi Mngoma 91ede2d568 Added a way to get the Author by productId
continuous-integration/drone/pr Build is passing
2026-05-30 18:17:55 +02:00
khwezi 2e77666d9e Merge pull request 'Added category seeder' (#48) from midrandshop into master
Reviewed-on: #48
2026-05-30 16:08:11 +02:00
Khwezi Mngoma 4d21740124 Added category seeder
continuous-integration/drone/pr Build is passing
2026-05-30 16:07:23 +02:00
khwezi 1977b6b301 Merge pull request 'midrandshop' (#47) from midrandshop into master
Reviewed-on: #47
2026-05-30 15:37:09 +02:00
Khwezi Mngoma 18d1640808 Added product categories
continuous-integration/drone/pr Build is passing
2026-05-30 15:35:35 +02:00
Khwezi Mngoma e40c958066 Implemented category feature 2026-05-30 14:22:00 +02:00
khwezi 0ab14d8b63 Merge pull request 'midrandshop' (#46) from midrandshop into master
Reviewed-on: #46
2026-05-30 00:14:10 +02:00
Khwezi Mngoma 2db3b3d293 Added customer seeder with order data
continuous-integration/drone/pr Build is passing
2026-05-30 00:11:19 +02:00
Khwezi Mngoma 50eee03dbe Product seeding completed 2026-05-29 23:02:06 +02:00
khwezi 466458e230 Merge pull request 'Implemented Product Data Seeder' (#45) from midrandshop into master
Reviewed-on: #45
2026-05-29 18:56:45 +02:00
Khwezi Mngoma 60fcc70e98 Implemented Product Data Seeder
continuous-integration/drone/pr Build is passing
2026-05-29 18:56:08 +02:00
khwezi 141d32f591 Merge pull request 'Refactored migrations' (#44) from midrandshop into master
Reviewed-on: #44
2026-05-29 09:03:27 +02:00
Khwezi Mngoma 38e765203d Refactored migrations
continuous-integration/drone/pr Build is passing
2026-05-29 09:02:47 +02:00
khwezi d9e7f225ae Merge pull request 'midrandshop' (#43) from midrandshop into master
Reviewed-on: #43
2026-05-29 08:23:15 +02:00
Khwezi Mngoma 9eb3526a2e Moved Book Page DTOs
continuous-integration/drone/pr Build is passing
2026-05-29 08:21:11 +02:00
Khwezi Mngoma 4397976ed8 Implemented order service tests 2026-05-29 08:18:29 +02:00
Khwezi Mngoma 2546c34ffc Wrote tests for most services, applied EF core optimisations 2026-05-29 01:05:22 +02:00
Khwezi Mngoma 4e53ff8a37 ProductService tested and stable 2026-05-28 17:28:33 +02:00
Khwezi Mngoma 2a0b34c730 Refactored database registration to allow postgres to use internal representations for afster performance 2026-05-28 09:05:49 +02:00
khwezi 6ae63e2ad1 Merge pull request 'midrandshop' (#42) from midrandshop into master
Reviewed-on: #42
2026-05-27 09:15:44 +02:00
144 changed files with 15199 additions and 808 deletions
+4 -1
View File
@@ -360,4 +360,7 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
/LiteCharms.Features.Tests/http/http-client.env.json
/LiteCharms.Features.Tests/http/midrandshop-api/http-client.env.json
/LiteCharms.Features.Tests/http/authentik/http-client.env.json
@@ -0,0 +1,133 @@
using LiteCharms.Features.MidrandBooks.Categories;
using LiteCharms.Features.MidrandBooks.Products;
namespace LiteCharms.Features.MidrandBooks.Seed;
public sealed class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features,
ILogger<CategorySeederService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!await features.IsEnabledAsync("CategorySeederService")) return;
logger.LogInformation("Category and Product-Tag Mapping Seeding started (15-char limit applied)");
// Initialize Bogus to ensure repeatable distribution matrix pathing
var faker = new Faker();
Randomizer.Seed = new Random(101);
// 1. Curate Broad Book Categories (IsMain = true, Max 20, Max 15 chars)
var broadMainCategories = new[]
{
"Fiction", "Non-Fiction", "Youth & Kids", "Academic",
"Biographies", "Business", "Sci-Fi & Fantasy",
"Thrillers", "Self-Help", "History",
"Spirituality", "Arts & Photo", "Technology",
"Cookbooks", "Travel & Maps", "Poetry & Drama", "Graphic Novels"
};
// 2. Curate Niche Subcategories/Tags (IsMain = false, Max 15 chars)
var specializedSubCategories = new[]
{
"Cyberpunk", "Space Opera", "Historical Fix", "Cozy Mystery", "True Crime",
"Agile Project", "Software Eng", "AI & ML", "Cloud Comput",
"SA History", "African Lit", "Apartheid Era", "Mandela Legacy",
"Finance", "Investments", "Startup", "Leadership",
"CBT Therapy", "Mindfulness", "Yoga & Health",
"Baking Basics", "African Food", "Vegan Recipes",
"Ancient World", "WWII History", "Geopolitics",
"Writing Guides", "Criticism", "Classic Poetry",
"Early Learning", "Teen Romance", "Survival",
"Urban Fantasy", "Dark Fantasy", "Psych Thriller", "Hard Sci-Fi",
"Data Science", "DevOps", "Cybersecurity",
"Economics", "Real Estate", "Governance",
"Essays", "Memoirs", "Art History",
"Architecture", "Photography", "Travel Writing",
"Gaming Culture", "Philosophy", "Ethics",
"DIY Home", "SA Gardening", "Parenting"
};
// 3. Seed Main Categories into the System
logger.LogInformation("Seeding broad main categories...");
foreach (var mainCat in broadMainCategories)
{
if (stoppingToken.IsCancellationRequested) return;
// Defensive truncation fallback just in case strings get modified later
string safeName = mainCat.Length > 15 ? mainCat.Substring(0, 15) : mainCat;
var result = await categoryService.CreateCategoryAsync(safeName, isMain: true, stoppingToken);
if (result.IsFailed && !result.Errors[0].Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("Notice while adding main category '{Name}': {Msg}", safeName, result.Errors[0].Message);
}
}
// 4. Seed Subcategories into the System
logger.LogInformation("Seeding boundless specialized niche tags...");
foreach (var subCat in specializedSubCategories)
{
if (stoppingToken.IsCancellationRequested) return;
string safeName = subCat.Length > 15 ? subCat.Substring(0, 15) : subCat;
var result = await categoryService.CreateCategoryAsync(safeName, isMain: false, stoppingToken);
if (result.IsFailed && !result.Errors[0].Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("Notice while adding subcategory '{Name}': {Msg}", safeName, result.Errors[0].Message);
}
}
// 5. Query back all enabled categories to extract active IDs for junction mapping
var fetchMainResult = await categoryService.GetCategoriesAsync(isMain: true, stoppingToken);
var fetchSubResult = await categoryService.GetCategoriesAsync(isMain: false, stoppingToken);
if (fetchMainResult.IsFailed || fetchSubResult.IsFailed)
{
logger.LogError("Aborting junction seeding: Could not retrieve categories from data store.");
return;
}
var mainCategoryIds = fetchMainResult.Value.Select(c => c.Id).ToArray();
var subCategoryIds = fetchSubResult.Value.Select(c => c.Id).ToArray();
// 6. Map Categories to your Product Collection (Product IDs 0 - 21)
logger.LogInformation("Beginning Product-Category mapping assignments for Product IDs 0 through 21...");
for (long productId = 0; productId <= 21; productId++)
{
if (stoppingToken.IsCancellationRequested) break;
// Every book belongs to 1 or 2 main categories
int mainCategoriesToAssign = faker.Random.Number(1, 2);
var chosenMainIds = faker.PickRandom(mainCategoryIds, mainCategoriesToAssign).Distinct();
foreach (var mainId in chosenMainIds)
{
var linkResult = await productService.AddProductCategoryAsync(productId, mainId, stoppingToken);
if (linkResult.IsFailed)
{
if (!linkResult.Errors[0].Message.Contains("exist", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Junction note for Product {PId} and Main Category {CId}: {Msg}", productId, mainId, linkResult.Errors[0].Message);
}
}
}
// Every book gets 1 to 4 granular subgenre tags
int subCategoriesToAssign = faker.Random.Number(1, 4);
var chosenSubIds = faker.PickRandom(subCategoryIds, subCategoriesToAssign).Distinct();
foreach (var subId in chosenSubIds)
{
var linkResult = await productService.AddProductCategoryAsync(productId, subId, stoppingToken);
if (linkResult.IsFailed && !linkResult.Errors[0].Message.Contains("exist", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Junction note for Product {PId} and Sub Category {CId}: {Msg}", productId, subId, linkResult.Errors[0].Message);
}
}
}
logger.LogInformation("Category and Product-Tag Mapping Seeding completed successfully.");
}
}
@@ -0,0 +1,14 @@
namespace LiteCharms.Features.MidrandBooks.Seed.Configuration;
public sealed class CdnSettings
{
public string? BaseCdn { get; set; }
public string[]? BookCovers { get; set; }
public string[]? Authors { get; set; }
public string[]? AuthorThumbnails { get; set; }
public string[]? BookThumbnails { get; set; }
}
@@ -0,0 +1,275 @@
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Orders.Models;
namespace LiteCharms.Features.MidrandBooks.Seed;
public sealed class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features,
ILogger<CustomerSeederService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!await features.IsEnabledAsync("CustomerSeederService")) return;
logger.LogInformation("Customer Seeding started");
// 1: Add shipping providers (shippingProvider IDs will be created in sequence: 1, 2, 3)
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.FastWay, "FastWay Couriers", 39, "https://www.fastway.co.za/our-services/track-your-parcel"), stoppingToken);
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.DHL, "DHL Couriers", 60, "https://www.dhl.com/za-en/home/tracking.html"), stoppingToken);
await orderService.CreateShippingProviderAsync(new CreateShippingProvider(ShippingProviderTypes.PostNet, "Postnet Overnight Mail", 45, "https://www.postnet.co.za/tracker"), stoppingToken);
// Initialize Bogus Faker engine
var faker = new Faker();
var culture = CultureInfo.InvariantCulture;
// Ensure repeatable datasets across executions
Randomizer.Seed = new Random(84);
// South African Provinces array lookup helper
var southAfricanProvinces = new[]
{
"Gauteng", "Western Cape", "KwaZulu-Natal", "Eastern Cape",
"Free State", "Limpopo", "Mpumalanga", "North West", "Northern Cape"
};
// South African major towns matching geographic boundaries roughly
var southAfricanCities = new[] { "Midrand", "Johannesburg", "Pretoria", "Cape Town", "Durban", "Gqeberha", "Polokwane", "Nelspruit", "Bloemfontein" };
// Tracks sequential Address IDs added globally to the system across all loops
long addressSequenceCounter = 0;
// 2: Create 15 customers with resources sequentially
for (int c = 0; c < 15; c++)
{
if (stoppingToken.IsCancellationRequested) break;
// Determine if this specific iteration represents a Corporate Client or an Individual Consumer
bool isCompanyCustomer = faker.Random.Bool(0.4f); // 40% chance of seeding a corporate entity
string customerFirstName = faker.Name.FirstName();
string customerLastName = faker.Name.LastName();
string companyName = isCompanyCustomer ? faker.Company.CompanyName() : "";
string companySuffix = isCompanyCustomer ? faker.Company.CompanySuffix() : "";
string fullCompanyName = isCompanyCustomer ? $"{companyName} {companySuffix}" : "";
string customerEmail = isCompanyCustomer
? faker.Internet.Email(firstName: companyName, provider: "co.za").ToLower(culture)
: faker.Internet.Email(customerFirstName, customerLastName).ToLower(culture);
string customerPhone = faker.Phone.PhoneNumber("087#######"); // Corporate VOIP / Personal South African cell line format
string customerWebsite = isCompanyCustomer ? faker.Internet.Url().Replace("www.", $"www.{companyName.ToLower(culture)}.") : "";
string customerVat = isCompanyCustomer ? faker.Phone.PhoneNumber("4#########") : ""; // SA VAT registration starts with a 4
// Randomly select distinct Social Media channels
var chosenSocialType = faker.PickRandom<SocialMediaTypes>();
string socialMediaUrl = chosenSocialType switch
{
SocialMediaTypes.LinkedIn => isCompanyCustomer ? $"https://linkedin.com/company/{companyName.ToLower(culture)}" : $"https://linkedin.com/in/{customerFirstName.ToLower(culture)}-{customerLastName.ToLower(culture)}",
SocialMediaTypes.GitHub => $"https://github.com/{(isCompanyCustomer ? "orgs/" + companyName.ToLower(culture) : customerFirstName.ToLower(culture))}",
_ => $"https://x.com/{(isCompanyCustomer ? companyName.ToLower(culture) : customerFirstName.ToLower(culture))}"
};
// 3: Create customer
var createCustomerResult = await customerService.CreateCustomerAsync(new CreateCustomer
{
Company = fullCompanyName,
Email = customerEmail,
Phone = customerPhone,
Website = customerWebsite,
SocialMedia =
[
new Models.SocialMedia
{
Name = chosenSocialType.ToString(),
Type = chosenSocialType,
ImageUrl = $"https://cdn.example.com/icons/{chosenSocialType.ToString().ToLower(culture)}.png",
Url = socialMediaUrl
}
],
VatNumber = customerVat
}, stoppingToken);
if (createCustomerResult.IsFailed)
{
logger.LogError("Failed to create customer record at index {Index}: {Error}", c, createCustomerResult.Errors[0].Message);
break;
}
var assignedCustomerId = createCustomerResult.Value;
// 4: Create customer contact (only if customer is a company entity)
if (isCompanyCustomer)
{
var contactFirstName = faker.Name.FirstName();
var contactLastName = faker.Name.LastName();
var createContactResult = await customerService.CreateCustomerContactAsync(assignedCustomerId, new CreateCustomerContact
{
Name = contactFirstName,
LastName = contactLastName,
Phone = faker.Phone.PhoneNumber("082#######"), // Typical South African mobile prefix format
Email = faker.Internet.Email(contactFirstName, contactLastName, provider: "company.co.za").ToLower(culture),
Type = ContactTypes.Business
}, stoppingToken);
if (createContactResult.IsFailed)
{
logger.LogError("Failed to create company customer contact relation: {Error}", createContactResult.Errors[0].Message);
break;
}
}
// Shared Randomizations for Regional Postal/Building details
var primaryState = faker.PickRandom(southAfricanProvinces);
var primaryCity = faker.PickRandom(southAfricanCities);
var shippingPostalCode = faker.Random.Replace("####");
var billingState = faker.PickRandom(southAfricanProvinces);
var billingCity = faker.PickRandom(southAfricanCities);
var billingPostalCode = faker.Random.Replace("####");
// 5: Create customer address - SHIPPING
var createShippingAddressResult = await customerService.CreateCustomerAddressAsync(assignedCustomerId, new CreateCustomerAddress
{
Name = isCompanyCustomer ? "Head Office Distribution" : "My Home Residence",
BuildingType = faker.PickRandom<AddressBuildingTypes>(),
Type = AddressType.Shipping,
Street = $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Street",
City = primaryCity,
State = primaryState,
Country = "South Africa",
IsPrimary = true,
Enabled = true,
PostalCode = shippingPostalCode
}, stoppingToken);
long currentCustomerShippingAddressId = 0;
if (createShippingAddressResult.IsSuccess)
{
addressSequenceCounter++;
currentCustomerShippingAddressId = addressSequenceCounter;
}
else
{
logger.LogWarning("Failed to attach Shipping address profile: {Error}", createShippingAddressResult.Errors[0].Message);
}
// 6: Create customer address - BILLING
var createBillingAddressResult = await customerService.CreateCustomerAddressAsync(assignedCustomerId, new CreateCustomerAddress
{
Name = isCompanyCustomer ? "Accounts Payable Department" : "Billing Address",
BuildingType = faker.PickRandom<AddressBuildingTypes>(),
Type = AddressType.Billing,
Street = isCompanyCustomer ? $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Boulevard" : $"{faker.Address.BuildingNumber()} {faker.Address.StreetName()} Street",
City = billingCity,
State = billingState,
Country = "South Africa",
IsPrimary = false,
Enabled = true,
PostalCode = billingPostalCode
}, stoppingToken);
long currentCustomerBillingAddressId = 0;
if (createBillingAddressResult.IsSuccess)
{
addressSequenceCounter++;
currentCustomerBillingAddressId = addressSequenceCounter;
}
else
{
logger.LogError("Failed to attach Billing address profile: {Error}", createBillingAddressResult.Errors[0].Message);
break;
}
// 7: Challenge Extrapolation — Create a random number of orders (0 to 4 orders) per customer
int ordersToGenerate = faker.Random.Number(0, 4);
for (int o = 0; o < ordersToGenerate; o++)
{
var deliveryInstructions = faker.PickRandom(
"Leave at reception desk",
"Please call before delivery",
"At the intercom, dial 1 then option 2",
"Leave with security guard at front gate",
"Deliver to back delivery bay"
);
// Use the calculated sequential Billing Address Id for order creation
var orderResult = await orderService.CreateOrderAsync(
assignedCustomerId,
new CreateOrder(currentCustomerBillingAddressId, deliveryInstructions),
stoppingToken
);
if (orderResult.IsFailed)
{
logger.LogWarning("Failed to create purchase order shell context: {Error}", orderResult.Errors[0].Message);
continue;
}
long seededOrderId = orderResult.Value;
// Build a varying array of items using valid product bounds (IDs: 0 to 21)
int lineItemsCount = faker.Random.Number(1, 5);
var itemsList = new List<CreateOrderItem>();
for (int i = 0; i < lineItemsCount; i++)
{
long randomProductId = faker.Random.Number(0, 21);
long randomProductPriceId = faker.Random.Number(0, 21);
int itemQuantity = faker.Random.Number(1, 3);
itemsList.Add(new CreateOrderItem(randomProductId, randomProductPriceId, itemQuantity));
}
// Push bulk items payload into order via matching test framework signatures
var addItemsResult = await orderService.AddItemsToOrderAsync(seededOrderId, [.. itemsList], stoppingToken);
if (addItemsResult.IsFailed)
{
logger.LogWarning("Failed to link item collections to Order Id {Id}", seededOrderId);
continue;
}
// Randomly select an order status matrix pathing
var targetedOrderStatus = faker.PickRandom<OrderStatus>();
await orderService.UpdateOrderStatusAsync(seededOrderId, targetedOrderStatus, stoppingToken);
// Check lifecycle workflow criteria: Attach dynamic shipping if status warrants it
if (targetedOrderStatus != OrderStatus.Pending &&
targetedOrderStatus != OrderStatus.Cancelled &&
targetedOrderStatus != OrderStatus.Failed &&
currentCustomerShippingAddressId > 0)
{
// Select from seeded Shipping Providers in step 1 (IDs: 1, 2, or 3)
long randomShippingProviderId = faker.Random.Number(1, 3);
var addShippingResult = await orderService.AddShippingToOrderAsync(
seededOrderId,
new CreateShipping(currentCustomerShippingAddressId, randomShippingProviderId),
stoppingToken
);
if (addShippingResult.IsSuccess)
{
long assignedShippingId = addShippingResult.Value;
// Transition logistics flags matching delivery metrics
var shippingStatus = faker.PickRandom<ShippingStatuses>();
await orderService.UpdateShippingStatusAsync(seededOrderId, shippingStatus, stoppingToken);
if (shippingStatus == ShippingStatuses.Shipped || shippingStatus == ShippingStatuses.Delivered)
{
string rawTrackingCode = $"ZA{faker.Random.Replace("#########")}NV";
await orderService.UpdateShippingTrackingNumberAsync(seededOrderId, assignedShippingId, rawTrackingCode);
}
}
}
}
logger.LogInformation("Successfully seeded customer profile #{Index}: {Name} alongside {Count} orders.", c, isCompanyCustomer ? fullCompanyName : $"{customerFirstName} {customerLastName}", ordersToGenerate);
}
logger.LogInformation("Customer Seeding completed successfully.");
}
}
@@ -0,0 +1,160 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>5c3bc894-8654-4691-99e8-f90d3414843f</UserSecretsId>
</PropertyGroup>
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.5" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.102">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.5.0" />
<PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.18.1" />
<PackageReference Include="Quartz.AspNetCore" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
<!-- Global Usings -->
<Using Include="Quartz" />
</ItemGroup>
<!-- Configuration -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<!-- Health Checks -->
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
</ItemGroup>
<!-- Open Telemetry -->
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" />
<Using Include="OpenTelemetry.Exporter" />
<Using Include="OpenTelemetry.Logs" />
<Using Include="OpenTelemetry.Metrics" />
<Using Include="OpenTelemetry.Trace" />
</ItemGroup>
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- Global Usings -->
<Using Include="Npgsql" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
</ItemGroup>
<!-- Email -->
<ItemGroup>
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.17.0" />
<!-- Global Usings-->
<Using Include="MimeKit" />
<Using Include="MailKit.Net.Smtp" />
</ItemGroup>
<!-- CQRS -->
<ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
<!-- Global Usings -->
<Using Include="FluentResults" />
<Using Include="Mediator" />
</ItemGroup>
<!-- Amazon S3 SDK -->
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.6" />
<PackageReference Include="AWSSDK.S3" Version="4.0.24.3" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings -->
<Using Include="Amazon.S3" />
<Using Include="Amazon.S3.Model" />
<Using Include="Amazon.Runtime" />
</ItemGroup>
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Bogus" />
<Using Include="Microsoft.FeatureManagement" />
<Using Include="System.Globalization" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" />
<Using Include="System.Text" />
<Using Include="System.Text.Json" />
<Using Include="System.Threading.Channels" />
<Using Include="System.Collections.ObjectModel" />
<Using Include="System.Diagnostics" />
<Using Include="System.Diagnostics.Metrics" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="System.Security.Cryptography" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,246 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Seed.Configuration;
namespace LiteCharms.Features.MidrandBooks.Seed;
public sealed class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService,
IFeatureManager features, IOptions<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService
{
private readonly CdnSettings cdnSettings = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (await features.IsEnabledAsync("ProductsSeederService") is not true) return;
logger.LogInformation("Product Seeding started");
if (cdnSettings.BookCovers is null || cdnSettings.BookCovers.Length == 0)
{
logger.LogWarning("No book covers found in CDN settings. Seeding aborted.");
return;
}
// Initialize Bogus Faker engine
var faker = new Faker();
var culture = CultureInfo.InvariantCulture;
// Ensure repeatable data sets if run multiple times by anchoring the seed
Randomizer.Seed = new Random(42);
foreach (var bookCover in cdnSettings.BookCovers)
{
if (stoppingToken.IsCancellationRequested) break;
// Generate beautifully mixed eclectic topics on the fly
var bookTopic = faker.PickRandom(
// --- Tech & IT ---
"C# 12 & Modern .NET Architecture",
"PostgreSQL Database Optimization",
"Docker & Kubernetes in Production",
"Domain-Driven Design Paradigms",
"Artificial Intelligence with Python",
// --- Sci-Fi & Fantasy ---
"The Chronicles of the Quantum Nebula",
"Legends of the Lost Cybernetic Kingdom",
"Parallel Dimensions and Rogue Time Streams",
"The Last Android in Neo-Johannesburg",
// --- Thrillers, Mystery & Crime ---
"The Midnight Code Cryptograph",
"Shadows in the Highveld",
"The Silent Witness of Midrand",
"Deception on the 14th Floor",
// --- Business, Finance & Wealth ---
"Mastering the South African Tech Market",
"The Modern Entrepreneur's Blueprint",
"Generational Wealth and Venture Capital",
"Negotiation Tactics for High-Stakes Deals",
// --- Self-Help & Personal Growth ---
"The Art of Relentless Focus",
"Building High-Performance Habits",
"The Mindfulness Guide for Software Engineers",
"Unlocking Creative Flow Under Pressure"
);
// Dynamic raw title generation formulas executed via random function picker
var titlePatterns = new Func<string>[]
{
() => $"{faker.Company.CatchPhrase()} with {bookTopic}",
() => $"The {faker.Commerce.ProductAdjective()} Guide to {bookTopic}",
() => $"Mastering {bookTopic}: A {faker.Company.Bs()} Blueprint",
() => $"{bookTopic} for the Modern {faker.Name.JobTitle()}",
() => $"Advanced {bookTopic}: Demystifying the {faker.Company.CatchPhrase()}",
() => $"{faker.Random.Replace("###")} Blueprints for {bookTopic}"
};
// Pick a format template and resolve it down to raw string text
var rawTitle = faker.PickRandom(titlePatterns)();
var bookTitle = rawTitle.Length > 255 ? rawTitle[..252] + "..." : rawTitle;
var rawSummary = $"A comprehensive guide to mastering {bookTopic}. Learn modern implementation techniques through real-world software engineering paradigms.";
var bookSummary = rawSummary.Length > 512 ? rawSummary[..509] + "..." : rawSummary;
// Generating a single concise paragraph ensures a rich text description falling safely well under 1024
var rawDescription = faker.Lorem.Paragraph(3);
var bookDescription = rawDescription.Length > 1024 ? rawDescription[..1021] + "..." : rawDescription;
var authorFirstName = faker.Name.FirstName();
var authorLastName = faker.Name.LastName();
var publisherCompany = faker.Company.CompanyName();
// Safe bounded random picking for book thumbnails
string? pickedBookThumbnail = null;
string? pickedBookThumbnail1 = null;
string? pickedBookThumbnail2 = null;
string? pickedBookThumbnail3 = null;
string? pickedBookThumbnail4 = null;
if (cdnSettings.BookThumbnails is not null && cdnSettings.BookThumbnails.Length > 0)
{
pickedBookThumbnail = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
pickedBookThumbnail1 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
pickedBookThumbnail2 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
pickedBookThumbnail3 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
pickedBookThumbnail4 = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.BookThumbnails)}";
}
// Step 1: Add Product
var productCreateResult = await productService.CreateProductAsync(new Products.Models.CreateProduct
{
Name = bookTitle,
Summary = bookSummary,
Description = bookDescription,
ImageUrl = $"{cdnSettings.BaseCdn}{bookCover}",
Type = ProductTypes.Book,
Metadata = new Models.ProductMetadata
{
CopyrightInfo = $"© {DateTime.UtcNow.Year} {publisherCompany}. All rights reserved.",
ManufactureDate = faker.Date.Past(3).ToString("yyyy-MM-dd", culture),
Manufacturer = $"{authorFirstName} {authorLastName} / {publisherCompany}",
SerialNumber = faker.Phone.PhoneNumber("978-##########")
},
Categories = ["Coding", "Computers", "IT"],
ThumbnailUrls = pickedBookThumbnail is not null ? [pickedBookThumbnail, pickedBookThumbnail1!, pickedBookThumbnail2!, pickedBookThumbnail3!, pickedBookThumbnail4!] : null
}, stoppingToken);
if (productCreateResult.IsFailed)
{
logger.LogError("Failed to create product: {Error}", productCreateResult.Errors[0].Message);
break;
}
// Step 2: Enable product so it can show on the shop
var enableProductResult = await productService.UpdateProductStatusAsync(productId: productCreateResult.Value, isEnabled: true, stoppingToken);
if (enableProductResult.IsFailed)
{
logger.LogError("Failed to enable created product: {Error}", enableProductResult.Errors[0].Message);
break;
}
// Step 3: Create Product Price
var productPriceCreateResult = await productService.CreateProductPriceAsync(productId: productCreateResult.Value, request: new Products.Models.CreateProductPrice
{
Amount = Math.Round(faker.Random.Decimal(150m, 650m), 2),
Discount = 0.0m
}, stoppingToken);
if (productPriceCreateResult.IsFailed)
{
logger.LogError("Failed to create product price: {Error}", productPriceCreateResult.Errors[0].Message);
break;
}
// Safe bounded picking for Authors (Real Avatars)
string authorAvatarUrl = faker.Internet.Avatar(); // Fallback
if (cdnSettings.Authors is not null && cdnSettings.Authors.Length > 0)
{
authorAvatarUrl = $"{cdnSettings.BaseCdn}{faker.PickRandom(cdnSettings.Authors)}";
}
// Safe bounded picking for Author Thumbnails (Cartoon Avatars)
string? authorThumbnailUrl = null;
if (cdnSettings.AuthorThumbnails is not null && cdnSettings.AuthorThumbnails.Length > 0)
{
var selectedThumb = faker.PickRandom(cdnSettings.AuthorThumbnails);
authorThumbnailUrl = $"{cdnSettings.BaseCdn}{selectedThumb}.jpg";
}
// Synthesize a highly dynamic, organic opening bio statement
var professionalBackgrounds = new[]
{
$"{authorFirstName} {authorLastName} is an award-winning {faker.Name.JobDescriptor()} {faker.Name.JobTitle()} with over {faker.Random.Number(5, 25)} years of core engineering domain expertise.",
$"As a veteran systems consultant and practicing {faker.Name.JobTitle()}, {authorFirstName} has spent decades leading digital infrastructure transformations and managing complex topologies.",
$"Operating from modern innovation hubs, {authorFirstName} {authorLastName} specializes in global product strategies and serves as an authority in {faker.Name.JobDescriptor()} computing.",
$"With a rich professional background as a principal {faker.Name.JobTitle()} at {publisherCompany}, {authorFirstName} has spent a lifetime refining the system workflows highlighted here."
};
// Pick a randomized context hook and append a 2-paragraph contextual narrative block
var biographyPrefix = faker.PickRandom(professionalBackgrounds);
var authorBiography = $"{biographyPrefix} {faker.Lorem.Paragraph(2)}";
// Step 4: Create Author
var authorCreateResult = await authorService.CreateAuthorAsync(request: new Authors.Models.CreateAuthor
{
Name = authorFirstName,
LastName = authorLastName,
Company = publisherCompany,
VatNumber = faker.Random.Bool() ? faker.Phone.PhoneNumber("4#########") : "",
PublisherType = faker.PickRandom<PublisherTypes>(),
Email = faker.Internet.Email(authorFirstName, authorLastName),
Website = faker.Internet.Url(),
ImageUrl = authorAvatarUrl,
SocialMedia =
[
new Models.SocialMedia
{
Name = "LinkedIn",
ImageUrl = "https://cdn.example.com/icons/linkedin.png",
Type = SocialMediaTypes.LinkedIn,
Url = $"https://linkedin.com/in/{authorFirstName.ToLower(culture)}-{authorLastName.ToLower(culture)}"
},
new Models.SocialMedia
{
Name = "GitHub",
ImageUrl = "https://cdn.example.com/icons/github.png",
Type = SocialMediaTypes.GitHub,
Url = $"https://github.com/tech-{authorFirstName.ToLower(culture)}"
}
],
Biography = authorBiography,
ThumbnailImageUrl = authorThumbnailUrl
}, stoppingToken);
if (authorCreateResult.IsFailed)
{
logger.LogError("Failed to create author: {Error}", authorCreateResult.Errors[0].Message);
break;
}
// Step 5: Create Author-Book link (product linkage)
var authorBookCreateResult = await booksService.CreateBookAsync(authorId: authorCreateResult.Value, productId: productCreateResult.Value, stoppingToken);
if (authorBookCreateResult.IsFailed)
{
logger.LogError("Failed to create author-book linkage: {Error}", authorBookCreateResult.Errors[0].Message);
break;
}
var enableAuthorBookResult = await booksService.UpdateBookStatusAsync(bookId: authorBookCreateResult.Value, isEnabled: true, stoppingToken);
if (enableAuthorBookResult.IsFailed)
{
logger.LogError("Failed to enable author-book link: {Error}", enableAuthorBookResult.Errors[0].Message);
break;
}
logger.LogInformation("Successfully seeded book product: {Title}", bookTitle);
}
logger.LogInformation("Product Seeding completed successfully.");
}
}
@@ -0,0 +1,27 @@
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Seed;
using LiteCharms.Features.MidrandBooks.Seed.Configuration;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddCommandLine(args)
.AddUserSecrets(typeof(Program).Assembly)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();
builder.Services.AddScopedFeatureManagement();
builder.Services
.AddLogging()
.AddShopServices()
.AddHostedService<ProductsSeederService>()
.AddHostedService<CategorySeederService>()
.AddHostedService<CustomerSeederService>()
.AddMidrandShopDatabase(builder.Configuration);
builder.Services.Configure<CdnSettings>(options => builder.Configuration.GetSection(nameof(CdnSettings)).Bind(options));
using var host = builder.Build();
await host.RunAsync();
@@ -0,0 +1,266 @@
{
"FeatureManagement": {
"CategorySeederService": false,
"CustomerSeederService": false,
"ProductsSeederService": false
},
"CdnSettings": {
"BaseCdn": "https://bookshop.cdn.khongisa.co.za/design/",
"BookCovers": [
"144e0314-0bd8-4e2c-8814-2b34608c0600_1764780116467.webp",
"2bf1f9a2-7b25-4fcf-9aa7-08941ea21e6c_1764838499686.webp",
"3cd172b9-416e-4b3a-b613-7ff0a3aecc4c_1764780129459.webp",
"762947b6-63ac-436e-98fb-91dd94de1d82_1764780126141.webp",
"7b42f23f-666c-4dd9-82e1-9b928e1b4db7_1764780186376.webp",
"8e281eea-8910-473d-b650-43f539995d9e_1764780152316.webp",
"91d18e2c-6ee6-44b4-84a5-8692db5dbfe4_1764780114484.webp",
"94fdf403-4d10-4cb4-a537-fc914a1f5ba8_1764780198423.webp",
"9b881786-75e1-47f7-9bc9-27188d46d1ec_1764780122458.webp",
"c417236d-a628-45eb-82c2-ec1cb0326e4f_1765006317965.webp",
"clpqjsgi71jpr1i6392e449l2.webp",
"clps1mk9f0m1z1i32grvq17tg.webp",
"clq550xxy2dab1ywb6mdv4acv.webp",
"clq5535o52dj51yw557ml49c1.webp",
"clq55455w2dcm1yvd8d8258d8.webp",
"clq558fkh2djy1ywbghwcawnh.webp",
"clq55cvqh2dqi1yyratl123wq.webp",
"clrhnk94e115k1y00bg8h9ixb.webp",
"d44a3c04-f124-4f0b-8301-3841ae2fd439_1764780121224.webp",
"e6ba52f208914285bcdf1966cfb08f6f.jpg",
"fa9cbbe6-f947-4f83-8e98-61d2661f43e0_1764841636705.webp"
],
"Authors": [
"authors/uifaces-human-avatar.jpg",
"authors/uifaces-human-avatar-1.jpg",
"authors/uifaces-human-avatar-2.jpg",
"authors/uifaces-human-avatar-3.jpg",
"authors/uifaces-human-avatar-4.jpg",
"authors/uifaces-human-avatar-5.jpg",
"authors/uifaces-human-avatar-6.jpg",
"authors/uifaces-human-avatar-7.jpg",
"authors/uifaces-human-avatar-8.jpg",
"authors/uifaces-human-avatar-9.jpg",
"authors/uifaces-human-avatar-10.jpg",
"authors/uifaces-human-avatar-11.jpg",
"authors/uifaces-human-avatar-12.jpg",
"authors/uifaces-human-avatar-13.jpg",
"authors/uifaces-human-avatar-14.jpg",
"authors/uifaces-human-avatar-15.jpg",
"authors/uifaces-human-avatar-16.jpg"
],
"AuthorThumbnails": [
"authors/thumbnails/uifaces-cartoon-avatar-1",
"authors/thumbnails/uifaces-cartoon-avatar-2",
"authors/thumbnails/uifaces-cartoon-avatar-3",
"authors/thumbnails/uifaces-cartoon-avatar-4",
"authors/thumbnails/uifaces-cartoon-avatar-5",
"authors/thumbnails/uifaces-cartoon-avatar-6",
"authors/thumbnails/uifaces-cartoon-avatar-7",
"authors/thumbnails/uifaces-cartoon-avatar-8",
"authors/thumbnails/uifaces-cartoon-avatar-9",
"authors/thumbnails/uifaces-cartoon-avatar-10"
],
"BookThumbnails": [
"thumbnails/book_thumbnail_001.jpg",
"thumbnails/book_thumbnail_002.jpg",
"thumbnails/book_thumbnail_003.jpg",
"thumbnails/book_thumbnail_004.jpg",
"thumbnails/book_thumbnail_005.jpg",
"thumbnails/book_thumbnail_006.jpg",
"thumbnails/book_thumbnail_007.jpg",
"thumbnails/book_thumbnail_008.jpg",
"thumbnails/book_thumbnail_009.jpg",
"thumbnails/book_thumbnail_010.jpg",
"thumbnails/book_thumbnail_011.jpg",
"thumbnails/book_thumbnail_012.jpg",
"thumbnails/book_thumbnail_013.jpg",
"thumbnails/book_thumbnail_014.jpg",
"thumbnails/book_thumbnail_015.jpg",
"thumbnails/book_thumbnail_016.jpg",
"thumbnails/book_thumbnail_017.jpg",
"thumbnails/book_thumbnail_018.jpg",
"thumbnails/book_thumbnail_019.jpg",
"thumbnails/book_thumbnail_020.jpg",
"thumbnails/book_thumbnail_021.jpg",
"thumbnails/book_thumbnail_022.jpg",
"thumbnails/book_thumbnail_023.jpg",
"thumbnails/book_thumbnail_024.jpg",
"thumbnails/book_thumbnail_025.jpg",
"thumbnails/book_thumbnail_026.jpg",
"thumbnails/book_thumbnail_027.jpg",
"thumbnails/book_thumbnail_028.jpg",
"thumbnails/book_thumbnail_029.jpg",
"thumbnails/book_thumbnail_030.jpg",
"thumbnails/book_thumbnail_031.jpg",
"thumbnails/book_thumbnail_032.jpg",
"thumbnails/book_thumbnail_033.jpg",
"thumbnails/book_thumbnail_034.jpg",
"thumbnails/book_thumbnail_035.jpg",
"thumbnails/book_thumbnail_036.jpg",
"thumbnails/book_thumbnail_037.jpg",
"thumbnails/book_thumbnail_038.jpg",
"thumbnails/book_thumbnail_039.jpg",
"thumbnails/book_thumbnail_040.jpg",
"thumbnails/book_thumbnail_041.jpg",
"thumbnails/book_thumbnail_042.jpg",
"thumbnails/book_thumbnail_043.jpg",
"thumbnails/book_thumbnail_044.jpg",
"thumbnails/book_thumbnail_045.jpg",
"thumbnails/book_thumbnail_046.jpg",
"thumbnails/book_thumbnail_047.jpg",
"thumbnails/book_thumbnail_048.jpg",
"thumbnails/book_thumbnail_049.jpg",
"thumbnails/book_thumbnail_050.jpg",
"thumbnails/book_thumbnail_051.jpg",
"thumbnails/book_thumbnail_052.jpg",
"thumbnails/book_thumbnail_053.jpg",
"thumbnails/book_thumbnail_054.jpg",
"thumbnails/book_thumbnail_055.jpg",
"thumbnails/book_thumbnail_056.jpg",
"thumbnails/book_thumbnail_057.jpg",
"thumbnails/book_thumbnail_058.jpg",
"thumbnails/book_thumbnail_059.jpg",
"thumbnails/book_thumbnail_060.jpg",
"thumbnails/book_thumbnail_061.jpg",
"thumbnails/book_thumbnail_062.jpg",
"thumbnails/book_thumbnail_063.jpg",
"thumbnails/book_thumbnail_064.jpg",
"thumbnails/book_thumbnail_065.jpg",
"thumbnails/book_thumbnail_066.jpg",
"thumbnails/book_thumbnail_067.jpg",
"thumbnails/book_thumbnail_068.jpg",
"thumbnails/book_thumbnail_069.jpg",
"thumbnails/book_thumbnail_070.jpg",
"thumbnails/book_thumbnail_071.jpg",
"thumbnails/book_thumbnail_072.jpg",
"thumbnails/book_thumbnail_073.jpg",
"thumbnails/book_thumbnail_074.jpg",
"thumbnails/book_thumbnail_075.jpg",
"thumbnails/book_thumbnail_076.jpg",
"thumbnails/book_thumbnail_077.jpg",
"thumbnails/book_thumbnail_078.jpg",
"thumbnails/book_thumbnail_079.jpg",
"thumbnails/book_thumbnail_080.jpg",
"thumbnails/book_thumbnail_081.jpg",
"thumbnails/book_thumbnail_082.jpg",
"thumbnails/book_thumbnail_083.jpg",
"thumbnails/book_thumbnail_084.jpg",
"thumbnails/book_thumbnail_085.jpg",
"thumbnails/book_thumbnail_086.jpg",
"thumbnails/book_thumbnail_087.jpg",
"thumbnails/book_thumbnail_088.jpg",
"thumbnails/book_thumbnail_089.jpg",
"thumbnails/book_thumbnail_090.jpg",
"thumbnails/book_thumbnail_091.jpg",
"thumbnails/book_thumbnail_092.jpg",
"thumbnails/book_thumbnail_093.jpg",
"thumbnails/book_thumbnail_094.jpg",
"thumbnails/book_thumbnail_095.jpg",
"thumbnails/book_thumbnail_096.jpg",
"thumbnails/book_thumbnail_097.jpg",
"thumbnails/book_thumbnail_098.jpg",
"thumbnails/book_thumbnail_099.jpg",
"thumbnails/book_thumbnail_100.jpg",
"thumbnails/book_thumbnail_101.jpg",
"thumbnails/book_thumbnail_102.jpg",
"thumbnails/book_thumbnail_103.jpg",
"thumbnails/book_thumbnail_104.jpg",
"thumbnails/book_thumbnail_105.jpg",
"thumbnails/book_thumbnail_106.jpg",
"thumbnails/book_thumbnail_107.jpg",
"thumbnails/book_thumbnail_108.jpg",
"thumbnails/book_thumbnail_109.jpg",
"thumbnails/book_thumbnail_110.jpg",
"thumbnails/book_thumbnail_111.jpg",
"thumbnails/book_thumbnail_112.jpg",
"thumbnails/book_thumbnail_113.jpg",
"thumbnails/book_thumbnail_114.jpg",
"thumbnails/book_thumbnail_115.jpg",
"thumbnails/book_thumbnail_116.jpg",
"thumbnails/book_thumbnail_117.jpg",
"thumbnails/book_thumbnail_118.jpg",
"thumbnails/book_thumbnail_119.jpg",
"thumbnails/book_thumbnail_120.jpg",
"thumbnails/book_thumbnail_121.jpg",
"thumbnails/book_thumbnail_122.jpg",
"thumbnails/book_thumbnail_123.jpg",
"thumbnails/book_thumbnail_124.jpg",
"thumbnails/book_thumbnail_125.jpg",
"thumbnails/book_thumbnail_126.jpg",
"thumbnails/book_thumbnail_127.jpg",
"thumbnails/book_thumbnail_128.jpg",
"thumbnails/book_thumbnail_129.jpg",
"thumbnails/book_thumbnail_130.jpg",
"thumbnails/book_thumbnail_131.jpg",
"thumbnails/book_thumbnail_132.jpg",
"thumbnails/book_thumbnail_133.jpg",
"thumbnails/book_thumbnail_134.jpg",
"thumbnails/book_thumbnail_135.jpg",
"thumbnails/book_thumbnail_136.jpg",
"thumbnails/book_thumbnail_137.jpg",
"thumbnails/book_thumbnail_138.jpg",
"thumbnails/book_thumbnail_139.jpg",
"thumbnails/book_thumbnail_140.jpg",
"thumbnails/book_thumbnail_141.jpg",
"thumbnails/book_thumbnail_142.jpg",
"thumbnails/book_thumbnail_143.jpg",
"thumbnails/book_thumbnail_144.jpg",
"thumbnails/book_thumbnail_145.jpg",
"thumbnails/book_thumbnail_146.jpg",
"thumbnails/book_thumbnail_147.jpg",
"thumbnails/book_thumbnail_148.jpg",
"thumbnails/book_thumbnail_149.jpg",
"thumbnails/book_thumbnail_150.jpg",
"thumbnails/book_thumbnail_151.jpg",
"thumbnails/book_thumbnail_152.jpg",
"thumbnails/book_thumbnail_153.jpg",
"thumbnails/book_thumbnail_154.jpg",
"thumbnails/book_thumbnail_155.jpg",
"thumbnails/book_thumbnail_156.jpg",
"thumbnails/book_thumbnail_157.jpg",
"thumbnails/book_thumbnail_158.jpg",
"thumbnails/book_thumbnail_159.jpg",
"thumbnails/book_thumbnail_160.jpg",
"thumbnails/book_thumbnail_161.jpg",
"thumbnails/book_thumbnail_162.jpg",
"thumbnails/book_thumbnail_163.jpg",
"thumbnails/book_thumbnail_164.jpg",
"thumbnails/book_thumbnail_165.jpg",
"thumbnails/book_thumbnail_166.jpg",
"thumbnails/book_thumbnail_167.jpg",
"thumbnails/book_thumbnail_168.jpg",
"thumbnails/book_thumbnail_169.jpg",
"thumbnails/book_thumbnail_170.jpg",
"thumbnails/book_thumbnail_171.jpg",
"thumbnails/book_thumbnail_172.jpg",
"thumbnails/book_thumbnail_173.jpg",
"thumbnails/book_thumbnail_174.jpg",
"thumbnails/book_thumbnail_175.jpg",
"thumbnails/book_thumbnail_176.jpg",
"thumbnails/book_thumbnail_177.jpg",
"thumbnails/book_thumbnail_178.jpg",
"thumbnails/book_thumbnail_179.jpg",
"thumbnails/book_thumbnail_180.jpg",
"thumbnails/book_thumbnail_181.jpg",
"thumbnails/book_thumbnail_182.jpg",
"thumbnails/book_thumbnail_183.jpg",
"thumbnails/book_thumbnail_184.jpg",
"thumbnails/book_thumbnail_185.jpg",
"thumbnails/book_thumbnail_186.jpg",
"thumbnails/book_thumbnail_187.jpg",
"thumbnails/book_thumbnail_188.jpg",
"thumbnails/book_thumbnail_189.jpg",
"thumbnails/book_thumbnail_190.jpg",
"thumbnails/book_thumbnail_191.jpg",
"thumbnails/book_thumbnail_192.jpg",
"thumbnails/book_thumbnail_193.jpg",
"thumbnails/book_thumbnail_194.jpg",
"thumbnails/book_thumbnail_195.jpg",
"thumbnails/book_thumbnail_196.jpg",
"thumbnails/book_thumbnail_197.jpg",
"thumbnails/book_thumbnail_198.jpg",
"thumbnails/book_thumbnail_199.jpg",
"thumbnails/book_thumbnail_200.jpg"
]
}
}
@@ -0,0 +1,81 @@
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class AuthorServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>();
[IntegrationFact]
public async Task CreateAuthorAsync_ShouldReturn_ResultWithAuthorId()
{
var request = new CreateAuthor
{
Name = "John",
LastName = "Doe",
Company = "Solo Publishers",
Email = "solo@publishers.co.za",
PublisherType = PublisherTypes.Independent,
ImageUrl = ""
};
var result = await authorService.CreateAuthorAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task UpdateAuthorAsync_ShouldReturn_ResultWithSuccess()
{
var request = new UpdateAuthor
{
Name = "Jane",
LastName = "Doe",
Company = "Solo Publishers",
Email = "solo@publishers.co.za",
PublisherType = PublisherTypes.Independent,
ImageUrl = ""
};
var result = await authorService.UpdateAuthorAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetAuthors_ShouldReturn_ResultWithAuthorList()
{
var range = new DateRange
{
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
To = DateOnly.FromDateTime(DateTime.UtcNow),
MaxRecords = 1000
};
var result = await authorService.GetAuthorsAsync(range, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetAuthorAsync_ShouldReturn_ResultWithAuthor()
{
var result = await authorService.GetAuthorAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task UpdateAuthorStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await authorService.UpdateAuthorStatusAsync(1, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
}
@@ -0,0 +1,52 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class BooksServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly BooksService bookService = fixture.Services.GetRequiredService<BooksService>();
[IntegrationFact]
public async Task CreateBookAsync_ShouldReturn_ResultWithBookId()
{
var result = await bookService.CreateBookAsync(1, 2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetBookAsync_ShouldReturn_ResultWithBook()
{
var result = await bookService.GetBookAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task GetBooksByAuthorAsync_ShouldReturn_ResultWithAuthorBooks()
{
var result = await bookService.GetBooksByAuthorAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetPublishedBooksAsync_ShouldReturn_ResultWithBublishedBooks()
{
var result = await bookService.GetPublishedBooksAsync(0, 1000, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task UpdateBookStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await bookService.UpdateBookStatusAsync(1, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
}
@@ -0,0 +1,70 @@
using LiteCharms.Features.MidrandBooks.Categories;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class CategoryServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly CategoryService categoryService = fixture.Services.GetRequiredService<CategoryService>();
[IntegrationFact]
public async Task UpdateCategoryStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await categoryService.UpdateCategoryStatusAsync(3, false, false, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetCategoryAsync_ShouldReturn_ResultWithCategory()
{
var result = await categoryService.GetCategoryAsync(3, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task GetCategoriesAsync_ShouldReturn_All_ResultWithCategoryList()
{
var result = await categoryService.GetCategoriesAsync(isMain: null,fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetCategoriesAsync_ShouldReturn_MainCategory_ResultWithCategoryList()
{
var result = await categoryService.GetCategoriesAsync(true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetCategoriesAsync_ShouldReturn_SubMainCategory_ResultWithCategoryList()
{
var result = await categoryService.GetCategoriesAsync(false, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task CreateCategoriesAsync_ShouldReturn_ResultWithSuccess()
{
var result = await categoryService.CreateCategoriesAsync(fixture.CancellationToken, "Test", "Test 1", "Test 2");
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CreateCategoryAsync_ShouldReturn_ResultWithCategoryId()
{
var result = await categoryService.CreateCategoryAsync("Test", true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
}
@@ -0,0 +1,201 @@
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class CustomerServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly CustomerService customerService = fixture.Services.GetRequiredService<CustomerService>();
[IntegrationFact]
public async Task CreateCustomerAsync_ShouldReturn_ResultWithCustomerId()
{
var request = new CreateCustomer
{
Company = "Book Lovers",
Email = "hank@booklovers.com",
Phone = "555 1245 8577",
Website = "https://www.booklovers.com"
};
var result = await customerService.CreateCustomerAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task CreateCustomerContactAsync_ShouldReturn_ResultWithCustomerContactId()
{
var request = new CreateCustomerContact
{
Name = "Sipho",
LastName = "Madlanga",
Phone = "0710857365",
Email = "sipho@madlanga.africa",
Type = ContactTypes.Business
};
var result = await customerService.CreateCustomerContactAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task CreateCustomerAddressAsync_ShouldReturn_ResultWithCustomerAddressId()
{
var request = new CreateCustomerAddress
{
Name = "Business",
BuildingType = AddressBuildingTypes.MixedUse,
Type = AddressType.Shipping,
Street = "123 Building 4, XYZ Suburb, Some Region",
City = "Johannesburg",
State = "Gauteng",
Country = "South Africa",
IsPrimary = true,
Enabled = true,
PostalCode = "12345"
};
var result = await customerService.CreateCustomerAddressAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task UpdateCustomerAsync_ShouldReturn_ResultWithSuccess()
{
var request = new UpdateCustomer
{
Company = "Book Lovers",
Email = "hank@booklovers.com",
Phone = "555 1245 8578",
Website = "https://www.booklovers.com"
};
var result = await customerService.UpdateCustomerAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateCustomerContactAsync_ShouldReturn_ResultWithSuccess()
{
var request = new UpdateCustomerContact
{
Name = "Sipho",
LastName = "Madlanga",
Phone = "0710857366",
Email = "sipho@madlanga.africa",
Type = ContactTypes.Business
};
var result = await customerService.UpdateCustomerContactAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateCustomerAddressAsync_ShouldReturn_ResultWithSuccess()
{
var request = new UpdateCustomerAddress
{
Name = "Business",
BuildingType = AddressBuildingTypes.MixedUse,
Type = AddressType.Shipping,
Street = "123 Building 4, XYZ Suburb, Some Region",
City = "Johannesburg",
State = "Gauteng",
Country = "South Africa",
IsPrimary = true,
Enabled = true,
PostalCode = "12346"
};
var result = await customerService.UpdateCustomerAddressAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateCustomerStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await customerService.UpdateCustomerStatusAsync(1, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateCustomerContactStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await customerService.UpdateCustomerContactStatusAsync(1, true, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateCustomerAddressStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await customerService.UpdateCustomerAddressStatusAsync(1, true, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetCustomersAsync_ShouldReturn_ResultWithCustomerList()
{
var result = await customerService.GetCustomersAsync(fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetCustomerContactsAsync_ShouldReturn_ResultWithCustomerContactList()
{
var result = await customerService.GetCustomerContactsAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetCustomerAddressesAsync_ShouldReturn_ResultWithCustomerAddressList()
{
var result = await customerService.GetCustomerAddressesAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetCustomerAsync_ShouldReturn_ResultWithCustomer()
{
var result = await customerService.GetCustomerAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task GetCustomerContactAsync_ShouldReturn_ResultWithCustomerContact()
{
var result = await customerService.GetCustomerContactsAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task GetCustomerAddressAsync_ShouldReturn_ResultWithCustomerAddress()
{
var result = await customerService.GetCustomerAddressAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
}
@@ -17,7 +17,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
@@ -35,17 +35,15 @@
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
<ProjectReference Include="..\LiteCharms.Features.Tests.Common\LiteCharms.Features.Tests.Common.csproj" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="System.Net" />
<Using Include="System.Text.Json" />
<Using Include="System.Diagnostics" />
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,196 @@
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class OrderServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly OrderService orderService = fixture.Services.GetRequiredService<OrderService>();
[IntegrationFact]
public async Task CreateOrderAsync_ShouldReturn_ResultWithOrderId()
{
var request = new CreateOrder(250, "At the intercomm, dial 1 then option 2");
var result = await orderService.CreateOrderAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AddItemToOrderAsync_ShouldReturn_ResultWithOrderItemId()
{
var request = new CreateOrderItem(1, 1, 2);
var result = await orderService.AddItemToOrderAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AddItemsToOrderAsync_ShouldReturn_ResultWithSuccess()
{
var requests = new List<CreateOrderItem>
{
new(1, 1, 1),
new(1, 1, 3)
};
var result = await orderService.AddItemsToOrderAsync(1, [.. requests], fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task RemoveItemFromOrderAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.RemoveItemFromOrderAsync(1, 5, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task ClearOrderItemsAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.ClearOrderItemsAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CancelOrderAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.CancelOrderAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetOrderAsync_ShouldReturn_ResultWithOrder()
{
var result = await orderService.GetOrderAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task GetOrdersByCustomerAsync_ShouldReturn_ResultWithOrderList()
{
var result = await orderService.GetOrdersByCustomerAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetOrdersAsync_ShouldReturn_ResultWithOrderList()
{
var range = new DateRange
{
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
To = DateOnly.FromDateTime(DateTime.UtcNow),
MaxRecords = 1000
};
var result = await orderService.GetOrdersAsync(range, 0, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task UpdateOrderStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.UpdateOrderStatusAsync(1, OrderStatus.Pending, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task AddShippingToOrderAsync_ShouldReturn_ResultWithSuccess()
{
var request = new CreateShipping(1, 2);
var result = await orderService.AddShippingToOrderAsync(1, request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task UpdateShippingStatusAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.UpdateShippingStatusAsync(1, ShippingStatuses.Shipped, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetShippingByOrderIdAsync_ShouldReturn_ResultWithShipping()
{
var result = await orderService.GetShippingByOrderIdAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task RemoveShippingFromOrderAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.RemoveShippingFromOrderAsync(1, 1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateShippingTrackingNumberAsync_ShouldReturn_ResultWithSuccess()
{
var result = await orderService.UpdateShippingTrackingNumberAsync(1, 2, "NA0009969397");
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CreateShippingProviderAsync_ShouldReturn_ResultWithShippingProviderId()
{
var request = new CreateShippingProvider(ShippingProviderTypes.FastWay, "FastWay Couriers", 50, "https://www.fastway.co.za/our-services/track-your-parcel");
var result = await orderService.CreateShippingProviderAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task GetShippingProvidersAsync_ShouldReturn_ResultWithShippingProviderList()
{
var result = await orderService.GetShippingProvidersAsync(true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task GetShippingProviderAsync_ShouldReturn_ResultWithShippingProvider()
{
var result = await orderService.GetShippingProviderAsync(2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task UpdateShippingProviderAsync_ShouldReturn_ResultWithSuccess()
{
var request = new UpdateShippingProvider(2,true, "FastWay Couriers", 50, "https://www.fastway.co.za/our-services/track-your-parcel");
var result = await orderService.UpdateShippingProviderAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
}
@@ -0,0 +1,63 @@
using LiteCharms.Features.MidrandBooks.Pages;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class PageServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PageService pageService = fixture.Services.GetRequiredService<PageService>();
[IntegrationFact]
public async Task CreatePageAsync_ShouldReturn_ResultWithPageId()
{
}
[IntegrationFact]
public async Task GetPagesAsync_ByBookId_ShouldReturn_ResultWithPageList()
{
}
[IntegrationFact]
public async Task GetPageAsync_ShouldReturn_ResultWithPage()
{
}
[IntegrationFact]
public async Task GetPageByNumberAsync_ById_And_BookPageNumber_ShouldReturn_ResultWithPage()
{
}
[IntegrationFact]
public async Task UpdatePageAsync_ShouldReturn_ResultWithSuccess()
{
}
[IntegrationFact]
public async Task DeletePageAsync_ShouldReturn_ResultWithSuccess()
{
}
[IntegrationFact]
public async Task DeleteByPageTypeAsync_ShouldReturn_ResultWithSuccess()
{
}
[IntegrationFact]
public async Task DeleteAllAsync_ShouldReturn_ResultWithSuccess()
{
}
[IntegrationFact]
public async Task UpdatePageStatusAsync_ShouldReturn_ResultWithSuccess()
{
}
}
@@ -0,0 +1,113 @@
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public sealed class PayfastServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PayfastService payfastService = fixture.Services.GetRequiredService<PayfastService>();
[IntegrationFact]
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithGatewayLedgerId()
{
var request = new CreateGatewayLedgerEntry
{
OrderId = 1,
PaymentId = 1,
MerchantPaymentId = "M_REF_TEST_99",
PayfastPaymentId = "PF_SYS_ID_10023",
CustomerEmail = "buyer@litecharms.co.za",
AmountGross = 350.00m,
AmountFee = 12.50m,
AmountNet = 337.50m,
PaymentStatus = "COMPLETE"
};
var result = await payfastService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task ValidateReferrerIpAsync_WithValidPayfastHostIp_ShouldReturnTrue()
{
var addresses = await Dns.GetHostAddressesAsync("sandbox.payfast.co.za", fixture.CancellationToken);
string liveTargetIp = addresses.First().ToString();
var result = await payfastService.ValidateReferrerIpAsync(liveTargetIp, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[IntegrationFact]
public async Task ValidateReferrerIpAsync_WithUntrustedIp_ShouldReturnFalse()
{
string rogueIp = "8.8.8.8";
var result = await payfastService.ValidateReferrerIpAsync(rogueIp, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.False(result.Value);
}
[IntegrationFact]
public void ValidatePaymentAmount_WhenWithinAllowableDelta_ShouldReturnTrue()
{
decimal systemExpectedTotal = 199.99m;
string gatewayClearedGross = "200.00"; // Variance is exactly R0.01
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[IntegrationFact]
public void ValidatePaymentAmount_WhenVarianceBreachesDeltaBounds_ShouldReturnFalse()
{
decimal systemExpectedTotal = 199.99m;
string gatewayClearedGross = "150.00";
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
Assert.True(result.IsSuccess);
Assert.False(result.Value);
}
[IntegrationFact]
public async Task ValidateServerConfirmationAsync_WithUnrecognizedPayload_ShouldReturnFalseFromCentralGateway()
{
// Arrange - Execute against actual Payfast servers using raw mock parameters.
// The server handshake will return 200 OK with string payload 'INVALID'
string arbitraryParameters = "merchant_id=10000000&payment_status=COMPLETE";
var result = await payfastService.ValidateServerConfirmationAsync(arbitraryParameters, isSandbox: true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.False(result.Value); // Handshake data rejected as fraudulent/unrecognized
}
[IntegrationFact]
public void GenerateSignature_WithStandardTelemetryData_ShouldSucceedAndHashString()
{
var telemetryPayload = new Dictionary<string, string?>
{
{ "merchant_id", "10049307" },
{ "merchant_key", "ju6navn0jcbf0" },
{ "amount_gross", "250.00" },
{ "item_name", "Midrand School Textbook Variant A" }
};
string passphrase = "oauth_test_signature_pass";
var result = PayfastService.GenerateSignature(telemetryPayload, passphrase);
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrWhiteSpace(result.Value));
Assert.Equal(32, result.Value.Length); // MD5 outputs hex representations totaling 32 characters
}
}
@@ -0,0 +1,98 @@
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public sealed class PaymentServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PaymentService paymentService = fixture.Services.GetRequiredService<PaymentService>();
[IntegrationFact]
public async Task CreateRefundAsync_ShouldReturn_ResultWithRefundId()
{
var request = new CreateRefund
{
Amount = 50,
OrderId = 2,
Type = RefundTypes.Partial,
Reason = "Returned damaged book",
Status = RefundStatus.Completed,
};
var result = await paymentService.CreateRefundAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new CreateLedgerEntry
{
CustomerId = 1,
OrderId = 1,
PaymentGatewayId = 1,
PaymentGatewayReference = "TEST REFERENCE",
PaymentId = 1,
Status = LedgerStatuses.Received,
};
var result = await paymentService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetPaymentGatewayAsync_ShouldReturn_ResultWithPaymentGateway()
{
var result = await paymentService.GetPaymentGatewayAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task CreatePaymentGatewayAsync_ShouldReturn_ResultWithGatewayId()
{
var request = new CreatePaymentGateway
{
IsSandbox = true,
MerchantId = "10049307",
MerchantKey = "ju6navn0jcbf0",
Name = "Payfast",
Website = "https://sandbox.payfast.co.za/eng/process",
};
var result = await paymentService.CreatePaymentGatewayAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task CompletePaymentAsync_ShouldReturn_ResultWithSuccess()
{
var result = await paymentService.CompletePaymentAsync(1, PaymentStatuses.Paid, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdatePaymentAsync_ShouldReturn_ResultWithSuccess()
{
var result = await paymentService.UpdatePaymentAsync(1, 200, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CreatePaymentAsync_ShouldReturn_ResultWithPaymentId()
{
var result = await paymentService.CreatePaymentAsync(100, 1, "HASHEDID", fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
}
@@ -0,0 +1,217 @@
using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Products.Models;
using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public class ProductServiceFeatureTests(Fixture fixture, ITestOutputHelper output) : IClassFixture<Fixture>
{
private readonly ProductService productService = fixture.Services.GetRequiredService<ProductService>();
[IntegrationFact]
public async Task CheckProductStockAvailabilityAsync_ShouldReturn_ResultWithProductInventory()
{
var result = await productService.CheckProductStockAvailabilityAsync(1, 1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[IntegrationFact]
public async Task ReserveProductInventoryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new ReserveStock
{
ProductId = 1,
ProductPriceId = 1,
Reservation = 100,
};
var result = await productService.ReserveProductInventoryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AllocateProductInventoryAsync_ShouldReturn_ResultWithSuccess()
{
var request = new AllocateStock
{
ProductId = 1,
ProductPriceId = 1,
Allocation = 500,
};
var result = await productService.AllocateProductInventoryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task AddProductCategoryAsync_ShouldReturn_ResultWithId()
{
var result = await productService.AddProductCategoryAsync(1, 2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetProductCategoriesAsync_ShouldReturn_ResultWithCategoryList()
{
var result = await productService.GetProductCategoriesAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
}
[IntegrationFact]
public async Task DeleteProductCategoryAsync_ShouldReturn_ResultWithSuccess()
{
var result = await productService.DeleteProductCategoryAsync(1, 1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task DeleteAllProductCategoriesAsync_ShouldReturn_ResultWithSuccess()
{
var result = await productService.DeleteAllProductCategoriesAsync(1, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task GetProductPriceAsync_ShouldReturn_ResultOneProductPrice()
{
var result = await productService.GetProductPriceAsync(2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
output.WriteLine(JsonSerializer.Serialize(result.Value));
}
[IntegrationFact]
public async Task GetProductPricesAsync_ShouldReturn_ResultProductPriceList()
{
var result = await productService.GetProductPricesAsync(2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
output.WriteLine(JsonSerializer.Serialize(result.Value));
}
[IntegrationFact]
public async Task SearchProductsAsync_ShouldReturn_ResultMatchingProducts()
{
var filter = new ProductFilter
{
Name = "system",
Manufacturer = "techwave",
SerialNumber = "2024",
MinPrice = 10,
MaxPrice = 30
};
var result = await productService.SearchProductsAsync(filter, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
output.WriteLine(JsonSerializer.Serialize(result.Value));
}
[IntegrationFact]
public async Task GetProductAsync_ShouldReturn_ResultOneProduct()
{
var result = await productService.GetProductAsync(2, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
output.WriteLine(JsonSerializer.Serialize(result.Value));
}
[IntegrationFact]
public async Task GetProductsAsync_ShouldReturn_ResultProducts()
{
var range = new DateRange
{
From = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-7)),
To = DateOnly.FromDateTime(DateTime.UtcNow),
MaxRecords = 1000
};
var result = await productService.GetProductsAsync(0, range, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotEmpty(result.Value);
output.WriteLine(JsonSerializer.Serialize(result.Value));
}
[IntegrationFact]
public async Task UpdateProductStatusAsync_ShouldResurn_ResultTrue()
{
var result = await productService.UpdateProductStatusAsync(2, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task UpdateProductPriceStatusAsync_ShouldReturn_ResultTrue()
{
var result = await productService.UpdateProductPriceStatusAsync(2, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
}
[IntegrationFact]
public async Task CreateProductPriceAsync_Should_Return_NewProductPriceId()
{
var request = new CreateProductPrice
{
Amount = 29.99m,
Discount = 0.00m
};
var result = await productService.CreateProductPriceAsync(2, request, fixture.CancellationToken);
Assert.True(result.IsSuccess, "Product price creation should be successful.");
Assert.True(result.Value > 0, "New ProductPriceId should be greater than 0.");
output.WriteLine($"Created ProductPriceId: {result.Value}");
}
[IntegrationFact]
public async Task CreateProductAsync_Result_Returns_ProductId()
{
var request = new CreateProduct
{
Name = "Systems Rewired",
Description = "[Design], <Code>, AND /CHAORS/ IN ***SYNC***",
Summary = "A comprehensive guide to systems thinking and design.",
ImageUrl = "https://bookshop.cdn.khongisa.co.za/design/2bf1f9a2-7b25-4fcf-9aa7-08941ea21e6c_1764838499686.webp",
Type = ProductTypes.Book,
Categories = ["Systems Thinking", "Design", "Programming"],
Metadata = new ProductMetadata
{
CopyrightInfo = "© 2024 John Doe. All rights reserved.",
ManufactureDate = "2024-06-01",
Manufacturer = "TechWave Publishing",
SerialNumber = "SR-2024-0001"
}
};
var result = await productService.CreateProductAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess, "Product creation should be successful.");
Assert.True(result.Value > 0, "ProductId should be greater than 0.");
output.WriteLine($"Created ProductId: {result.Value}");
}
}
@@ -1,3 +1,3 @@
namespace LiteCharms.Features.MidrandBooks.Abstractions;
public interface IService;
public interface IMidrandBooks;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -7,23 +7,21 @@ namespace LiteCharms.Features.MidrandBooks.AuthorBooks;
public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result> UpdateBookStatusAsync(long bookId, bool isEnabled, CancellationToken cancellationToken)
public async ValueTask<Result> UpdateBookStatusAsync(long bookId, bool isEnabled, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var book = await context.Books.FirstOrDefaultAsync(b => b.Id == bookId, cancellationToken);
var rowsUpdated = await context.Books
.Where(b => b.Id == bookId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(b => b.Enabled, isEnabled)
.SetProperty(b => b.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (book is null)
return Result.Fail(new Error($"Book with ID {bookId} not found"));
book.UpdatedAt = DateTime.UtcNow;
book.Enabled = isEnabled;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to change status of book with ID {bookId}"));
: Result.Fail(new Error($"Book with ID {bookId} not found"));
}
catch (Exception ex)
{
@@ -45,8 +43,9 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
var book = context.Books.Add(new Entities.AuthorBook
{
CreatedAt = DateTime.UtcNow,
AuthorId = authorId,
ProductId = productId,
ProductId = productId
});
return await context.SaveChangesAsync(cancellationToken) > 0
@@ -59,6 +58,30 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
}
}
public async ValueTask<Result<AuthorBook>> GetBookByProductIdAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var book = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.Include(b => b.Pages)
.FirstOrDefaultAsync(b => b.ProductId == productId, cancellationToken);
return book is null
? Result.Fail<AuthorBook>(new Error($"Book with product ID {productId} not found"))
: Result.Ok(book.ToModel());
}
catch (Exception ex)
{
return Result.Fail<AuthorBook>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<AuthorBook>> GetBookAsync(long bookId, CancellationToken cancellationToken = default)
{
try
@@ -68,7 +91,8 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
var book = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product!.Price)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.Include(b => b.Pages)
.FirstOrDefaultAsync(b => b.Id == bookId, cancellationToken);
@@ -86,7 +110,7 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Authors.AnyAsync(a => a.Id == authorId, cancellationToken))
return Result.Fail<AuthorBook[]>(new Error($"Author with ID {authorId} not found"));
@@ -94,7 +118,8 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
var books = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product!.Price)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.OrderByDescending(b => b.CreatedAt)
.Where(b => b.AuthorId == authorId)
.ToListAsync(cancellationToken);
@@ -118,9 +143,10 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
var books = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product!.Price)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.Include(b => b.Pages)
.Where(b => b.Enabled && b.Product!.Enabled && b.Author.Enabled)
.Where(b => b.Enabled && b.Product!.Enabled && b.Author!.Enabled)
.OrderByDescending(b => b.Ranking)
.ThenByDescending(b => b.Ranking)
.ThenByDescending(b => b.CreatedAt)
@@ -6,7 +6,7 @@ namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
public class AuthorBook : Models.AuthorBook
{
public virtual Author Author { get; set; } = new();
public virtual Author? Author { get; set; }
public new virtual Product? Product { get; set; }
@@ -1,5 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -9,53 +8,44 @@ namespace LiteCharms.Features.MidrandBooks.Authors;
public sealed class AuthorService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result<AuthorBook[]>> GetAuthorBooksAsync(long authorId, CancellationToken cancellationToken)
public async ValueTask<Result<Author>> GetAuthorByProductIdAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken);
var author = await context.Books
.AsNoTracking()
.Include(i => i.Author)
.Where(b => b.ProductId == productId)
.FirstOrDefaultAsync(cancellationToken);
if (author is null)
return Result.Fail<AuthorBook[]>(new Error($"Author with ID {authorId} not found"));
return Result.Fail<Author>(new Error($"No author association discovered for Product ID {productId}"));
var books = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product!.Price)
.OrderByDescending(b => b.CreatedAt)
.Where(p => p.AuthorId == authorId)
.AsSplitQuery()
.ToArrayAsync(cancellationToken);
return books?.Length > 0
? Result.Ok(books.Select(b => b.ToModel()).ToArray())
: Result.Fail<AuthorBook[]>(new Error($"No books found for author with ID {authorId}"));
return Result.Ok(author.Author!.ToModel());
}
catch (Exception ex)
{
return Result.Fail<AuthorBook[]>(new Error(ex.Message).CausedBy(ex));
return Result.Fail<Author>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdateAuthorStatusAsync(long authorId, bool isEnabled, CancellationToken cancellationToken)
public async ValueTask<Result> UpdateAuthorStatusAsync(long authorId, bool isEnabled, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken);
var rowsUpdated = await context.Authors
.Where(a => a.Id == authorId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(a => a.Enabled, isEnabled)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (author is null)
return Result.Fail(new Error($"Author with ID {authorId} not found"));
author.UpdatedAt = DateTime.UtcNow;
author.Enabled = isEnabled;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to change status of author with ID {authorId}"));
: Result.Fail(new Error($"Author with ID {authorId} not found"));
}
catch (Exception ex)
{
@@ -81,12 +71,12 @@ public sealed class AuthorService(IDbContextFactory<MidrandBooksDbContext> conte
}
}
public async ValueTask<Result<Author[]>> GetAuthors(DateRange range, CancellationToken cancellationToken)
public async ValueTask<Result<Author[]>> GetAuthorsAsync(DateRange range, CancellationToken cancellationToken = default)
{
try
{
var fromDate = range.From.ToDateTime(TimeOnly.MinValue);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue);
var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -107,18 +97,12 @@ public sealed class AuthorService(IDbContextFactory<MidrandBooksDbContext> conte
}
}
public async ValueTask<Result> UpdateAuthorAsync(long authorId, UpdateAuthor request, CancellationToken cancellationToken)
public async ValueTask<Result> UpdateAuthorAsync(long authorId, UpdateAuthor request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken))
return Result.Fail(new Error($"An author with the name {request.Name} {request.LastName} already exists"));
if (await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken))
return Result.Fail(new Error($"An author with the email {request.Email} already exists"));
var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken);
if (author is null)
@@ -151,12 +135,12 @@ public sealed class AuthorService(IDbContextFactory<MidrandBooksDbContext> conte
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken))
if (await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken))
return Result.Fail<long>(new Error($"An author with the name {request.Name} {request.LastName} already exists"));
if(await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken))
if (await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken))
return Result.Fail<long>(new Error($"An author with the email {request.Email} already exists"));
var newAuthor = context.Authors.Add(new Entities.Author
@@ -18,7 +18,8 @@ public sealed class AuthorConfiguration : IEntityTypeConfiguration<Author>
builder.Property(f => f.Website).IsRequired(false).HasMaxLength(1024);
builder.Property(f => f.ImageUrl).IsRequired().HasMaxLength(2048);
builder.Property(f => f.ThumbnailImageUrl).IsRequired(false).HasMaxLength(2048);
builder.Property(f => f.SocialMedia).IsRequired(false).HasColumnType("jsonb");
builder.Property(f => f.Enabled).HasDefaultValue(true);
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
}
}
@@ -30,7 +30,7 @@ public class Author
public string? ThumbnailImageUrl { get; set; }
public SocialMedia[]? SocialMedia { get; set; }
public ICollection<SocialMedia>? SocialMedia { get; set; }
public bool Enabled { get; set; }
}
@@ -0,0 +1,132 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Categories.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
namespace LiteCharms.Features.MidrandBooks.Categories;
public sealed class CategoryService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result> UpdateCategoryStatusAsync(long categoryId, bool enabled, bool isMain, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rowsUpdated = await context.Categories
.Where(c => c.Id == categoryId && c.Enabled)
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.Enabled, enabled)
.SetProperty(c => c.IsMain, isMain), cancellationToken);
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to update category"));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Category>> GetCategoryAsync(long categoryId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var category = await context.Categories.AsNoTracking().FirstOrDefaultAsync(c => c.Id == categoryId, cancellationToken);
return category is not null
? Result.Ok(category.ToModel())
: Result.Fail<Category>("Failed to create new category");
}
catch (Exception ex)
{
return Result.Fail<Category>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Category[]>> GetCategoriesAsync(bool? isMain = null, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var query = context.Categories.AsNoTracking()
.OrderByDescending(o => o.IsMain)
.ThenByDescending(o => o.Id)
.ThenBy(o => o.Name)
.AsQueryable();
query = isMain is null
? query.Where(c => c.Enabled).AsQueryable()
: query.Where(c => c.Enabled && c.IsMain == isMain.Value);
var categories = await query.ToListAsync(cancellationToken);
return categories?.Count > 0
? Result.Ok(categories.Select(c => c.ToModel()).ToArray())
: Result.Fail<Category[]>("No categories found");
}
catch (Exception ex)
{
return Result.Fail<Category[]>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateCategoriesAsync(CancellationToken cancellationToken = default, params string[] categories)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
foreach (var category in categories)
{
if (await context.Categories.AnyAsync(c => EF.Functions.ILike(c.Name!, category!), cancellationToken))
continue;
context.Categories.Add(new Entities.Category
{
Name = category.Humanize(LetterCasing.Title),
IsMain = false,
Enabled = true,
});
}
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to add any category in the list");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreateCategoryAsync(string category, bool isMain, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Categories.AnyAsync(c => EF.Functions.ILike(c.Name!, category!), cancellationToken))
return Result.Fail($"Category '{category}' already exists");
var newCategory = context.Categories.Add(new Entities.Category
{
Name = StringHumanizeExtensions.Humanize(category, LetterCasing.Title),
IsMain = isMain,
Enabled = true,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(newCategory.Entity.Id)
: Result.Fail("Failed to create new category");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,4 @@
namespace LiteCharms.Features.MidrandBooks.Categories.Entities;
[EntityTypeConfiguration<CategoryConfiguration, Category>]
public sealed class Category : Models.Category;
@@ -0,0 +1,14 @@
namespace LiteCharms.Features.MidrandBooks.Categories.Entities;
public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ToTable("Categories");
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).IsRequired().HasMaxLength(15);
builder.Property(c => c.IsMain).HasDefaultValue(false);
builder.Property(c => c.Enabled).HasDefaultValue(true);
}
}
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.MidrandBooks.Categories.Models;
public class Category
{
public long Id { get; set; }
public string? Name { get; set; }
public bool IsMain { get; set; }
public bool Enabled { get; set; }
}
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -13,7 +13,7 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Customers.AnyAsync(c => c.Email!.Equals(request.Email, StringComparison.OrdinalIgnoreCase), cancellationToken))
if (await context.Customers.AnyAsync(c => EF.Functions.ILike(c.Email!, $"%{request.Email}%"), cancellationToken))
return Result.Fail<long>(new Error($"Customer with email '{request.Email}' already exists."));
var customer = context.Customers.Add(new Entities.Customer
@@ -46,7 +46,7 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
return Result.Fail<long>(new Error($"Customer with ID '{customerId}' does not exist."));
if (await context.Contacts.AnyAsync(cc => cc.CustomerId == customerId && cc.Email!.Equals(request.Email, StringComparison.OrdinalIgnoreCase), cancellationToken))
if (await context.Contacts.AnyAsync(cc => cc.CustomerId == customerId && EF.Functions.ILike(cc.Email!, $"%{request.Email}%"), cancellationToken))
return Result.Fail<long>(new Error($"Contact with email '{request.Email}' already exists for this customer."));
var contact = context.Contacts.Add(new Entities.Contact
@@ -139,21 +139,19 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var contact = await context.Contacts.FirstOrDefaultAsync(cc => cc.Id == contactId, cancellationToken);
var rowsUpdated = await context.Contacts
.Where(cc => cc.Id == contactId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(cc => cc.Name, request.Name)
.SetProperty(cc => cc.LastName, request.LastName)
.SetProperty(cc => cc.Email, request.Email)
.SetProperty(cc => cc.Phone, request.Phone)
.SetProperty(cc => cc.Type, request.Type)
.SetProperty(cc => cc.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (contact is null)
return Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
contact.UpdatedAt = DateTime.UtcNow;
contact.Name = request.Name;
contact.LastName = request.LastName;
contact.Email = request.Email;
contact.Phone = request.Phone;
contact.Type = request.Type;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error("Failed to update customer contact."));
: Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
}
catch (Exception ex)
{
@@ -167,25 +165,23 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var address = await context.Addresses.FirstOrDefaultAsync(a => a.Id == addressId, cancellationToken);
var rowsUpdated = await context.Addresses
.Where(a => a.Id == addressId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(a => a.Street, request.Street)
.SetProperty(a => a.City, request.City)
.SetProperty(a => a.State, request.State)
.SetProperty(a => a.PostalCode, request.PostalCode)
.SetProperty(a => a.Country, request.Country)
.SetProperty(a => a.Type, request.Type)
.SetProperty(a => a.BuildingType, request.BuildingType)
.SetProperty(a => a.IsPrimary, request.IsPrimary)
.SetProperty(a => a.Name, request.Name)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (address is null)
return Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
address.UpdatedAt = DateTime.UtcNow;
address.Street = request.Street;
address.City = request.City;
address.State = request.State;
address.PostalCode = request.PostalCode;
address.Country = request.Country;
address.Type = request.Type;
address.BuildingType = request.BuildingType;
address.IsPrimary = request.IsPrimary;
address.Name = request.Name;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error("Failed to update customer address."));
: Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
}
catch (Exception ex)
{
@@ -199,17 +195,15 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers.FirstOrDefaultAsync(c => c.Id == customerId, cancellationToken);
var rowsUpdated = await context.Customers
.Where(c => c.Id == customerId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.Enabled, enabled)
.SetProperty(c => c.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (customer is null)
return Result.Fail(new Error($"Customer with ID '{customerId}' does not exist."));
customer.Enabled = enabled;
customer.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error("Failed to update customer status."));
: Result.Fail(new Error($"Customer with ID '{customerId}' does not exist."));
}
catch (Exception ex)
{
@@ -223,18 +217,16 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var contact = await context.Contacts.FirstOrDefaultAsync(cc => cc.Id == contactId, cancellationToken);
var rowsUpdated = await context.Contacts
.Where(cc => cc.Id == contactId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(cc => cc.Enabled, enabled)
.SetProperty(cc => cc.IsPrimary, isPrimary)
.SetProperty(cc => cc.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (contact is null)
return Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
contact.Enabled = enabled;
contact.IsPrimary = isPrimary;
contact.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error("Failed to update customer contact status."));
: Result.Fail(new Error($"Contact with ID '{contactId}' does not exist."));
}
catch (Exception ex)
{
@@ -248,18 +240,16 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var address = await context.Addresses.FirstOrDefaultAsync(a => a.Id == addressId, cancellationToken);
var rowsUpdated = await context.Addresses
.Where(a => a.Id == addressId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(a => a.Enabled, enabled)
.SetProperty(a => a.IsPrimary, isPrimary)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (address is null)
return Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
address.Enabled = enabled;
address.IsPrimary = isPrimary;
address.UpdatedAt = DateTime.UtcNow;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error("Failed to update customer address status."));
: Result.Fail(new Error($"Address with ID '{addressId}' does not exist."));
}
catch (Exception ex)
{
@@ -344,6 +334,28 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
}
}
public async ValueTask<Result<Customer>> GetCustomerAsync(string email, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers
.AsNoTracking()
.Include(c => c.Contacts)
.Include(c => c.Addresses)
.FirstOrDefaultAsync(c => c.Email == email, cancellationToken);
return customer is not null
? Result.Ok(customer.ToModel())
: Result.Fail<Customer>(new Error($"Customer with email '{email}' does not exist."));
}
catch (Exception ex)
{
return Result.Fail<Customer>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default)
{
try
@@ -12,9 +12,10 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
builder.Property(c => c.Company).IsRequired(false);
builder.Property(c => c.VatNumber).IsRequired(false);
builder.Property(c => c.Email).IsRequired();
builder.Property(c => c.Phone).IsRequired();
builder.Property(c => c.Website).IsRequired();
builder.Property(c => c.SocialMedia).IsRequired(false).HasColumnType("jsonb");
builder.Property(c => c.Phone).IsRequired(false);
builder.Property(c => c.Website).IsRequired(false);
builder.Property(c => c.Enabled).HasDefaultValue(true);
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
}
}
@@ -20,7 +20,7 @@ public class Customer
public string? Phone { get; set; }
public SocialMedia[]? SocialMedia { get; set; }
public ICollection<SocialMedia>? SocialMedia { get; set; }
public bool Enabled { get; set; }
}
@@ -14,7 +14,7 @@ public record CreateCustomer
public string? Phone { get; set; }
public SocialMedia[]? SocialMedia { get; set; }
public ICollection<SocialMedia>? SocialMedia { get; set; }
}
public sealed record UpdateCustomer : CreateCustomer;
-62
View File
@@ -1,62 +0,0 @@
namespace LiteCharms.Features.MidrandBooks;
public enum PublisherTypes : int
{
Individual = 0,
Company = 1,
Organization = 2,
SelfPublished = 3,
UniversityPress = 4,
GovernmentAgency = 5,
NonProfit = 6,
Independent = 7
}
public enum BookTypes : int
{
Fiction = 0,
NonFiction = 1,
Academic = 2,
SelfHelp = 3,
Biography = 4,
Poetry = 5,
Children = 6,
YoungAdult = 7,
ScienceFiction = 8,
Fantasy = 9
}
public enum BookContentTypes : int
{
Text = 0,
Image = 1,
Video = 2,
Audio = 3,
Interactive = 4,
Markdown = 5,
Html = 6,
Json = 7,
Yaml = 8
}
public enum BookPageTypes : int
{
Cover = 0,
Preface = 1,
Introduction = 2,
Content = 3,
Closing = 4,
Referencer = 5,
Credits = 6,
BackCover = 7
}
public enum ProductTypes : int
{
Book = 1,
Journal = 2,
Magazine = 3,
EBook = 4,
Audiobook = 5,
Accessory = 6
}
@@ -1,14 +1,97 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks.Models;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Categories.Models;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Pages.Models;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products.Models;
namespace LiteCharms.Features.MidrandBooks.Extensions;
public static class Mappers
{
public static PaymentGatewayLedger ToModel(this Payments.Entities.PaymentGatewayLedger entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
CustomerEmail = entity.CustomerEmail,
OrderId = entity.OrderId,
PaymentId = entity.PaymentId,
MerchantPaymentId = entity.MerchantPaymentId,
PayfastPaymentId = entity.PayfastPaymentId,
PaymentStatus = entity.PaymentStatus,
AmountGross = entity.AmountGross,
AmountFee = entity.AmountFee,
AmountNet = entity.AmountNet
};
public static Refund ToModel(this Payments.Entities.Refund entity) => new()
{
CreatedAt = entity.CreatedAt,
Amount = entity.Amount,
Id = entity.Id,
OrderId = entity.OrderId,
Reason = entity.Reason,
Status = entity.Status,
Type = entity.Type,
UpdatedAt = entity.UpdatedAt,
};
public static PaymentLedger ToModel(this Payments.Entities.PaymentLedger entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
CustomerId = entity.CustomerId,
OrderId = entity.OrderId,
PaymentId = entity.PaymentId,
Status = entity.Status,
MerchantPaymentId = entity.MerchantPaymentId,
};
public static PaymentGateway ToModel(this Payments.Entities.PaymentGateway entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Enabled = entity.Enabled,
IsSandbox = entity.IsSandbox,
MerchantId = entity.MerchantId,
MerchantKey = entity.MerchantKey,
Name = entity.Name,
Website = entity.Website,
};
public static Payment ToModel(this Payments.Entities.Payment entity) => new()
{
Id = entity.Id,
Amount = entity.Amount,
CreatedAt = entity.CreatedAt,
OrderId = entity.OrderId,
Reference = entity.Reference,
Status = entity.Status,
UpdatedAt = entity.UpdatedAt,
};
public static ProductInventory ToModel(this Products.Entities.ProductInventory entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
ProductId = entity.ProductId,
ProductPriceId = entity.ProductPriceId,
Status = entity.Status,
TotalAllocated = entity.TotalAllocated,
TotalReserved = entity.TotalReserved,
};
public static Category ToModel(this Categories.Entities.Category entity) => new()
{
Id = entity.Id,
Name = entity.Name,
IsMain = entity.IsMain,
Enabled = entity.Enabled,
};
public static ShippingProvider ToModel(this Orders.Entities.ShippingProvider entity) => new()
{
Id = entity.Id,
@@ -17,7 +100,8 @@ public static class Mappers
Name = entity.Name,
Type = entity.Type,
Price = entity.Price,
Enabled = entity.Enabled
Enabled = entity.Enabled,
TrackingUrl = entity.TrackingUrl,
};
public static Shipping ToModel(this Orders.Entities.Shipping entity) => new()
@@ -64,7 +148,7 @@ public static class Mappers
SocialMedia = entiry.SocialMedia,
UpdatedAt = entiry.UpdatedAt,
VatNumber = entiry.VatNumber,
Website = entiry.Website
Website = entiry.Website
};
public static Address ToModel(this Customers.Entities.Address entity) => new()
@@ -82,7 +166,7 @@ public static class Mappers
Street = entity.Street,
City = entity.City,
State = entity.State,
Country = entity.Country
Country = entity.Country
};
public static Contact ToModel(this Customers.Entities.Contact entity) => new()
@@ -111,7 +195,7 @@ public static class Mappers
Enabled = entity.Enabled,
Notes = entity.Notes,
References = entity.References,
Type = entity.Type
Type = entity.Type
};
public static AuthorBook ToModel(this AuthorBooks.Entities.AuthorBook entity) => new()
@@ -137,25 +221,21 @@ public static class Mappers
Enabled = entity.Enabled
};
public static Product ToModel(this Products.Entities.Product entity)
public static Product ToModel(this Products.Entities.Product entity) => new Product
{
return new Product
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Name = entity.Name,
Summary = entity.Summary,
Description = entity.Description,
Type = entity.Type,
ImageUrl = entity.ImageUrl,
ThumbnailUrls = entity.ThumbnailUrls,
Metadata = entity.Metadata,
Categories = entity.Categories,
Enabled = entity.Enabled,
Price = entity.Prices?.FirstOrDefault(p => p.Enabled)?.ToModel() ?? null,
};
}
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Name = entity.Name,
Summary = entity.Summary,
Description = entity.Description,
Type = entity.Type,
ImageUrl = entity.ImageUrl,
ThumbnailUrls = entity.ThumbnailUrls,
Metadata = entity.Metadata,
Enabled = entity.Enabled,
Price = entity.Prices?.FirstOrDefault(p => p.Enabled)?.ToModel() ?? null,
};
public static Author ToModel(this Authors.Entities.Author entity) => new()
{
@@ -8,8 +8,18 @@ public static class Postgres
public static IServiceCollection AddMidrandShopDatabase(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(MidrandBooksDbConfigName);
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConfigureTypeLoading(options => { options.EnableTypeLoading(false); });
var dataSource = dataSourceBuilder.Build();
services.AddSingleton(dataSource);
services.AddPooledDbContextFactory<MidrandBooksDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString(MidrandBooksDbConfigName)));
options.UseNpgsql(dataSource));
return services;
}
@@ -1,22 +1,27 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Browser;
using LiteCharms.Features.MidrandBooks.Abstractions;
namespace LiteCharms.Features.MidrandBooks.Extensions;
public static class Shop
{
public static IServiceCollection AddShopServices(this IServiceCollection services, Assembly assembly, ServiceLifetime serviceLifetime)
public static IServiceCollection AddShopServices(this IServiceCollection services, bool includeLocalStorage = false)
{
var serviceType = typeof(IService);
var implementations = assembly.GetTypes()
var sharedImplementations = typeof(IFeatures).Assembly.GetTypes()
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
foreach (var implementation in implementations)
{
var descriptor = new ServiceDescriptor(serviceType, implementation, serviceLifetime);
foreach (var sharedImplementation in sharedImplementations) services.AddScoped(sharedImplementation);
services.Add(descriptor);
}
var coreImplementations = typeof(IMidrandBooks).Assembly.GetTypes()
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
foreach (var coreImplementation in coreImplementations) services.AddScoped(coreImplementation);
if (includeLocalStorage)
services.AddScoped<LocalStorageService>();
return services;
}
@@ -31,11 +31,12 @@
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="3.0.96">
<PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.102">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
<PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -49,11 +50,11 @@
<!-- Configuration -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" />
@@ -66,9 +67,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -77,12 +78,12 @@
<!-- Open Telemetry -->
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" />
@@ -94,17 +95,17 @@
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- Global Usings -->
<Using Include="Npgsql" />
@@ -115,8 +116,8 @@
<!-- Email -->
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="MimeKit" Version="4.16.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.17.0" />
<!-- Global Usings-->
<Using Include="MimeKit" />
@@ -135,8 +136,8 @@
<!-- Amazon S3 SDK -->
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" />
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.6" />
<PackageReference Include="AWSSDK.S3" Version="4.0.24.3" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings -->
@@ -147,6 +148,12 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="System.Net.Sockets" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Web" />
<Using Include="System.Net" />
<Using Include="Humanizer" />
<Using Include="System.Globalization" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.AspNetCore.Builder" />
@@ -11,7 +11,7 @@ public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
builder.Property(o => o.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(o => o.UpdatedAt).HasDefaultValueSql("now()");
builder.Property(o => o.Status).IsRequired();
builder.Property(o => o.Total).IsRequired().HasColumnType("decimal(18,2)");
builder.Property(o => o.Total).IsRequired().HasPrecision(18, 2);
builder.Property(o => o.Notes).HasMaxLength(1000);
}
}
@@ -1,10 +1,10 @@
namespace LiteCharms.Features.MidrandBooks.Orders.Models;
public sealed record CreateOrder(long CustomerId, decimal TotalPrice, string? Notes);
public sealed record CreateOrder(decimal TotalPrice, string? Notes);
public sealed record CreateOrderItem(long AuthorBookId, long ProductPriceId, int Quantity);
public sealed record CreateShipping(long OrderId, long AddressId, long ShippingProviderId, string? TrackingNumber);
public sealed record CreateShipping(long AddressId, long ShippingProviderId, string? TrackingNumber = null);
public sealed record CreateShippingProvider(ShippingProviderTypes Type, string Name, decimal Price, string TrackingUrl);
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -49,6 +49,17 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
if (!await context.Prices.AnyAsync(pp => pp.Id == request.ProductPriceId, cancellationToken))
return Result.Fail<long>("Product price not found.");
var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == request.ProductPriceId && i.OrderId == orderId, cancellationToken);
if(existingItem is not null)
{
existingItem.Quantity += request.Quantity;
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(existingItem.Id)
: Result.Fail<long>("Update existing order item.");
}
var orderItem = context.OrderItems.Add(new Entities.OrderItem
{
OrderId = orderId,
@@ -78,9 +89,6 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
return Result.Fail("Order not found.");
var existingItems = context.OrderItems.Where(oi => oi.OrderId == orderId);
context.OrderItems.RemoveRange(existingItems);
foreach (var item in items)
{
@@ -90,18 +98,23 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
if (!await context.Prices.AnyAsync(pp => pp.Id == item.ProductPriceId, cancellationToken))
return Result.Fail($"Product price with ID {item.ProductPriceId} not found.");
context.OrderItems.Add(new Entities.OrderItem
{
OrderId = orderId,
AuthorBookId = item.AuthorBookId,
ProductPriceId = item.ProductPriceId,
Quantity = item.Quantity
});
var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == item.ProductPriceId && i.OrderId == orderId, cancellationToken);
if (existingItem is not null)
existingItem.Quantity += item.Quantity;
else
context.OrderItems.Add(new Entities.OrderItem
{
OrderId = orderId,
AuthorBookId = item.AuthorBookId,
ProductPriceId = item.ProductPriceId,
Quantity = item.Quantity
});
await context.SaveChangesAsync(cancellationToken);
}
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to add items to order.");
return Result.Ok();
}
catch (Exception ex)
{
@@ -115,16 +128,13 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var orderItem = await context.OrderItems.FirstOrDefaultAsync(oi => oi.Id == orderItemId && oi.OrderId == orderId, cancellationToken);
var rowsDeleted = await context.OrderItems
.Where(oi => oi.Id == orderItemId && oi.OrderId == orderId)
.ExecuteDeleteAsync(cancellationToken);
if (orderItem is null)
return Result.Fail("Order item not found.");
context.OrderItems.Remove(orderItem);
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("Failed to remove item from order.");
: Result.Fail("Order item not found or failed to remove.");
}
catch (Exception ex)
{
@@ -132,15 +142,14 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
}
}
public async ValueTask<Result> ClearOrderItemasAsync(long orderId, CancellationToken cancellationToken = default)
public async ValueTask<Result> ClearOrderItemsAsync(long orderId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var orderItems = context.OrderItems.Where(oi => oi.OrderId == orderId);
context.OrderItems.RemoveRange(orderItems);
var deletedItems = await context.OrderItems.Where(oi => oi.OrderId == orderId)
.ExecuteDeleteAsync(cancellationToken);
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
@@ -155,6 +164,27 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) =>
await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken);
public async ValueTask<Result<Order>> GetPendingOrderAsync(long customerId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Pending && o.CustomerId == customerId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
return order is not null
? Result.Ok(order.ToModel())
: Result.Fail<Order>("Order not found.");
}
catch (Exception ex)
{
return Result.Fail<Order>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
{
try
@@ -199,12 +229,15 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
try
{
var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var orders = await context.Orders
.AsNoTracking()
.Where(o => o.CreatedAt >= range.From.ToDateTime(TimeOnly.MinValue) && o.CreatedAt <= range.To.ToDateTime(TimeOnly.MaxValue))
.Skip(index * range.MaxRecords)
.Where(o => o.CreatedAt >= fromDate && o.CreatedAt <= toDate)
.Skip(index).Take(range.MaxRecords)
.ToListAsync(cancellationToken);
return Result.Ok(orders.Select(o => o.ToModel()).ToArray());
@@ -221,17 +254,15 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
var rowsUpdated = await context.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, newStatus)
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (order is null)
return Result.Fail("Order not found.");
order.UpdatedAt = DateTime.UtcNow;
order.Status = newStatus;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update order status.");
: Result.Fail("Order not found or status update failed.");
}
catch (Exception ex)
{
@@ -239,7 +270,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
}
}
public async ValueTask<Result> AddShippingToOrderAsync(long orderId, CreateShipping request, CancellationToken cancellationToken = default)
public async ValueTask<Result<long>> AddShippingToOrderAsync(long orderId, CreateShipping request, CancellationToken cancellationToken = default)
{
try
{
@@ -282,17 +313,16 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var shipping = await context.Shippings.FirstOrDefaultAsync(s => s.OrderId == orderId, cancellationToken);
var rowsUpdated = await context.Shippings
.Where(s => s.OrderId == orderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.Status, newStatus)
.SetProperty(s => s.UpdatedAt, DateTime.UtcNow),
cancellationToken);
if (shipping is null)
return Result.Fail("Shipping not found for this order.");
shipping.UpdatedAt = DateTime.UtcNow;
shipping.Status = newStatus;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update shipping status.");
: Result.Fail("Shipping not found for this order or status update failed.");
}
catch (Exception ex)
{
@@ -325,21 +355,14 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rowsDeleted = await context.Shippings
.Where(s => s.Id == shippingId && s.OrderId == orderId)
.ExecuteDeleteAsync(cancellationToken);
if(!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
return Result.Fail("Order not found.");
var shipping = await context.Shippings.AsNoTracking()
.FirstOrDefaultAsync(s => s.OrderId == orderId && s.Id == shippingId, cancellationToken);
if (shipping is null)
return Result.Fail("Shipping not found for this order.");
context.Shippings.Remove(shipping);
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("Failed to remove shipping from order.");
: Result.Fail("Shipping record not found for this order.");
}
catch (Exception ex)
{
@@ -353,17 +376,15 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var shipping = await context.Shippings.FirstOrDefaultAsync(s => s.OrderId == orderId && s.Id == shippingId, cancellationToken);
var rowsUpdated = await context.Shippings
.Where(s => s.Id == shippingId && s.OrderId == orderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.TrackingNumber, trackingNumber)
.SetProperty(s => s.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (shipping is null)
return Result.Fail("Shipping not found for this order.");
shipping.UpdatedAt = DateTime.UtcNow;
shipping.TrackingNumber = trackingNumber;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update shipping tracking number.");
: Result.Fail("Shipping record not found for this order.");
}
catch (Exception ex)
{
@@ -371,7 +392,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
}
}
public async ValueTask<Result> CreateShippingProviderAsync(CreateShippingProvider request, CancellationToken cancellationToken = default)
public async ValueTask<Result<long>> CreateShippingProviderAsync(CreateShippingProvider request, CancellationToken cancellationToken = default)
{
try
{
@@ -382,6 +403,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
var shippingProvider = context.ShippingProviders.Add(new Entities.ShippingProvider
{
CreatedAt = DateTime.UtcNow,
Name = request.Name,
Type = request.Type,
Price = request.Price,
@@ -389,7 +411,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
? Result.Ok(shippingProvider.Entity.Id)
: Result.Fail("Failed to create shipping provider.");
}
catch (Exception ex)
@@ -440,24 +462,22 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var provider = await context.ShippingProviders.FirstOrDefaultAsync(sp => sp.Id == request.ProviderId, cancellationToken);
var rowsUpdated = await context.ShippingProviders
.Where(sp => sp.Id == request.ProviderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(sp => sp.Name, request.Name)
.SetProperty(sp => sp.Price, request.Price)
.SetProperty(sp => sp.TrackingUrl, request.TrackingUrl)
.SetProperty(sp => sp.Enabled, request.Enabled)
.SetProperty(sp => sp.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (provider is null)
return Result.Fail("Shipping provider not found.");
provider.UpdatedAt = DateTime.UtcNow;
provider.Enabled = request.Enabled;
provider.Name = request.Name;
provider.Price = request.Price;
provider.TrackingUrl = request.TrackingUrl;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update shipping provider status.");
: Result.Fail("Shipping provider not found.");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
}
@@ -13,10 +13,11 @@ public sealed class BookPageConfiguration : IEntityTypeConfiguration<BookPage>
builder.Property(bp => bp.AuthorBookId).IsRequired();
builder.Property(bp => bp.Content).IsRequired();
builder.Property(bp => bp.Type).IsRequired();
builder.Property(bp => bp.ContentType).IsRequired();
builder.Property(bp => bp.Notes).IsRequired(false).HasColumnType("jsonb");
builder.Property(bp => bp.References).IsRequired(false).HasColumnType("jsonb");
builder.Property(bp => bp.ContentType).IsRequired();
builder.Property(bp => bp.Enabled).HasDefaultValue(true);
builder.Property(bp => bp.Notes).IsRequired(false);
builder.OwnsMany(f => f.References, b => { b.ToJson(); });
builder.HasOne(f =>f.Book)
.WithMany(b => b.Pages)
@@ -22,7 +22,7 @@ public class BookPage
public string[]? Notes { get; set; }
public PageReference[]? References { get; set; }
public ICollection<PageReference>? References { get; set; }
public bool Enabled { get; set; }
}
@@ -14,5 +14,7 @@ public class CreateBookPage
public string[]? Notes { get; set; }
public PageReference[]? References { get; set; }
public ICollection<PageReference>? References { get; set; }
}
public sealed class UpdateBookPage : CreateBookPage;
@@ -1,3 +0,0 @@
namespace LiteCharms.Features.MidrandBooks.Pages.Models;
public sealed class UpdateBookPage : CreateBookPage;
@@ -1,4 +1,4 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Pages.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
@@ -13,19 +13,13 @@ public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> context
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken))
return Result.Fail("Book not found");
var rowsDeleted = await context.Pages
.Where(p => p.AuthorBookId == authorBookId)
.ExecuteDeleteAsync(cancellationToken);
var pages = await context.Pages.Where(p => p.AuthorBookId == authorBookId).ToListAsync(cancellationToken);
if (pages.Count == 0)
return Result.Fail("No pages found for the specified book");
context.Pages.RemoveRange(pages);
await context.SaveChangesAsync(cancellationToken);
return Result.Ok();
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("No pages found for the specified book");
}
catch (Exception ex)
{
@@ -39,16 +33,13 @@ public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> context
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var page = await context.Pages.FirstOrDefaultAsync(p => p.AuthorBookId == authorBookId && p.Number == pageNumber && p.Type == pageType, cancellationToken);
var rowsDeleted = await context.Pages
.Where(p => p.AuthorBookId == authorBookId && p.Number == pageNumber && p.Type == pageType)
.ExecuteDeleteAsync(cancellationToken);
if (page is null)
return Result.Fail("Page not found");
context.Pages.Remove(page);
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("Failed to delete page");
: Result.Fail("Page not found");
}
catch (Exception ex)
{
@@ -62,17 +53,15 @@ public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> context
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken);
var rowsUpdated = await context.Pages
.Where(p => p.Id == bookPageId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Enabled, enabled)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (page is null)
return Result.Fail("Page not found");
page.UpdatedAt = DateTime.UtcNow;
page.Enabled = enabled;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update page status");
: Result.Fail("Page not found");
}
catch (Exception ex)
{
@@ -86,16 +75,13 @@ public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> context
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken);
var rowsDeleted = await context.Pages
.Where(p => p.Id == bookPageId)
.ExecuteDeleteAsync(cancellationToken);
if (page is null)
return Result.Fail("Page not found");
context.Pages.Remove(page);
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("Failed to delete page");
: Result.Fail("Page not found");
}
catch (Exception ex)
{
@@ -109,22 +95,20 @@ public sealed class PageService(IDbContextFactory<MidrandBooksDbContext> context
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken);
var rowsUpdated = await context.Pages
.Where(p => p.Id == bookPageId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Type, request.Type)
.SetProperty(p => p.ContentType, request.ContentType)
.SetProperty(p => p.Number, request.Number)
.SetProperty(p => p.Content, request.Content)
.SetProperty(p => p.Notes, request.Notes)
.SetProperty(p => p.References, request.References)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (page is null)
return Result.Fail("Page not found");
page.UpdatedAt = DateTime.UtcNow;
page.Type = request.Type;
page.ContentType = request.ContentType;
page.Number = request.Number;
page.Content = request.Content;
page.Notes = request.Notes;
page.References = request.References;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail("Failed to update page");
: Result.Fail("Page not found");
}
catch (Exception ex)
{
@@ -0,0 +1,153 @@
using LiteCharms.Features.Browser;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products.Models;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed class CartService(LocalStorageService localStorage)
{
private readonly string CartStorageKey = HashService.ToMd5Hash(nameof(Cart)).Value;
public Cart ShoppingCart { get; private set; } = new();
public event Action? OnCartChanged;
public static Func<Cart, long, int> GetCartItemQuantity = (shoppingCart, productPriceId) =>
shoppingCart.Items.FirstOrDefault(p => p.Price!.Id == productPriceId)?.Quantity ?? 1;
public Cart GetCart() => ShoppingCart;
public void NotifyStateChanged() => OnCartChanged?.Invoke();
public async Task LoadCartFromStorageAsync()
{
var loadResult = await localStorage.GetAsync<Cart>(CartStorageKey);
if (loadResult.IsFailed) await localStorage.SaveAsync(CartStorageKey, ShoppingCart);
if (loadResult.IsSuccess) ShoppingCart = loadResult.Value;
NotifyStateChanged();
}
public async Task SaveCartToStorageAsync() => await localStorage.SaveAsync(CartStorageKey, ShoppingCart);
public void AddItem(ProductPrice productPrice, Product product, Author author)
{
var itemExists = false;
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPrice.Id)
{
ShoppingCart.Items[i].Quantity++;
ShoppingCart.Items[i].Amount += productPrice.Amount;
itemExists = true;
break;
}
}
if (!itemExists)
ShoppingCart.Items.Add(new CartItem
{
Product = product,
Author = author,
Price = productPrice,
Amount = productPrice.Amount,
Quantity = 1,
});
CalculateTotalPrice();
NotifyStateChanged();
}
public void UpdateQuantity(long productPriceId, int delta)
{
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPriceId)
{
var oldQuantity = ShoppingCart.Items[i].Quantity;
var pricePerUnit = ShoppingCart.Items[i].Price!.Amount;
ShoppingCart.Items[i].Quantity += delta;
ShoppingCart.Items[i].Amount = pricePerUnit * ShoppingCart.Items[i].Quantity;
break;
}
}
CalculateTotalPrice();
NotifyStateChanged();
}
public void RemoveOneItem(long productPriceId)
{
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPriceId)
{
if (ShoppingCart.Items[i].Quantity <= 1)
{
ShoppingCart.Items.Remove(ShoppingCart.Items[i]);
break;
}
else
{
ShoppingCart.Items[i].Quantity--;
ShoppingCart.Items[i].Amount -= ShoppingCart.Items[i].Price!.Amount;
}
break;
}
}
CalculateTotalPrice();
NotifyStateChanged();
}
public void RemoveAllSameItem(long productPriceId)
{
if (ShoppingCart.Items.Count == 0) return;
var item = ShoppingCart.Items.FirstOrDefault(i => i.Price?.Id == productPriceId);
if (item is not null) ShoppingCart.Items.Remove(item);
CalculateTotalPrice();
NotifyStateChanged();
}
public void Clear()
{
if(ShoppingCart.CustomerId is not null || ShoppingCart.OrderId is not null)
{
ShoppingCart.TotalAmount = 0;
ShoppingCart.TotalVat = 0;
ShoppingCart.Items.Clear();
return;
}
ShoppingCart = new Cart();
NotifyStateChanged();
}
public decimal CalculateTotalPrice()
{
if (ShoppingCart.Items.Count == 0) return 0;
var gross = ShoppingCart.Items.Sum(i => i.Amount);
if (!ShoppingCart.IsVatInclusive) ShoppingCart.TotalVat = gross * ShoppingCart.VatRate;
ShoppingCart.TotalAmount = gross + ShoppingCart.TotalVat;
return ShoppingCart.TotalAmount;
}
}
@@ -0,0 +1,9 @@
using LiteCharms.Features.MidrandBooks.Orders.Entities;
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentConfiguration, Payment>]
public class Payment : Models.Payment
{
public virtual Order? Order { get; set; }
}
@@ -0,0 +1,22 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
public sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.ToTable("Payments");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedAt);
builder.Property(f => f.Status).IsRequired();
builder.Property(f => f.Reference).IsRequired();
builder.Property(f => f.OrderId).IsRequired();
builder.Property(f => f.Amount).IsRequired().HasPrecision(18, 2);
builder.HasOne(f => f.Order)
.WithMany()
.HasForeignKey(f => f.OrderId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,4 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentGatewayConfiguration, PaymentGateway>]
public class PaymentGateway : Models.PaymentGateway;
@@ -0,0 +1,19 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
public sealed class PaymentGatewayConfiguration : IEntityTypeConfiguration<PaymentGateway>
{
public void Configure(EntityTypeBuilder<PaymentGateway> builder)
{
builder.ToTable("Gateways");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedAt);
builder.Property(f => f.Website).IsRequired(false);
builder.Property(f => f.IsSandbox);
builder.Property(f => f.MerchantKey).IsRequired();
builder.Property(f => f.MerchantId).IsRequired();
builder.Property(f => f.Enabled);
builder.Property(f => f.Name).IsRequired();
}
}
@@ -0,0 +1,11 @@
using LiteCharms.Features.MidrandBooks.Orders.Entities;
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentGatewayLedgerConfiguration, PaymentGatewayLedger>]
public class PaymentGatewayLedger : Models.PaymentGatewayLedger
{
public virtual Order? Order { get; set; }
public virtual Payment? Payment { get; set; }
}
@@ -0,0 +1,30 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
public sealed class PaymentGatewayLedgerConfiguration : IEntityTypeConfiguration<PaymentGatewayLedger>
{
public void Configure(EntityTypeBuilder<PaymentGatewayLedger> builder)
{
builder.ToTable("GatewayLedger");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.OrderId).IsRequired();
builder.Property(f => f.PaymentId).IsRequired();
builder.Property(f => f.PayfastPaymentId).IsRequired();
builder.Property(f => f.MerchantPaymentId).IsRequired();
builder.Property(f => f.AmountGross).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.AmountFee).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.AmountNet).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.CustomerEmail).IsRequired(false);
builder.HasOne(f => f.Order)
.WithMany()
.HasForeignKey(f => f.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Payment)
.WithMany()
.HasForeignKey(f => f.PaymentId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,14 @@
using LiteCharms.Features.MidrandBooks.Customers.Entities;
using LiteCharms.Features.MidrandBooks.Orders.Entities;
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentLedgerConfiguration, PaymentLedger>]
public class PaymentLedger : Models.PaymentLedger
{
public virtual Payment? Payment { get; set; }
public virtual Order? Order { get; set; }
public virtual Customer? Customer { get; set; }
}
@@ -0,0 +1,34 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration<PaymentLedger>
{
public void Configure(EntityTypeBuilder<PaymentLedger> builder)
{
builder.ToTable("Ledger");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.Status).IsRequired();
builder.Property(f => f.MerchantPaymentId).IsRequired(false);
builder.Property(f => f.OrderId).IsRequired();
builder.Property(f => f.CustomerId).IsRequired();
builder.Property(f => f.PaymentId).IsRequired();
builder.HasOne(f => f.Payment)
.WithMany()
.IsRequired()
.HasForeignKey(f => f.PaymentId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Order)
.WithMany()
.IsRequired()
.HasForeignKey(f => f.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Customer)
.WithMany()
.IsRequired()
.HasForeignKey(f => f.CustomerId);
}
}
@@ -0,0 +1,164 @@
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers;
public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services,
IOptions<PayfastSettings> payfastOptions, ILogger<PayfastPaymentConfirmationReceivedEvent> logger) :
INotificationHandler<PayfastPaymentConfirmationReceivedEvent>
{
private readonly PayfastSettings pasfastSettings = payfastOptions.Value;
public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken)
{
using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(PayfastPaymentConfirmationReceivedEvent).Name}");
activity?.SetTag("event.correlation_id", notification.CorrelationId);
await using var scope = services.CreateAsyncScope();
var hashService = scope.ServiceProvider.GetRequiredService<HashService>();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var paymentService = scope.ServiceProvider.GetRequiredService<PaymentService>();
var payfastService = scope.ServiceProvider.GetRequiredService<PayfastService>();
var payload = notification.Payload ?? throw new Exception("Payload metadata context context is null.");
var dict = payload.ToParamDictionary();
var localSignature = PayfastService.GenerateSignature(dict, pasfastSettings.Passphrase);
if (localSignature.IsFailed)
throw new Exception("Failed to generate local signature for incoming webhook payload.");
if (!string.Equals(localSignature.Value, payload.Signature, StringComparison.OrdinalIgnoreCase))
{
logger.LogCritical("Incoming webhook signature verification failed. Possible payload tampering.");
return;
}
var hashResult = hashService.DecodeLongIdHash(payload.MerchantPaymentId!);
if (hashResult.IsFailed) throw new Exception("Failed to decode application tracking hash key identifier.");
var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken);
if (orderResult.IsFailed) throw new Exception("Target system order entity context cannot be traced.");
var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.Id, cancellationToken);
if (paymentResult.IsFailed) throw new Exception("Target payment ledger entity cannot be resolved.");
decimal.TryParse(payload.AmountGross, CultureInfo.InvariantCulture, out var gross);
decimal.TryParse(payload.AmountFee, CultureInfo.InvariantCulture, out var fee);
decimal.TryParse(payload.AmountNet, CultureInfo.InvariantCulture, out var net);
string status = payload.PaymentStatus ?? "UNKNOWN";
var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, cancellationToken);
if (isAlreadyProcessed.Value)
{
logger.LogWarning("Webhook reference token '{Ref}' already verified. Skipping validation routines.", payload.MerchantPaymentId);
return;
}
if (notification.PerformBackgroundChecks)
{
var isHostValid = await payfastService.ValidateReferrerIpAsync(notification.RemoteIpAddress!, notification.AllowLoopback, cancellationToken);
if (isHostValid.IsFailed)
throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks.");
if (!isHostValid.Value)
throw new Exception("Security validation exception: Webhook packet source address failed cluster validation checks.");
var isAmountValid = payfastService.ValidatePaymentAmount(orderResult.Value.Total, payload.AmountGross);
if (!isAmountValid.Value)
throw new Exception("Security validation exception: Transaction cost variance bounds breached.");
var paramList = new List<string>();
foreach (var kvp in dict)
{
if (!string.IsNullOrEmpty(kvp.Value))
{
string encoded = HttpUtility.UrlEncode(kvp.Value.Trim());
string safeValue = PayfastService.PercentEncodingRegex.Replace(encoded, m => m.Value.ToLowerInvariant());
paramList.Add($"{kvp.Key}={safeValue}");
}
}
string rawParamString = string.Join("&", paramList);
var serverConfirmation = await payfastService.ValidateServerConfirmationAsync(rawParamString, isSandbox: true, cancellationToken);
if (serverConfirmation.IsFailed)
throw new Exception("Security validation exception: Payfast central handshake server rejected payload legitimacy.");
}
await payfastService.WriteLedgerEntryAsync(new CreateGatewayLedgerEntry
{
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
MerchantPaymentId = payload.MerchantPaymentId!,
PayfastPaymentId = payload.PaymentId,
CustomerEmail = payload.EmailAddress,
AmountFee = fee,
AmountGross = gross,
AmountNet = net,
PaymentStatus = status,
}, cancellationToken);
if (status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase))
{
var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
PaymentGatewayReference = payload.PaymentId!,
Status = LedgerStatuses.Completed,
CustomerId = orderResult.Value.CustomerId,
}, cancellationToken);
if (ledgerWriteResult.IsFailed)
throw new Exception("Failed to write ledger entry for payment confirmation.");
var completePaymentResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken);
if (completePaymentResult.IsFailed)
throw new Exception("Failed to update payment status to 'Paid' for payment confirmation.");
var updateOrderResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken);
if (updateOrderResult.IsFailed)
throw new Exception("Failed to update order status to 'Completed' for payment confirmation.");
logger.LogInformation("Order payment verified secure and cleared successfully.");
}
else
{
LedgerStatuses ledgerStatus;
if (status.Equals("CANCELLED", StringComparison.OrdinalIgnoreCase))
ledgerStatus = LedgerStatuses.Cancelled;
else
ledgerStatus = LedgerStatuses.Failed;
var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
PaymentGatewayReference = payload.PaymentId!,
Status = ledgerStatus,
CustomerId = orderResult.Value.CustomerId,
}, cancellationToken);
logger.LogInformation("Webhook validation pipeline passed checks successfully, logged entry to ledger with status: {Status}", status);
}
activity?.SetStatus(ActivityStatusCode.Ok);
}
}
@@ -0,0 +1,30 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Events;
public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent
{
public string Name { get; set; } = nameof(PayfastPaymentConfirmationReceivedEvent);
public PayfastWebhookPayload? Payload { get; set; }
public string? RemoteIpAddress { get; set; }
public bool PerformBackgroundChecks { get; set; }
public bool AllowLoopback { get; set; }
public PayfastPaymentConfirmationReceivedEvent() { }
private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false)
{
Payload = payload;
CorrelationId = paymentId;
PerformBackgroundChecks = performBackgroundChecks;
AllowLoopback = allowLoopback;
}
public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false) =>
new(payload, paymentId, performBackgroundChecks, allowLoopback);
}
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class Cart
{
public long? CustomerId { get; set; }
public long? OrderId { get; set; }
public decimal TotalAmount { get; set; }
public decimal TotalVat { get; set; }
public decimal VatRate { get; set; } = 0.15m;
public bool IsVatInclusive { get; set; } = true;
public IList<CartItem> Items { get; set; } = [];
}
@@ -0,0 +1,17 @@
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Products.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class CartItem
{
public Author? Author { get; set; }
public Product? Product { get; set; }
public ProductPrice? Price { get; set; }
public int Quantity { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,59 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class PayfastWebhookPayload
{
public string? MerchantId { get; set; }
public string? MerchantKey { get; set; }
public string? Signature { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PaymentId { get; set; }
public string? PaymentStatus { get; set; }
public string? ItemName { get; set; }
public string? ItemDescription { get; set; }
public string? AmountGross { get; set; }
public string? AmountFee { get; set; }
public string? AmountNet { get; set; }
public string? NameFirst { get; set; }
public string? NameLast { get; set; }
public string? EmailAddress { get; set; }
public string? CustomStr1 { get; set; }
public string? CustomInt1 { get; set; }
public string? Token { get; set; }
public IDictionary<string, string?> ToParamDictionary() => new Dictionary<string, string?>
(StringComparer.Ordinal)
{
{ "merchant_id", MerchantId },
{ "merchant_key", MerchantKey },
{ "m_payment_id", MerchantPaymentId },
{ "pf_payment_id", PaymentId },
{ "payment_status", PaymentStatus },
{ "item_name", ItemName },
{ "item_description", ItemDescription },
{ "amount_gross", AmountGross },
{ "amount_fee", AmountFee },
{ "amount_net", AmountNet },
{ "custom_str1", CustomStr1 },
{ "custom_int1", CustomInt1 },
{ "name_first", NameFirst },
{ "name_last", NameLast },
{ "email_address", EmailAddress },
{ "token", Token }
};
}
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public class Payment
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public decimal Amount { get; set; }
public long OrderId { get; set; }
public string? Reference { get; set; }
public PaymentStatuses Status { get; set; }
}
@@ -0,0 +1,22 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public class PaymentGateway
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? Name { get; set; }
public string? Website { get; set; }
public string? MerchantId { get; set; }
public string? MerchantKey { get; set; }
public bool IsSandbox { get; set; }
public bool Enabled { get; set; }
}
@@ -0,0 +1,26 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public class PaymentGatewayLedger
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public string? CustomerEmail { get; set; }
public long OrderId { get; set; }
public long PaymentId { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PayfastPaymentId { get; set; }
public string? PaymentStatus { get; set; }
public decimal AmountGross { get; set; }
public decimal AmountFee { get; set; }
public decimal AmountNet { get; set; }
}
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public class PaymentLedger
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public LedgerStatuses Status { get; set; }
public long OrderId { get; set; }
public long PaymentId { get; set; }
public long CustomerId { get; set; }
public string? MerchantPaymentId { get; set; }
}
@@ -0,0 +1,74 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed record CreateGatewayLedgerEntry
{
public string? CustomerEmail { get; set; }
public required long OrderId { get; set; }
public required long PaymentId { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PayfastPaymentId { get; set; }
public string? PaymentStatus { get; set; }
public decimal AmountGross { get; set; }
public decimal AmountFee { get; set; }
public decimal AmountNet { get; set; }
}
public sealed record UpdateRefund
{
public long OrderId { get; set; }
public RefundStatus Status { get; set; }
public string? Reason { get; set; }
public decimal Amount { get; set; }
};
public sealed record CreateRefund
{
public long OrderId { get; set; }
public RefundTypes Type { get; set; }
public RefundStatus Status { get; set; }
public string? Reason { get; set; }
public decimal Amount { get; set; }
}
public sealed record CreateLedgerEntry
{
public required LedgerStatuses Status { get; set; }
public required long OrderId { get; set; }
public required long PaymentId { get; set; }
public required long CustomerId { get; set; }
public string? PaymentGatewayReference { get; set; }
public long? PaymentGatewayId { get; set; }
}
public sealed record CreatePaymentGateway
{
public required string? Name { get; set; }
public string? Website { get; set; }
public required string? MerchantId { get; set; }
public required string? MerchantKey { get; set; }
public bool IsSandbox { get; set; }
}
@@ -0,0 +1,242 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbContext> contextFactory,
IOptions<PayfastSettings> payfastOptions, ILogger<PayfastService> logger, IHttpClientFactory httpClientFactory) : IService
{
[GeneratedRegex(@"%[0-9A-Fa-f]{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
public static partial Regex PercentEncodingRegex { get; }
public async ValueTask<Result<long>> WriteLedgerEntryAsync(CreateGatewayLedgerEntry request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken))
return Result.Fail<long>("Referenced order ID does not exist in database.");
if(!await context.Payments.AnyAsync(p => p.Id == request.PaymentId, cancellationToken))
return Result.Fail<long>("Referenced payment ID does not exist in database.");
var entry = context.GatewayLedger.Add(new Entities.PaymentGatewayLedger
{
CustomerEmail = request.CustomerEmail,
OrderId = request.OrderId,
PaymentId = request.PaymentId,
MerchantPaymentId = request.MerchantPaymentId,
PayfastPaymentId = request.PayfastPaymentId,
PaymentStatus = request.PaymentStatus,
AmountGross = request.AmountGross,
AmountFee = request.AmountFee,
AmountNet = request.AmountNet,
CreatedAt = DateTime.UtcNow,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(entry.Entity.Id)
: Result.Fail<long>("Failed to save Payfast ledger entry to database.");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error("Failed to write Payfast ledger entry to database.").CausedBy(ex));
}
}
public static bool VerifyIncomingSignature(IDictionary<string, string> formFields, string passphrase)
{
if (!formFields.TryGetValue("signature", out string? incomingSignature))
return false;
var stringBuilder = new StringBuilder();
foreach (var key in formFields.Keys)
{
if (key.Equals("signature", StringComparison.OrdinalIgnoreCase))
continue;
string rawValue = formFields[key] ?? string.Empty;
string encodedVal = HttpUtility.UrlEncode(rawValue.Trim());
string cleanVal = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToUpperInvariant());
stringBuilder.Append($"{key}={cleanVal}&");
}
string encodedPassphrase = HttpUtility.UrlEncode(passphrase.Trim());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToUpperInvariant());
stringBuilder.Append($"passphrase={safePassphrase}");
string generatedSignature = HashService.ToMd5Hash(stringBuilder.ToString()).Value;
return incomingSignature.Equals(generatedSignature, StringComparison.OrdinalIgnoreCase);
}
public async ValueTask<Result<bool>> ValidateReferrerIpAsync(string remoteIpAddress, bool allowLoopback = false, CancellationToken cancellationToken = default)
{
if(payfastOptions.Value?.ValidHosts?.Length == 0)
return Result.Fail<bool>("Valid payfast hosts not configured.");
if (string.IsNullOrWhiteSpace(remoteIpAddress))
return Result.Fail<bool>("Remote IP address is null or whitespace.");
try
{
var validIps = new HashSet<IPAddress>();
foreach (var host in payfastOptions.Value!.ValidHosts!)
{
try
{
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
foreach (var addr in addresses) validIps.Add(addr);
}
catch (SocketException ex)
{
logger.LogWarning(ex, "DNS warning: Failed to resolve Payfast node '{Host}'. It may be decommissioned or unreachable.", host);
}
}
if (IPAddress.TryParse(remoteIpAddress, out var incomingIp))
{
if (allowLoopback && IPAddress.IsLoopback(incomingIp))
{
logger.LogInformation("Local development loopback IP '{RemoteIp}' allowed bypassing DNS verification.", remoteIpAddress);
return Result.Ok(true);
}
bool isValid = validIps.Contains(incomingIp);
if (!isValid)
logger.LogWarning("SECURITY ALERT: Webhook IP '{RemoteIp}' originated from an unlisted host schema.", remoteIpAddress);
return Result.Ok(isValid);
}
return Result.Fail<bool>("Invalid remote IP address format.");
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("DNS Verification error while scanning Payfast IP nodes.").CausedBy(ex));
}
}
public Result<bool> ValidatePaymentAmount(decimal expectedTotal, string? amountGrossString)
{
if (!decimal.TryParse(amountGrossString, CultureInfo.InvariantCulture, out decimal grossAmount))
return Result.Fail<bool>("Failed to parse payment amount.");
decimal delta = Math.Abs(expectedTotal - grossAmount);
bool isAmountValid = delta <= 0.01m;
if (!isAmountValid)
logger.LogError("FINANCIAL DRIFT EXCEPTION: Expected order total R{Expected} but gateway cleared R{Cleared}.", expectedTotal, grossAmount);
return Result.Ok(isAmountValid);
}
public async ValueTask<Result<bool>> ValidateServerConfirmationAsync(string rawQueryParamString, bool isSandbox, CancellationToken ct)
{
try
{
string host = isSandbox ? "sandbox.payfast.co.za" : "www.payfast.co.za";
string targetUrl = $"https://{host}/eng/query/validate";
using var content = new StringContent(rawQueryParamString, Encoding.UTF8, "application/x-www-form-urlencoded");
var httpClient = httpClientFactory.CreateClient();
var response = await httpClient.PostAsync(targetUrl, content, ct);
if (!response.IsSuccessStatusCode) return Result.Fail<bool>("Failed to validate server confirmation.");
string responseText = await response.Content.ReadAsStringAsync(ct);
bool isValidated = string.Equals(responseText.Trim(), "VALID", StringComparison.OrdinalIgnoreCase);
if (!isValidated)
logger.LogWarning("SECURITY WARNING: Payfast back-channel returned validation response: '{Response}'", responseText);
return Result.Ok(isValidated);
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("Failed to complete back-channel cURL verification handshakes with Payfast remote endpoints.").CausedBy(ex));
}
}
public static Result<string> GenerateSignature(IDictionary<string, string?> data, string? passPhrase = null)
{
var pfOutput = new StringBuilder();
var mandatorySequence = GetPayfastMandatoryFieldSequence();
foreach (string key in mandatorySequence)
{
if (data.TryGetValue(key, out string? rawValue) && !string.IsNullOrEmpty(rawValue))
{
string encodedVal = HttpUtility.UrlEncode(rawValue.Trim());
string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToUpperInvariant());
pfOutput.Append($"{key}={val}&");
}
}
var getString = pfOutput.Length > 0
? pfOutput.ToString()[..^1]
: string.Empty;
if (!string.IsNullOrWhiteSpace(passPhrase))
{
string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToUpperInvariant());
getString += $"&passphrase={safePassphrase}";
}
return HashService.ToMd5Hash(getString);
}
private static string[] GetPayfastMandatoryFieldSequence() =>
[
"merchant_id",
"merchant_key",
"return_url",
"cancel_url",
"notify_url",
"name_first",
"name_last",
"email_address",
"cell_number",
"m_payment_id",
"amount",
"item_name",
"item_description",
"custom_int1",
"custom_int2",
"custom_int3",
"custom_int4",
"custom_int5",
"custom_str1",
"custom_str2",
"custom_str3",
"custom_str4",
"custom_str5",
"email_confirmation",
"confirmation_address",
"payment_method",
"subscription_type",
"billing_date",
"recurring_amount",
"frequency",
"cycles"
];
}
@@ -1,7 +1,301 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed class PaymentService : IService
public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result<Payment>> GetOrderPaymentAsync(long orderId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var payment = await context.Payments.AsNoTracking()
.Where(p => p.OrderId == orderId)
.OrderByDescending(p => p.Id)
.FirstOrDefaultAsync(cancellationToken);
return payment is not null
? Result.Ok(payment.ToModel())
: Result.Fail<Payment>("Could not find payment for the order");
}
catch (Exception ex)
{
return Result.Fail<Payment>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Refund>> GetRefundAsync(long refundId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var refund = await context.Refunds.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == refundId, cancellationToken);
return refund is not null
? Result.Ok(refund.ToModel())
: Result.Fail<Refund>("Could not find refund");
}
catch (Exception ex)
{
return Result.Fail<Refund>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdateRefundAsync(long refundId, UpdateRefund request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken))
return Result.Fail("Order not found");
var updatedRows = await context.Refunds
.Where(r => r.Id == refundId && r.OrderId == request.OrderId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(r => r.Status, request.Status)
.SetProperty(r => r.Reason, request.Reason)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow)
.SetProperty(r => r.Amount, request.Amount), cancellationToken);
return updatedRows > 0
? Result.Ok()
: Result.Fail("Failed to update refund");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreateRefundAsync(CreateRefund request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == request.OrderId
&& o.Status == OrderStatus.Completed, cancellationToken);
if (order is null) return Result.Fail("Order not found");
if (request.Amount > order.Total)
return Result.Fail<long>("Refund amount cannot be greater than order total");
var totalRefundsPaid = await context.Refunds
.Where(r => r.OrderId == request.OrderId)
.SumAsync(r => r.Amount, cancellationToken);
if (request.Amount > (order.Total - totalRefundsPaid))
return Result.Fail<long>("Refund amount exceeds amount available for refund");
var refund = context.Refunds.Add(new Entities.Refund
{
Amount = request.Amount,
CreatedAt = DateTime.UtcNow,
OrderId = request.OrderId,
Reason = request.Reason,
Status = request.Status,
Type = request.Type,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(refund.Entity.Id)
: Result.Fail<long>("Failed to create refund");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<bool>> HasLedgerEntryAsync(long orderId, long paymentId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var exists = await context.Ledger.AnyAsync(l =>
l.OrderId == orderId &&
l.PaymentId == paymentId, cancellationToken);
return Result.Ok(exists);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> WriteLedgerEntryAsync(CreateLedgerEntry request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken))
return Result.Fail("Order not found");
if (!await context.Customers.AnyAsync(o => o.Id == request.CustomerId, cancellationToken))
return Result.Fail("Customer not found");
if (!await context.Orders.AnyAsync(oc => oc.Id == request.OrderId && oc.CustomerId == request.CustomerId, cancellationToken))
return Result.Fail("Customer does not match the order");
if (!await context.Payments.AnyAsync(o => o.Id == request.PaymentId && o.OrderId == request.OrderId, cancellationToken))
return Result.Fail("Payment not found");
if (request.PaymentGatewayId is not null)
if (!await context.Gateways.AnyAsync(o => o.Id == request.PaymentGatewayId, cancellationToken))
return Result.Fail("Gateway not found");
context.Ledger.Add(new Entities.PaymentLedger
{
CreatedAt = DateTime.UtcNow,
CustomerId = request.CustomerId,
OrderId = request.OrderId,
PaymentId = request.PaymentId,
Status = request.Status,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create ledger entry");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<PaymentGateway>> GetPaymentGatewayAsync(long paymentGatewayId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var gateway = await context.Gateways.AsNoTracking().FirstOrDefaultAsync(g => g.Id == paymentGatewayId, cancellationToken);
return gateway is not null
? Result.Ok(gateway.ToModel())
: Result.Fail<PaymentGateway>("Could not find gateway");
}
catch (Exception ex)
{
return Result.Fail<PaymentGateway>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreatePaymentGatewayAsync(CreatePaymentGateway request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Gateways.AnyAsync(g => g.MerchantId == request.MerchantId && g.MerchantKey == request.MerchantKey, cancellationToken))
return Result.Fail<long>("A gateway with the same credentials already exists");
var gateway = context.Gateways.Add(new Entities.PaymentGateway
{
CreatedAt = DateTime.UtcNow,
Enabled = true,
IsSandbox = request.IsSandbox,
MerchantId = request.MerchantId,
MerchantKey = request.MerchantKey,
Name = request.Name,
Website = request.Website,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(gateway.Entity.Id)
: Result.Fail<long>("Failed to create payment gateway");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CompletePaymentAsync(long paymentId, PaymentStatuses status, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (status == PaymentStatuses.NotPaid)
return Result.Fail("Cannot finalise a payment using NotPaid status");
var updatedRecords = await context.Payments
.Where(p => p.Id == paymentId && p.Status != PaymentStatuses.Paid && p.Status != status)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.Status, status)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow), cancellationToken);
return updatedRecords > 0
? Result.Ok()
: Result.Fail("Failed to update payment");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdatePaymentAsync(long paymentId, decimal amount, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var updatedRecords = await context.Payments
.Where(p => p.Id == paymentId && p.Status == PaymentStatuses.NotPaid)
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.Amount, amount)
.SetProperty(u => u.Status, PaymentStatuses.NotPaid)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow), cancellationToken);
return updatedRecords > 0
? Result.Ok()
: Result.Fail("Failed to update payment");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> CreatePaymentAsync(decimal amount, long orderId, string reference, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Payments.AnyAsync(p => p.OrderId == orderId && p.Amount == amount && p.Status != PaymentStatuses.Paid, cancellationToken))
return Result.Fail<long>("An order with the same amount already exists in the system");
var payment = context.Payments.Add(new Entities.Payment
{
CreatedAt = DateTime.UtcNow,
Amount = amount,
OrderId = orderId,
Reference = reference,
Status = PaymentStatuses.NotPaid,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(payment.Entity.Id)
: Result.Fail<long>("Failed to make payment");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,5 +1,6 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities;
using LiteCharms.Features.MidrandBooks.Authors.Entities;
using LiteCharms.Features.MidrandBooks.Categories.Entities;
using LiteCharms.Features.MidrandBooks.Customers.Entities;
using LiteCharms.Features.MidrandBooks.Orders.Entities;
using LiteCharms.Features.MidrandBooks.Pages.Entities;
@@ -35,4 +36,18 @@ public sealed class MidrandBooksDbContext(DbContextOptions<MidrandBooksDbContext
public DbSet<Shipping> Shippings => Set<Shipping>();
public DbSet<ShippingProvider> ShippingProviders => Set<ShippingProvider>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>();
public DbSet<ProductInventory> Inventories => Set<ProductInventory>();
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<PaymentGateway> Gateways => Set<PaymentGateway>();
public DbSet<PaymentLedger> Ledger => Set<PaymentLedger>();
public DbSet<PaymentGatewayLedger> GatewayLedger => Set<PaymentGatewayLedger>();
}
@@ -1,7 +1,6 @@
// <auto-generated />
using System;
using LiteCharms.Features.MidrandBooks.Postgres;
using LiteCharms.Features.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -13,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
[DbContext(typeof(MidrandBooksDbContext))]
[Migration("20260527070840_Init")]
[Migration("20260529070104_Init")]
partial class Init
{
/// <inheritdoc />
@@ -112,9 +111,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Property<int>("PublisherType")
.HasColumnType("integer");
b.Property<SocialMedia[]>("SocialMedia")
.HasColumnType("jsonb");
b.Property<string>("ThumbnailImageUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -291,9 +287,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<SocialMedia[]>("SocialMedia")
.HasColumnType("jsonb");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -338,7 +331,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("integer");
b.Property<decimal>("Total")
.HasColumnType("decimal(18,2)");
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
@@ -494,17 +488,14 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.PrimitiveCollection<string>("Notes")
.HasColumnType("jsonb");
b.PrimitiveCollection<string[]>("Notes")
.HasColumnType("text[]");
b.Property<int>("Number")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<PageReference[]>("References")
.HasColumnType("jsonb");
b.Property<int>("Type")
.HasColumnType("integer");
@@ -570,8 +561,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<string>("Categories")
.HasColumnType("jsonb");
b.PrimitiveCollection<string[]>("Categories")
.HasColumnType("text[]");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
@@ -591,9 +582,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<ProductMetadata>("Metadata")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
@@ -604,8 +592,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.PrimitiveCollection<string>("ThumbnailUrls")
.HasColumnType("jsonb");
b.PrimitiveCollection<string[]>("ThumbnailUrls")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
@@ -661,40 +649,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("Prices", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("Discount")
.HasColumnType("numeric");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId")
.IsUnique();
b.ToTable("ProductPrice");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", "Author")
@@ -714,6 +668,38 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("AuthorId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("AuthorId", "__synthesizedOrdinal");
b1.ToTable("Authors");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("AuthorId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
@@ -736,6 +722,38 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("CustomerId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("CustomerId", "__synthesizedOrdinal");
b1.ToTable("Customers");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("CustomerId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "AuthorBook")
@@ -798,7 +816,34 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.OwnsMany("LiteCharms.Features.Models.PageReference", "References", b1 =>
{
b1.Property<long>("BookPageId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Tag");
b1.Property<string>("Url");
b1.HasKey("BookPageId", "__synthesizedOrdinal");
b1.ToTable("BookPages");
b1
.ToJson("References")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("BookPageId");
});
b.Navigation("Book");
b.Navigation("References");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b =>
@@ -812,6 +857,35 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.OwnsOne("LiteCharms.Features.Models.ProductMetadata", "Metadata", b1 =>
{
b1.Property<long>("ProductId");
b1.Property<string>("CopyrightInfo");
b1.Property<string>("ManufactureDate");
b1.Property<string>("Manufacturer");
b1.Property<string>("SerialNumber");
b1.HasKey("ProductId");
b1.ToTable("Products");
b1
.ToJson("Metadata")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("ProductId");
});
b.Navigation("Metadata");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
@@ -823,15 +897,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", null)
.WithOne("Price")
.HasForeignKey("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", "ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.Navigation("Pages");
@@ -865,8 +930,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.Navigation("Price");
b.Navigation("Prices");
});
#pragma warning restore 612, 618
@@ -1,5 +1,4 @@
using System;
using LiteCharms.Features.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
@@ -31,8 +30,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
Website = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ImageUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
ThumbnailImageUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
SocialMedia = table.Column<SocialMedia[]>(type: "jsonb", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true)
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SocialMedia = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@@ -52,8 +51,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
Email = table.Column<string>(type: "text", nullable: false),
Website = table.Column<string>(type: "text", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
SocialMedia = table.Column<SocialMedia[]>(type: "jsonb", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true)
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SocialMedia = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@@ -70,7 +69,7 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, defaultValueSql: "now()"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
Total = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
Total = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
InvoiceUrl = table.Column<string>(type: "text", nullable: true)
},
@@ -92,10 +91,10 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
Summary = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Description = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ImageUrl = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
ThumbnailUrls = table.Column<string>(type: "jsonb", nullable: true),
Categories = table.Column<string>(type: "jsonb", nullable: true),
Metadata = table.Column<ProductMetadata>(type: "jsonb", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
ThumbnailUrls = table.Column<string[]>(type: "text[]", nullable: true),
Categories = table.Column<string[]>(type: "text[]", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Metadata = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@@ -260,30 +259,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ProductPrice",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ProductId = table.Column<long>(type: "bigint", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Discount = table.Column<decimal>(type: "numeric", nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductPrice", x => x.Id);
table.ForeignKey(
name: "FK_ProductPrice_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Shippings",
columns: table => new
@@ -334,9 +309,9 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
ContentType = table.Column<int>(type: "integer", nullable: false),
Number = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
Content = table.Column<byte[]>(type: "bytea", nullable: false),
Notes = table.Column<string>(type: "jsonb", nullable: true),
References = table.Column<PageReference[]>(type: "jsonb", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true)
Notes = table.Column<string[]>(type: "text[]", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
References = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@@ -428,12 +403,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
table: "Prices",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_ProductPrice_ProductId",
table: "ProductPrice",
column: "ProductId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Refunds_OrderId",
table: "Refunds",
@@ -468,9 +437,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
migrationBuilder.DropTable(
name: "OrderItems");
migrationBuilder.DropTable(
name: "ProductPrice");
migrationBuilder.DropTable(
name: "Refunds");
@@ -0,0 +1,966 @@
// <auto-generated />
using System;
using LiteCharms.Features.MidrandBooks.Postgres;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
[DbContext(typeof(MidrandBooksDbContext))]
[Migration("20260530104851_AddedCategories")]
partial class AddedCategories
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.Property<int>("Ranking")
.HasColumnType("integer");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ProductId");
b.ToTable("Books");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Biography")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Company")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("ImageUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<int>("PublisherType")
.HasColumnType("integer");
b.Property<string>("ThumbnailImageUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("VatNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Website")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Id");
b.ToTable("Authors", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsMain")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
b.HasKey("Id");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("BuildingType")
.HasColumnType("integer");
b.Property<string>("City")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PostalCode")
.IsRequired()
.HasColumnType("text");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Street")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("Addresses", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("Contacts", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Company")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("VatNumber")
.HasColumnType("text");
b.Property<string>("Website")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Customers", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("InvoiceUrl")
.HasColumnType("text");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<decimal>("Total")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.ToTable("Orders", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorBookId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<long>("ProductPriceId")
.HasColumnType("bigint");
b.Property<int>("Quantity")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AuthorBookId");
b.HasIndex("OrderId");
b.HasIndex("ProductPriceId");
b.ToTable("OrderItems", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AddressId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<long>("ShippingProviderId")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TrackingNumber")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("AddressId");
b.HasIndex("OrderId")
.IsUnique();
b.HasIndex("ShippingProviderId");
b.ToTable("Shippings", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<string>("TrackingUrl")
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ShippingProviders");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorBookId")
.HasColumnType("bigint");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.PrimitiveCollection<string[]>("Notes")
.HasColumnType("text[]");
b.Property<int>("Number")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("AuthorBookId");
b.ToTable("BookPages", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<string>("Reason")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("Refunds", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<string[]>("Categories")
.HasColumnType("text[]");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.PrimitiveCollection<string[]>("ThumbnailUrls")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.ToTable("Products", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<decimal>("Discount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("ProductId");
b.ToTable("Prices", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", "Author")
.WithMany("Books")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("AuthorId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("AuthorId", "__synthesizedOrdinal");
b1.ToTable("Authors");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("AuthorId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
.WithMany("Addresses")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Contact", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
.WithMany("Contacts")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("CustomerId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("CustomerId", "__synthesizedOrdinal");
b1.ToTable("Customers");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("CustomerId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "AuthorBook")
.WithMany()
.HasForeignKey("AuthorBookId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany("OrderItems")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "ProductPrice")
.WithMany()
.HasForeignKey("ProductPriceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AuthorBook");
b.Navigation("Order");
b.Navigation("ProductPrice");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", "Address")
.WithMany()
.HasForeignKey("AddressId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithOne("Shipping")
.HasForeignKey("LiteCharms.Features.MidrandBooks.Orders.Entities.Shipping", "OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", "ShippingProvider")
.WithMany("Shippings")
.HasForeignKey("ShippingProviderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Address");
b.Navigation("Order");
b.Navigation("ShippingProvider");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Pages.Entities.BookPage", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "Book")
.WithMany("Pages")
.HasForeignKey("AuthorBookId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.OwnsMany("LiteCharms.Features.Models.PageReference", "References", b1 =>
{
b1.Property<long>("BookPageId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Tag");
b1.Property<string>("Url");
b1.HasKey("BookPageId", "__synthesizedOrdinal");
b1.ToTable("BookPages");
b1
.ToJson("References")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("BookPageId");
});
b.Navigation("Book");
b.Navigation("References");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany("Refunds")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.OwnsOne("LiteCharms.Features.Models.ProductMetadata", "Metadata", b1 =>
{
b1.Property<long>("ProductId");
b1.Property<string>("CopyrightInfo");
b1.Property<string>("ManufactureDate");
b1.Property<string>("Manufacturer");
b1.Property<string>("SerialNumber");
b1.HasKey("ProductId");
b1.ToTable("Products");
b1
.ToJson("Metadata")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("ProductId");
});
b.Navigation("Metadata");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
.WithMany("Prices")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.Navigation("Pages");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b =>
{
b.Navigation("Books");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b =>
{
b.Navigation("Addresses");
b.Navigation("Contacts");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", b =>
{
b.Navigation("OrderItems");
b.Navigation("Refunds");
b.Navigation("Shipping");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.ShippingProvider", b =>
{
b.Navigation("Shippings");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.Navigation("Prices");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddedCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(15)", maxLength: 15, nullable: false),
IsMain = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Categories");
}
}
}
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddedProductCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Categories",
table: "Products");
migrationBuilder.CreateTable(
name: "ProductCategories",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ProductId = table.Column<long>(type: "bigint", nullable: false),
CategoryId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductCategories", x => x.Id);
table.ForeignKey(
name: "FK_ProductCategories_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ProductCategories_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ProductCategories_CategoryId",
table: "ProductCategories",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_ProductCategories_ProductId",
table: "ProductCategories",
column: "ProductId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ProductCategories");
migrationBuilder.AddColumn<string[]>(
name: "Categories",
table: "Products",
type: "text[]",
nullable: true);
}
}
}
@@ -0,0 +1,185 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddedPaymentObjects : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Gateways",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Name = table.Column<string>(type: "text", nullable: false),
Website = table.Column<string>(type: "text", nullable: true),
MerchantId = table.Column<string>(type: "text", nullable: false),
MerchantKey = table.Column<string>(type: "text", nullable: false),
Passphrase = table.Column<string>(type: "text", nullable: false),
IsSandbox = table.Column<bool>(type: "boolean", nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Gateways", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Inventories",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
Status = table.Column<int>(type: "integer", nullable: false),
ProductId = table.Column<long>(type: "bigint", nullable: false),
ProductPriceId = table.Column<long>(type: "bigint", nullable: false),
TotalAllocated = table.Column<int>(type: "integer", nullable: false),
TotalReserved = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Inventories", x => x.Id);
table.ForeignKey(
name: "FK_Inventories_Prices_ProductPriceId",
column: x => x.ProductPriceId,
principalTable: "Prices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Inventories_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
OrderId = table.Column<long>(type: "bigint", nullable: false),
Reference = table.Column<string>(type: "text", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Payments", x => x.Id);
table.ForeignKey(
name: "FK_Payments_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Ledger",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
Status = table.Column<int>(type: "integer", nullable: false),
OrderId = table.Column<long>(type: "bigint", nullable: false),
PaymentId = table.Column<long>(type: "bigint", nullable: false),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
PaymentGatewayReference = table.Column<string>(type: "text", nullable: true),
PaymentGatewayId = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Ledger", x => x.Id);
table.ForeignKey(
name: "FK_Ledger_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Ledger_Gateways_PaymentGatewayId",
column: x => x.PaymentGatewayId,
principalTable: "Gateways",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Ledger_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Ledger_Payments_PaymentId",
column: x => x.PaymentId,
principalTable: "Payments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Inventories_ProductId",
table: "Inventories",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_Inventories_ProductPriceId",
table: "Inventories",
column: "ProductPriceId");
migrationBuilder.CreateIndex(
name: "IX_Ledger_CustomerId",
table: "Ledger",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_Ledger_OrderId",
table: "Ledger",
column: "OrderId");
migrationBuilder.CreateIndex(
name: "IX_Ledger_PaymentGatewayId",
table: "Ledger",
column: "PaymentGatewayId");
migrationBuilder.CreateIndex(
name: "IX_Ledger_PaymentId",
table: "Ledger",
column: "PaymentId");
migrationBuilder.CreateIndex(
name: "IX_Payments_OrderId",
table: "Payments",
column: "OrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Inventories");
migrationBuilder.DropTable(
name: "Ledger");
migrationBuilder.DropTable(
name: "Gateways");
migrationBuilder.DropTable(
name: "Payments");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class RemovedPassphraseFromPaymentGateway : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Passphrase",
table: "Gateways");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Passphrase",
table: "Gateways",
type: "text",
nullable: false,
defaultValue: "");
}
}
}
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddedPaymentGatewayLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Ledger_Gateways_PaymentGatewayId",
table: "Ledger");
migrationBuilder.DropIndex(
name: "IX_Ledger_PaymentGatewayId",
table: "Ledger");
migrationBuilder.DropColumn(
name: "PaymentGatewayId",
table: "Ledger");
migrationBuilder.RenameColumn(
name: "PaymentGatewayReference",
table: "Ledger",
newName: "MerchantPaymentId");
migrationBuilder.CreateTable(
name: "GatewayLedger",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
CustomerEmail = table.Column<string>(type: "text", nullable: true),
OrderId = table.Column<long>(type: "bigint", nullable: false),
PaymentId = table.Column<long>(type: "bigint", nullable: false),
MerchantPaymentId = table.Column<string>(type: "text", nullable: true),
PayfastPaymentId = table.Column<string>(type: "text", nullable: false),
PaymentStatus = table.Column<string>(type: "text", nullable: true),
AmountGross = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
AmountFee = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
AmountNet = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GatewayLedger", x => x.Id);
table.ForeignKey(
name: "FK_GatewayLedger_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GatewayLedger_Payments_PaymentId",
column: x => x.PaymentId,
principalTable: "Payments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GatewayLedger_OrderId",
table: "GatewayLedger",
column: "OrderId");
migrationBuilder.CreateIndex(
name: "IX_GatewayLedger_PaymentId",
table: "GatewayLedger",
column: "PaymentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GatewayLedger");
migrationBuilder.RenameColumn(
name: "MerchantPaymentId",
table: "Ledger",
newName: "PaymentGatewayReference");
migrationBuilder.AddColumn<long>(
name: "PaymentGatewayId",
table: "Ledger",
type: "bigint",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Ledger_PaymentGatewayId",
table: "Ledger",
column: "PaymentGatewayId");
migrationBuilder.AddForeignKey(
name: "FK_Ledger_Gateways_PaymentGatewayId",
table: "Ledger",
column: "PaymentGatewayId",
principalTable: "Gateways",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddedPayfastPaymentIdToPaymentGatewayLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MerchantPaymentId",
table: "GatewayLedger",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MerchantPaymentId",
table: "GatewayLedger",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public partial class OnlyEmailIsMandatoryOnCustomer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}
@@ -1,7 +1,6 @@
// <auto-generated />
using System;
using LiteCharms.Features.MidrandBooks.Postgres;
using LiteCharms.Features.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -18,7 +17,7 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -109,9 +108,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Property<int>("PublisherType")
.HasColumnType("integer");
b.Property<SocialMedia[]>("SocialMedia")
.HasColumnType("jsonb");
b.Property<string>("ThumbnailImageUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -134,6 +130,34 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("Authors", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsMain")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("character varying(15)");
b.HasKey("Id");
b.ToTable("Categories", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b =>
{
b.Property<long>("Id")
@@ -285,12 +309,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasDefaultValue(true);
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
b.Property<SocialMedia[]>("SocialMedia")
.HasColumnType("jsonb");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -300,7 +320,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("text");
b.Property<string>("Website")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
@@ -335,7 +354,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("integer");
b.Property<decimal>("Total")
.HasColumnType("decimal(18,2)");
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("UpdatedAt")
.ValueGeneratedOnAdd()
@@ -491,17 +511,14 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("boolean")
.HasDefaultValue(true);
b.PrimitiveCollection<string>("Notes")
.HasColumnType("jsonb");
b.PrimitiveCollection<string[]>("Notes")
.HasColumnType("text[]");
b.Property<int>("Number")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<PageReference[]>("References")
.HasColumnType("jsonb");
b.Property<int>("Type")
.HasColumnType("integer");
@@ -517,6 +534,178 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("BookPages", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<string>("Reference")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("Payments", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGateway", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<bool>("IsSandbox")
.HasColumnType("boolean");
b.Property<string>("MerchantId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MerchantKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Website")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Gateways", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("AmountFee")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("AmountGross")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("AmountNet")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("CustomerEmail")
.HasColumnType("text");
b.Property<string>("MerchantPaymentId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<string>("PayfastPaymentId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("PaymentId")
.HasColumnType("bigint");
b.Property<string>("PaymentStatus")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("PaymentId");
b.ToTable("GatewayLedger", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("MerchantPaymentId")
.HasColumnType("text");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<long>("PaymentId")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.HasIndex("OrderId");
b.HasIndex("PaymentId");
b.ToTable("Ledger", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b =>
{
b.Property<long>("Id")
@@ -567,9 +756,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.PrimitiveCollection<string>("Categories")
.HasColumnType("jsonb");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -588,9 +774,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<ProductMetadata>("Metadata")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
@@ -601,8 +784,8 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.PrimitiveCollection<string>("ThumbnailUrls")
.HasColumnType("jsonb");
b.PrimitiveCollection<string[]>("ThumbnailUrls")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
@@ -617,6 +800,66 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("Products", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CategoryId")
.HasColumnType("bigint");
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ProductId");
b.ToTable("ProductCategories", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.Property<long>("ProductPriceId")
.HasColumnType("bigint");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalAllocated")
.HasColumnType("integer");
b.Property<int>("TotalReserved")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("ProductPriceId");
b.ToTable("Inventories", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b =>
{
b.Property<long>("Id")
@@ -658,40 +901,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("Prices", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("Discount")
.HasColumnType("numeric");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<long>("ProductId")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId")
.IsUnique();
b.ToTable("ProductPrice");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", "Author")
@@ -711,6 +920,38 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Authors.Entities.Author", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("AuthorId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("AuthorId", "__synthesizedOrdinal");
b1.ToTable("Authors");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("AuthorId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Address", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
@@ -733,6 +974,38 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Customer");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", b =>
{
b.OwnsMany("LiteCharms.Features.Models.SocialMedia", "SocialMedia", b1 =>
{
b1.Property<long>("CustomerId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("ImageUrl");
b1.Property<string>("Name");
b1.Property<int>("Type");
b1.Property<string>("Url");
b1.HasKey("CustomerId", "__synthesizedOrdinal");
b1.ToTable("Customers");
b1
.ToJson("SocialMedia")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("CustomerId");
});
b.Navigation("SocialMedia");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Orders.Entities.OrderItem", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", "AuthorBook")
@@ -795,7 +1068,91 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.OwnsMany("LiteCharms.Features.Models.PageReference", "References", b1 =>
{
b1.Property<long>("BookPageId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Tag");
b1.Property<string>("Url");
b1.HasKey("BookPageId", "__synthesizedOrdinal");
b1.ToTable("BookPages");
b1
.ToJson("References")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("BookPageId");
});
b.Navigation("Book");
b.Navigation("References");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment")
.WithMany()
.HasForeignKey("PaymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Order");
b.Navigation("Payment");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment")
.WithMany()
.HasForeignKey("PaymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
b.Navigation("Order");
b.Navigation("Payment");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.Refund", b =>
@@ -809,6 +1166,73 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.OwnsOne("LiteCharms.Features.Models.ProductMetadata", "Metadata", b1 =>
{
b1.Property<long>("ProductId");
b1.Property<string>("CopyrightInfo");
b1.Property<string>("ManufactureDate");
b1.Property<string>("Manufacturer");
b1.Property<string>("SerialNumber");
b1.HasKey("ProductId");
b1.ToTable("Products");
b1
.ToJson("Metadata")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("ProductId");
});
b.Navigation("Metadata");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductCategory", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Categories.Entities.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
.WithMany("Categories")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductInventory", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", "Price")
.WithMany()
.HasForeignKey("ProductPriceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Price");
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.ProductPrice", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", "Product")
@@ -820,15 +1244,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Product");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Products.Entities.Product", null)
.WithOne("Price")
.HasForeignKey("LiteCharms.Features.MidrandBooks.Products.Models.ProductPrice", "ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.AuthorBooks.Entities.AuthorBook", b =>
{
b.Navigation("Pages");
@@ -862,7 +1277,7 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Products.Entities.Product", b =>
{
b.Navigation("Price");
b.Navigation("Categories");
b.Navigation("Prices");
});
@@ -3,5 +3,7 @@
[EntityTypeConfiguration<ProductConfiguration, Product>]
public class Product : Models.Product
{
public virtual ICollection<ProductCategory> Categories { get; set; } = [];
public virtual ICollection<ProductPrice> Prices { get; set; } = [];
}
@@ -0,0 +1,11 @@
using LiteCharms.Features.MidrandBooks.Categories.Entities;
namespace LiteCharms.Features.MidrandBooks.Products.Entities;
[EntityTypeConfiguration<ProductCategoryConfiguration, ProductCategory>]
public class ProductCategory : Models.ProductCategory
{
public virtual Product? Product { get; set; }
public virtual Category? Category { get; set; }
}
@@ -0,0 +1,23 @@
namespace LiteCharms.Features.MidrandBooks.Products.Entities;
public sealed class ProductCategoryConfiguration : IEntityTypeConfiguration<ProductCategory>
{
public void Configure(EntityTypeBuilder<ProductCategory> builder)
{
builder.ToTable("ProductCategories");
builder.HasKey(p => p.Id);
builder.Property(p => p.ProductId).IsRequired();
builder.Property(p => p.CategoryId).IsRequired();
builder.HasOne(p => p.Product)
.WithMany(p => p.Categories)
.HasForeignKey(p => p.ProductId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.Category)
.WithMany()
.HasForeignKey(c => c.CategoryId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -4,6 +4,8 @@ public sealed class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Ignore(p => p.Price);
builder.ToTable("Products");
builder.HasKey(f => f.Id);
@@ -14,9 +16,9 @@ public sealed class ProductConfiguration : IEntityTypeConfiguration<Product>
builder.Property(f => f.Summary).IsRequired().HasMaxLength(512);
builder.Property(f => f.Description).HasMaxLength(1024);
builder.Property(f => f.ImageUrl).HasMaxLength(1024);
builder.Property(f => f.ThumbnailUrls).IsRequired(false).HasColumnType("jsonb");
builder.Property(f => f.Metadata).IsRequired(false).HasColumnType("jsonb");
builder.Property(f => f.Categories).IsRequired(false).HasColumnType("jsonb");
builder.Property(f => f.Enabled).HasDefaultValue(false);
builder.Property(f => f.ThumbnailUrls).IsRequired(false);
builder.OwnsOne(f => f.Metadata, b => { b.ToJson(); });
}
}
@@ -0,0 +1,9 @@
namespace LiteCharms.Features.MidrandBooks.Products.Entities;
[EntityTypeConfiguration<ProductInventoryConfiguration, ProductInventory>]
public class ProductInventory : Models.ProductInventory
{
public virtual Product? Product { get; set; }
public virtual ProductPrice? Price { get; set; }
}
@@ -0,0 +1,27 @@
namespace LiteCharms.Features.MidrandBooks.Products.Entities;
public sealed class ProductInventoryConfiguration : IEntityTypeConfiguration<ProductInventory>
{
public void Configure(EntityTypeBuilder<ProductInventory> builder)
{
builder.ToTable("Inventories");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.Status).IsRequired();
builder.Property(f => f.TotalAllocated).IsRequired();
builder.Property(f => f.TotalReserved).IsRequired();
builder.Property(f => f.ProductId).IsRequired();
builder.Property(f => f.ProductPriceId).IsRequired();
builder.HasOne(f => f.Product)
.WithMany()
.HasForeignKey(f => f.ProductId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Price)
.WithMany()
.HasForeignKey(f => f.ProductPriceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -3,5 +3,5 @@
[EntityTypeConfiguration<ProductPriceConfiguration, ProductPrice>]
public class ProductPrice : Models.ProductPrice
{
public virtual Product Product { get; set; } = new();
public virtual Product? Product { get; set; }
}
@@ -1,10 +0,0 @@
namespace LiteCharms.Features.MidrandBooks.Products.Models;
public sealed class CreateProductPrice
{
public long ProductId { get; set; }
public decimal Amount { get; set; }
public decimal Discount { get; set; }
}
@@ -22,8 +22,6 @@ public class Product
public string[]? ThumbnailUrls { get; set; }
public string[]? Categories { get; set; }
public ProductMetadata? Metadata { get; set; }
public ProductPrice? Price { get; set; }
@@ -0,0 +1,10 @@
namespace LiteCharms.Features.MidrandBooks.Products.Models;
public class ProductCategory
{
public long Id { get; set; }
public long ProductId { get; set; }
public long CategoryId { get; set; }
}
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.MidrandBooks.Products.Models;
public class ProductInventory
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public InventoryStatuses Status { get; set; }
public long ProductId { get; set; }
public long ProductPriceId { get; set; }
public int TotalAllocated { get; set; }
public int TotalReserved { get; set; }
}
@@ -2,6 +2,24 @@
namespace LiteCharms.Features.MidrandBooks.Products.Models;
public sealed record ReserveStock
{
public required long ProductId { get; set; }
public required long ProductPriceId { get; set; }
public int Reservation { get; set; }
}
public sealed record AllocateStock
{
public required long ProductId { get; set; }
public required long ProductPriceId { get; set; }
public int Allocation { get; set; }
}
public sealed record CreateProduct
{
public required ProductTypes Type { get; set; }
@@ -20,3 +38,10 @@ public sealed record CreateProduct
public ProductMetadata? Metadata { get; set; }
}
public sealed class CreateProductPrice
{
public decimal Amount { get; set; }
public decimal Discount { get; set; }
}
@@ -1,30 +1,241 @@
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.MidrandBooks.Categories.Models;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Postgres;
using LiteCharms.Features.MidrandBooks.Products.Models;
using LiteCharms.Features.Models;
using Org.BouncyCastle.Asn1.Ocsp;
namespace LiteCharms.Features.MidrandBooks.Products;
public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> contextFactory) : IService
{
public async ValueTask<Result<ProductInventory>> CheckProductStockAvailabilityAsync(long productId, long productPriceId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var inventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == productPriceId && i.ProductId == productId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
return inventory is not null
? Result.Ok(inventory.ToModel())
: Result.Fail<ProductInventory>("Product sold out");
}
catch (Exception ex)
{
return Result.Fail<ProductInventory>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> ReserveProductInventoryAsync(ReserveStock request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var oldInventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == request.ProductPriceId && i.ProductId == request.ProductId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
var newAllocation = 0;
var newReservation = 0;
if (oldInventory is not null)
{
newAllocation = oldInventory.TotalAllocated;
newReservation = oldInventory.TotalReserved + request.Reservation;
}
else
{
newAllocation = 0;
newReservation = request.Reservation;
}
if (newAllocation - newReservation < 0)
return Result.Fail<long>("Allocation failure: The requested book quantity exceeds current physical inventory availability.");
var inventory = context.Inventories.Add(new Entities.ProductInventory
{
CreatedAt = DateTime.UtcNow,
ProductId = request.ProductId,
ProductPriceId = request.ProductPriceId,
Status = InventoryStatuses.Reserved,
TotalAllocated = newAllocation,
TotalReserved = newReservation,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(inventory.Entity.Id)
: Result.Fail<long>("Failed to create inventory entry");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<long>> AllocateProductInventoryAsync(AllocateStock request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var oldInventory = await context.Inventories
.AsNoTracking()
.Where(i => i.ProductPriceId == request.ProductPriceId && i.ProductId == request.ProductId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
var newAllocation = 0;
var newReservation = 0;
if (oldInventory is not null)
{
newAllocation = oldInventory.TotalAllocated + request.Allocation;
newReservation = oldInventory.TotalReserved;
}
else
{
newAllocation = request.Allocation;
newReservation = 0;
}
var inventory = context.Inventories.Add(new Entities.ProductInventory
{
CreatedAt = DateTime.UtcNow,
ProductId = request.ProductId,
ProductPriceId = request.ProductPriceId,
Status = InventoryStatuses.Adjustment,
TotalAllocated = newAllocation,
TotalReserved = newReservation,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(inventory.Entity.Id)
: Result.Fail<long>("Failed to create inventory entry");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> AddProductCategoryAsync(long productId, long categoryId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Products.AnyAsync(p => p.Id == productId && p.Enabled, cancellationToken))
return Result.Fail("Product does not exist");
if (!await context.Categories.AnyAsync(c => c.Id == categoryId && c.Enabled, cancellationToken))
return Result.Fail("Category does not exist");
if (await context.ProductCategories.AnyAsync(c => c.ProductId == productId && c.CategoryId == categoryId, cancellationToken))
return Result.Fail("Category already assigned to product");
context.ProductCategories.Add(new Entities.ProductCategory
{
ProductId = productId,
CategoryId = categoryId,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Could not add category to product");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Category[]>> GetProductCategoriesAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var categories = await context.ProductCategories.AsNoTracking()
.Where(p => p.ProductId == productId)
.OrderByDescending(o => o.Id)
.Select(p => p.Category)
.ToArrayAsync(cancellationToken);
return categories?.Length > 0
? Result.Ok(categories.Select(c => c!.ToModel()).ToArray())
: Result.Fail<Category[]>("Failed to get product categories");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> DeleteProductCategoryAsync(long productId, long categoryId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rowsDeleted = await context.ProductCategories
.Where(p => p.ProductId == productId && p.CategoryId == categoryId)
.ExecuteDeleteAsync(cancellationToken);
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("No product categories were deleted");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> DeleteAllProductCategoriesAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rowsDeleted = await context.ProductCategories
.Where(p => p.ProductId == productId)
.ExecuteDeleteAsync(cancellationToken);
return rowsDeleted > 0
? Result.Ok()
: Result.Fail("No product categories were deleted");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> UpdateProductPriceStatusAsync(long productPriceId, bool isEnabled, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var productPrice = await context.Prices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken);
var rowsUpdated = await context.Prices
.Where(p => p.Id == productPriceId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Enabled, isEnabled)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (productPrice is null)
return Result.Fail(new Error($"Product price with ID {productPriceId} not found"));
productPrice.UpdatedAt = DateTime.UtcNow;
productPrice.Enabled = isEnabled;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to change status of product price with ID {productPriceId}"));
: Result.Fail(new Error($"Product price with ID {productPriceId} not found"));
}
catch (Exception ex)
{
@@ -38,17 +249,15 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
var rowsUpdated = await context.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Enabled, isEnabled)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
if (product is null)
return Result.Fail(new Error($"Product with ID {productId} not found"));
product.UpdatedAt = DateTime.UtcNow;
product.Enabled = isEnabled;
return await context.SaveChangesAsync(cancellationToken) > 0
return rowsUpdated > 0
? Result.Ok()
: Result.Fail(new Error($"Failed to change status of product with ID {productId}"));
: Result.Fail(new Error($"Product with ID {productId} not found"));
}
catch (Exception ex)
{
@@ -64,17 +273,19 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
var query = context.Products.AsQueryable();
if (!string.IsNullOrWhiteSpace(filter.Title))
query = query.Where(p => p.Name!.Contains(filter.Title));
var cultureInfo = CultureInfo.InvariantCulture;
if (!string.IsNullOrWhiteSpace(filter.Category))
query = query.Where(p => p.Categories!.Any(c => c == filter.Category));
if (!string.IsNullOrWhiteSpace(filter.Name))
query = query.Where(p => EF.Functions.ILike(p.Name!, $"%{filter.Name}%"));
if (!string.IsNullOrWhiteSpace(filter.Title))
query = query.Where(p => EF.Functions.ILike(p.Name!, $"%{filter.Title}%"));
if (!string.IsNullOrWhiteSpace(filter.Manufacturer))
query = query.Where(p => p.Metadata!.Manufacturer == filter.Manufacturer);
query = query.Where(p => EF.Functions.ILike(p.Metadata!.Manufacturer!, $"%{filter.Manufacturer}%"));
if (!string.IsNullOrWhiteSpace(filter.SerialNumber))
query = query.Where(p => p.Metadata!.SerialNumber == filter.SerialNumber);
query = query.Where(p => EF.Functions.ILike(p.Metadata!.SerialNumber!, $"%{filter.SerialNumber}%"));
if (filter.MinPrice > 0)
query = query.Where(p => p.Prices!.Any(pr => pr.Amount >= filter.MinPrice && pr.Amount <= filter.MaxPrice));
@@ -151,7 +362,6 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
ImageUrl = request.ImageUrl,
ThumbnailUrls = request.ThumbnailUrls,
Metadata = request.Metadata,
Categories = request.Categories,
Enabled = true
});
@@ -165,7 +375,7 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
}
}
public async ValueTask<Result<ProductPrice[]>> GetProductPriceAsync(long productId, CancellationToken cancellationToken = default)
public async ValueTask<Result<ProductPrice>> GetProductPriceAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
@@ -175,16 +385,16 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
.AsNoTracking()
.OrderByDescending(p => p.CreatedAt)
.ThenBy(p => p.UpdatedAt)
.FirstOrDefaultAsync(p => p.ProductId == productId, cancellationToken);
.FirstOrDefaultAsync(p => p.ProductId == productId && p.Enabled, cancellationToken);
return product is not null
? Result.Ok(new[] { product.ToModel() })
: Result.Fail<ProductPrice[]>(new Error($"No price found for product with ID {productId}"));
? Result.Ok(product.ToModel())
: Result.Fail<ProductPrice>(new Error($"No price found for product with ID {productId}"));
}
catch (Exception ex)
{
return Result.Fail<ProductPrice[]>(new Error(ex.Message).CausedBy(ex));
return Result.Fail<ProductPrice>(new Error(ex.Message).CausedBy(ex));
}
}
@@ -212,7 +422,8 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var product = await context.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
var product = await context.Products
.AsNoTracking().FirstOrDefaultAsync(p => p.Id == productId, cancellationToken);
return product is null
? Result.Fail<Product>(new Error($"Product with ID {productId} not found."))
@@ -228,8 +439,8 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
{
try
{
var fromDate = range.From.ToDateTime(TimeOnly.MinValue);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue);
var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -244,7 +455,7 @@ public sealed class ProductService(IDbContextFactory<MidrandBooksDbContext> cont
.AsSplitQuery()
.ToArrayAsync(cancellationToken);
return await context.SaveChangesAsync(cancellationToken) > 0
return products?.Length > 0
? Result.Ok(products.Select(p => p.ToModel()).ToArray())
: Result.Fail<Product[]>(new Error("Failed to retrieve products."));
}
@@ -17,7 +17,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
@@ -1,9 +1,8 @@
using LiteCharms.Features.Extensions;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models;
using LiteCharms.Features.TechShop.Extensions;
using LiteCharms.Features.TechShop.Leads.Models;
using LiteCharms.Features.TechShop.Postgres;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.TechShop.Leads;
@@ -29,7 +28,7 @@ public class LeadService(IDbContextFactory<ShopDbContext> contextFactory)
FeedItemId = request.FeedItemId,
Status = LeadStatus.New,
TargetId = request.TargetId,
AttributionHash = StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}")
AttributionHash = HashService.StringToSha256Hash($"{request.ClickId}{request.AppClickId}{request.WebClickId}")
});
return await context.SaveChangesAsync(cancellationToken) > 0
@@ -36,7 +36,7 @@
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
<PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -50,11 +50,11 @@
<!-- Configuration -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" />
@@ -67,9 +67,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -78,12 +78,12 @@
<!-- Open Telemetry -->
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" />
@@ -95,17 +95,17 @@
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- Global Usings -->
<Using Include="Npgsql" />
@@ -116,8 +116,8 @@
<!-- Email -->
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="MimeKit" Version="4.16.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.17.0" />
<!-- Global Usings-->
<Using Include="MimeKit" />
@@ -136,8 +136,8 @@
<!-- Amazon S3 SDK -->
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" />
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.6" />
<PackageReference Include="AWSSDK.S3" Version="4.0.24.3" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings -->

Some files were not shown because too many files have changed in this diff Show More