Compare commits

...

164 Commits

Author SHA1 Message Date
1822f074c6 fix: update phone number change instructions in help/support FAQs for clarity and localization 2026-02-01 14:24:35 +03:00
db946b9531 fix build error 2026-01-25 14:10:46 +03:00
25ede3c9e7 feat(lookup): add localized help/support FAQs 2026-01-25 13:15:59 +03:00
47b825c4b2 Merge pull request #89 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card service and transaction notification for shared ac…
2026-01-20 16:28:42 +03:00
f5c3b03264 feat: enhance card service and transaction notification for shared account handling
- Updated CardService to differentiate between shared and separate accounts during card control updates, optimizing balance allocation.
- Enhanced TransactionNotificationListener to accurately reflect balance based on account structure for internal transfers and top-ups.
- Improved logging for better traceability of account operations and balance calculations.
2026-01-20 16:27:32 +03:00
6a250efd5e Merge pull request #88 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification listener for internal transfer…
2026-01-20 15:21:45 +03:00
a09b84e475 feat: enhance transaction notification listener for internal transfer support
- Updated TransactionNotificationListener to differentiate between internal transfers and external top-ups for child accounts.
- Added new notification scopes and messages for internal transfers from parent to child.
- Improved balance retrieval logic to ensure accurate account balances are displayed in notifications.
- Enhanced localization support by adding relevant keys for internal transfer notifications in both English and Arabic.
2026-01-20 15:20:58 +03:00
604cb7ce25 Merge pull request #87 from Zod-Alkhair/feature/notification-system-fcm-registration
fix the messages
2026-01-20 14:42:03 +03:00
4305c4b75f fix the messages 2026-01-20 14:40:16 +03:00
ef5572440c Merge pull request #86 from Zod-Alkhair/fix/spending-history-junior-id-query
fix: correct junior ID to customer ID mapping in transaction queries
2026-01-20 12:50:34 +03:00
64623c7cea fix: correct junior ID to customer ID mapping in transaction queries
Fixed spending history and related transaction queries that were incorrectly
using juniorId as customerId. The queries now properly join through the
Customer -> Junior relationship to filter by junior ID.

Affected methods:
- getTransactionsForCardWithinDateRange (spending history)
- findTransfersToJunior (transfers list)
- countTransfersToJunior (transfers count)
- findTransactionById (transaction details)

This fixes the spending history endpoint which was returning empty results
due to ID mismatch between Junior entity ID and Customer entity ID.

Performance impact: Minimal (~1-2ms overhead from additional joins on
indexed foreign keys). The queries now return correct results instead of
0 results.
2026-01-20 12:39:10 +03:00
4ca8123a67 Merge pull request #85 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: refine transaction notification listener for improved balance c…
2026-01-14 16:57:36 +03:00
7c9e0f0b51 feat: refine transaction notification listener for improved balance calculations
- Updated TransactionNotificationListener to differentiate between child top-up and spending notifications, ensuring accurate balance retrieval.
- Enhanced error handling and fallback mechanisms for both child and parent account balance fetching.
- Improved logging to provide detailed insights into balance calculations, including available balance after accounting for reserved amounts.
2026-01-14 16:56:22 +03:00
e734060c52 Merge pull request #84 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: improve transaction notification listener for accurate balance …
2026-01-14 16:33:05 +03:00
1086166e04 feat: improve transaction notification listener for accurate balance retrieval
- Enhanced TransactionNotificationListener to fetch updated balances for child and parent accounts by bypassing entity cache.
- Implemented error handling and fallback mechanisms to ensure reliable balance notifications.
- Updated logging for better traceability of balance fetching processes.
2026-01-14 16:31:47 +03:00
5e6c8d96de Merge pull request #83 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: add timezone support to user and device entities
2026-01-14 16:12:59 +03:00
6d6dc1471f feat: add timezone support to user and device entities
- Introduced optional timezone fields in User and Device entities to store user preferences and device timezones.
- Updated request DTOs for login and user updates to include timezone information.
- Enhanced AuthService to handle timezone during device registration and updates.
- Added migration to incorporate timezone fields in the database schema.
2026-01-14 16:12:08 +03:00
0f56381703 Merge pull request #82 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card and transaction services for balance updates
2026-01-14 14:52:57 +03:00
1b0d6cb284 feat: enhance card and transaction services for balance updates
- Added functionality to credit child account balance and decrease parent account balance in CardService.
- Updated TransactionService to reload card details for accurate balance after transactions.
- Improved TransactionNotificationListener to fetch updated balances for both child and parent accounts, ensuring accurate notifications.
2026-01-14 14:51:45 +03:00
887bd20217 Merge pull request #81 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card service validation and notification integration
2026-01-14 13:27:56 +03:00
c963b57904 feat: enhance card service validation and notification integration
- Added validation for card reference and limit in CardService to ensure data integrity.
- Improved error handling with detailed logging for invalid card states.
- Updated transaction notification listener to fetch parent account details and adjust balance notifications accordingly.
- Enhanced notification creation process to include status management for better tracking.
2026-01-14 13:27:02 +03:00
2e21acac7f Merge pull request #80 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: implement KYC and card notification events
2026-01-14 12:57:11 +03:00
145e6c62b8 feat: implement KYC and card notification events
- Added KycNotificationListener to handle notifications for KYC approval and rejection events.
- Introduced CardNotificationListener to manage notifications for card creation and blocking events.
- Enhanced CardService to emit events for card creation and blocking, integrating with the new notification system.
- Updated notification constants and interfaces to include new KYC and card-related events.
- Improved notification message formatting and added localization support for new events.
2026-01-14 12:31:48 +03:00
652359b1bf Merge pull request #79 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification logging and error handling
2026-01-12 16:48:13 +03:00
45acf73a4a feat: enhance transaction notification logging and error handling
- Added console logging for emitted transaction creation events in TransactionService.
- Improved error handling in TransactionNotificationListener for i18n translation failures, providing fallback messages.
- Updated amount parsing in MoneyRequestNotificationListener to ensure consistent handling of string and numeric values.
2026-01-12 16:47:28 +03:00
2d6524be9f Merge pull request #78 from Zod-Alkhair/feature/notification-system-fcm-registration
refactor: standardize notification message formatting
2026-01-12 16:30:32 +03:00
d3ff755439 refactor: standardize notification message formatting
- Updated notification message arguments to use consistent object syntax for better readability.
- Modified Arabic and English translation files to reflect the new argument format in notification messages.
2026-01-12 16:28:26 +03:00
3ab00dfc29 Merge pull request #76 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: implement money request notification system
2026-01-12 16:15:19 +03:00
21653efc46 feat: implement money request notification system
- Added MoneyRequestNotificationListener to handle notifications for money request events (created, approved, declined).
- Introduced new notification event constants for money requests.
- Updated notification interfaces to include money request event payloads.
- Enhanced existing notification system to support money request notifications, notifying parents and children appropriately.
- Updated device service to support finding devices by ID for improved functionality.
2026-01-12 16:07:48 +03:00
11b2b25adc Merge pull request #75 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance Redis module exports for pub/sub functionality
2026-01-11 14:40:12 +03:00
63b0a42eca feat: enhance Redis module exports for pub/sub functionality
- Added 'REDIS_PUBLISHER' and 'REDIS_SUBSCRIBER' to the exports of RedisModule to improve pub/sub capabilities.
2026-01-11 14:38:59 +03:00
ed8cf4b4f8 Merge pull request #74 from Zod-Alkhair/feature/notification-system-fcm-registration
Feature/notification system fcm registration
2026-01-11 14:31:58 +03:00
b1cda5e7dc feat: Complete Phase 2 notification system implementation
- Implement messaging system factory pattern
- Fix all transaction notification blockers
- Complete listener logic for all notification types
2026-01-11 11:17:08 +03:00
2c8de913f8 refactor: update notification titles and enhance notification creation process
- Simplified notification titles by removing emojis for better clarity.
- Modified createNotification method to include automatic publishing to Redis, improving notification delivery.
- Updated email and OTP notification methods to leverage the new createNotification functionality.
2026-01-06 16:22:21 +03:00
98f6aaf01f Merge pull request #73 from Zod-Alkhair/feature/notification-system-fcm-registration
add eveint lestiner to the parent
2026-01-06 14:52:52 +03:00
170aa903c7 add eveint lestiner to the parent 2026-01-06 14:51:44 +03:00
16f8756b74 Merge pull request #72 from Zod-Alkhair/feature/notification-system-fcm-registration
merge conflect
2026-01-06 12:58:49 +03:00
2f74aa36a9 merge conflect 2026-01-06 12:57:13 +03:00
f849003142 Merge pull request #71 from Zod-Alkhair/feature/notification-system-fcm-registration
Feature/notification system fcm registration
2026-01-06 12:54:45 +03:00
2562515574 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into feature/notification-system-fcm-registration 2026-01-06 12:53:44 +03:00
93b509b256 feat: add notification event handling and notification factory service
- Introduce constants for notification event names
- Implement interfaces for transaction created events
- Create a transaction notification listener to handle transaction notifications
- Develop a notification factory service for sending notifications based on user preferences
- Add a migration to include a data column in the notifications table
2026-01-06 12:38:19 +03:00
9c93a35093 feat: implement notification system with FCM token registration
- Add FCM token registration during login/signup
- Implement transaction notification listeners
- Add notification data column to database
- Update Firebase service with data payload support
- Add transaction notification scopes
- Update card repository to load relations for notifications
2026-01-06 12:29:01 +03:00
d77d59a793 Merge pull request #70 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-18 14:26:27 +03:00
110a6fb0ee refactor: remove address fields from customer entity and related services
- Removed address-related fields from Customer entity, DTOs, and services to streamline KYC process.
- Updated KYC initiation and customer update logic to default to Saudi Arabia for country and use fixed address values.
- Added migration to drop address columns from the database.
2025-12-18 12:35:32 +03:00
83787c7c67 Merge pull request #69 from Zod-Alkhair/feature/kyc-onboarding
feat: enhance KYC process with external customer ID validation
2025-12-17 12:56:32 +03:00
24bcb10d76 feat: enhance KYC process with external customer ID validation
- Added validation to ensure customer has a neoleapExternalCustomerId before card creation.
- Updated KYC status update to include neoleapExternalCustomerId in the customer record.
- Enhanced application info to include ExternalCorporateId for better integration with Neoleap.
2025-12-17 12:51:20 +03:00
a3cdf50cb7 Merge pull request #68 from Zod-Alkhair/feature/kyc-onboarding
refactor: remove obsolete customer fields and update migration
2025-12-16 16:42:30 +03:00
cfd02e8c30 refactor: remove obsolete customer fields and update migration
- Removed unused fields: sourceOfIncome, profession, and professionType from Customer entity and DTOs.
- Updated KYC callback mock to reflect the removal of professionType.
- Added migration to drop the corresponding columns from the database.
2025-12-16 16:40:13 +03:00
0fb76d712d Merge pull request #67 from Zod-Alkhair/feature/kyc-onboarding
Feature/kyc onboarding
2025-12-16 14:59:28 +03:00
5e708c16fe chore: remove migration from wrong directory
Migration already exists in correct location: src/db/migrations/
2025-12-16 14:57:03 +03:00
fe11f35b32 feat: send the adress data to noleap 2025-12-16 14:51:21 +03:00
3200f60821 feat: Complete KYC implementation with address fields
- Added address fields to registration (verify-user DTO)
- Added address fields to KYC initiation (initiate-kyc DTO)
- Added national_id column to kyc_transactions table
- Changed duplicate KYC check from customerId to nationalId
- Added KYC webhook endpoint (/api/neoleap-webhooks/kyc)
- Added webhook processing logic
- Updated customer service to save address during registration and KYC
- Added validation to require address before card creation
- Removed duplicate src/migrations/ directory
2025-12-16 14:44:07 +03:00
24521c4223 Merge pull request #66 from Zod-Alkhair/chore/remove-email-dob-from-signup
chore: remove email and dob from guardian signup flow
2025-12-11 12:15:54 +03:00
e8127970f6 chore: remove email and dob from guardian signup flow 2025-12-11 12:13:51 +03:00
07d4a83cf9 test ssh 2025-12-07 20:18:59 +03:00
ce1f6341b7 Merge pull request #65 from HamzaSha1/dev
Dev to main
2025-11-26 09:58:33 +03:00
2a62787c3b Merge pull request #63 from HamzaSha1/feature/kyc-onboarding-metadata
refactor: remove unused PoiValidationRule class from KycMetadataRespo
2025-11-18 15:16:33 +03:00
91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
ef28c75f9b Merge pull request #62 from HamzaSha1/feature/kyc-onboarding-metadata
feat: add KYC onboarding metadata endpoint with POI validation
2025-11-18 15:06:50 +03:00
c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
d2d83549b2 Merge pull request #61 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Enhance profile picture handling in JuniorService to ensure foreign
2025-11-09 12:43:54 +03:00
506974afc8 Enhance profile picture handling in JuniorService to ensure foreign key consistency and validate document ownership before assignment. 2025-11-09 12:42:48 +03:00
95f8cfbfdf Merge pull request #60 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Update return value in updateJunior method to fetch updated junior dtails by ID instead of returning the junior object directly.
2025-11-09 12:26:44 +03:00
8b00cda23d Update return value in updateJunior method to fetch updated junior details by ID instead of returning the junior object directly. 2025-11-09 12:25:37 +03:00
12cc88a50e Merge pull request #59 from HamzaSha1/money-request-to-use-the-parint-account
Refactor balance check in increaseReservedBalance method to delegate …
2025-11-02 12:41:51 +03:00
2172051093 Refactor balance check in increaseReservedBalance method to delegate validation to the caller, improving clarity and responsibility separation. 2025-11-02 12:41:16 +03:00
a6a573957c Merge pull request #58 from HamzaSha1/money-request-to-use-the-parint-account
add more loggs
2025-11-02 12:35:31 +03:00
d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
b0011eb7cc Merge pull request #57 from HamzaSha1/money-request-to-use-the-parint-account
Money request to use the parint account
2025-11-02 12:07:13 +03:00
99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
0c9b40132a Merge pull request #56 from HamzaSha1/ZOD-344-after-a-child-completes-registration-using-the-qr-code-the-same-qr-code-remains-valid-and-allows-the-child-to-register-again-instead-of-expiring
ZOD-344-Add QR code validation error handling and localization support
2025-11-02 11:02:25 +03:00
3b295ea79f ZOD-344-Add QR code validation error handling and localization support
- Introduced new error handling for already used or expired QR codes in JuniorService.
- Added corresponding localization entries in Arabic and English app.json files for QR code validation messages.
2025-11-02 10:52:43 +03:00
5ffe18ede3 Merge pull request #54 from HamzaSha1/fix/verfy-email
Implement OTP generation and email verification logic in UserService
2025-10-28 16:17:51 +03:00
a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
39d5fc1869 Merge pull request #52 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
Enhance weekly summary functionality to accept optional date range pa…
2025-10-28 11:22:52 +03:00
05a6ad2d84 Enhance weekly summary functionality to accept optional date range parameters in CardService, TransactionService, JuniorService, and JuniorController. Update API documentation to reflect new query parameters for start and end dates. 2025-10-28 11:20:49 +03:00
5649d24724 Merge pull request #50 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
2025-10-26 16:05:00 +03:00
bbeece9e03 git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view 2025-10-26 13:14:35 +03:00
596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
2714255dd1 Merge pull request #45 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341 Add email uniqueness validation to prevent duplicate emails
2025-10-20 14:31:11 +03:00
39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
fdd2e23669 Merge pull request #39 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instea
2025-10-19 10:47:51 +03:00
d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
297a2fe5ad ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code 2025-10-19 09:57:35 +03:00
33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
310233c519 Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers
ZOD-309-child-transaction-history-parent-→-child-transfers
2025-10-16 12:26:50 +03:00
15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
7fc1918de0 Merge pull request #35 from HamzaSha1/feat/parent-topups-and-child-transfers
feat: add guardian transactions feature with response DTOs and service integration
2025-10-15 14:17:08 +03:00
f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
dd6886ff2b Merge pull request #34 from HamzaSha1/feat/neoleap-integration
match the neoleap-integration branch with dev
2025-10-14 12:20:19 +03:00
649191f3f4 Merge pull request #33 from HamzaSha1/fix/customer-gender-missing-in-get-profile
fix: add gender property to UserResponseDto
2025-10-14 12:14:01 +03:00
183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
8f601b26ae fix: add gender property to UserResponseDto 2025-10-12 16:03:25 +03:00
918b15c315 fix: add swagger 2025-09-23 09:00:41 +03:00
1830d92cbd feat: weekly stats for junior 2025-09-23 08:56:57 +03:00
44124b9964 Merge branch 'dev' of github.com:HamzaSha0/zod-backend into dev 2025-09-18 10:03:47 +03:00
454ded627f fix: fix transfer to child bug 2025-09-16 21:01:32 +03:00
f1484e125b feat: soft delete junior 2025-09-15 09:02:56 +03:00
df4d2e3c1f feat: get card by child id 2025-09-15 08:47:56 +03:00
872d231f72 feat: show embossing information for child cards 2025-09-09 21:45:37 +03:00
cc4c8254f6 feat: view child active cards 2025-09-09 21:37:55 +03:00
039c95aa56 fix: calculating child and parent balance 2025-09-09 20:31:48 +03:00
e1f50decfa feat: money requests 2025-09-08 21:38:11 +03:00
11712bedf3 Merge pull request #31 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-09-08 21:34:46 +03:00
e6642b5a15 fix: fix controller name 2025-09-07 21:47:17 +03:00
954aa422a2 fix: fix mock request for funding decorators 2025-09-07 21:40:55 +03:00
15a48e4884 fix: validate card spending limit before transfering to child 2025-09-07 21:18:49 +03:00
d768da70f2 feat: transfer to parent 2025-09-07 20:23:11 +03:00
9b0e1791da feat: transfer money to child 2025-09-07 20:14:28 +03:00
44b5937f7a feat: finalize update junior 2025-09-07 18:13:28 +03:00
edddc2f457 feat: update junior 2025-09-07 09:12:14 +03:00
88730a2b2b fix: fix create application mock 2025-08-26 19:17:00 +03:00
3df34c0017 fix: fix duplicate iban 2025-08-26 12:19:47 +03:00
7dd309e0e3 fix: fix null assertion in creating junior 2025-08-24 20:14:59 +03:00
4552a7fc93 fix: fix issue with customer relationship in creating junior 2025-08-24 20:12:57 +03:00
740135051d feat: create card for children 2025-08-24 20:01:07 +03:00
3222aa4a66 fix: fix card embossing info endpoint 2025-08-24 18:40:49 +03:00
6602414779 feat: finialize creating juniors 2025-08-23 21:52:59 +03:00
7291447c5a Merge branch 'dev' into feat/neoleap-integration 2025-08-23 20:12:48 +03:00
d437b21dc3 fix: make kyc run on mocks 2025-08-23 19:30:29 +03:00
e06642225a feat: working on creating parent card 2025-08-14 14:40:08 +03:00
c06086f899 Merge pull request #30 from HamzaSha1/dev
Dev
2025-08-11 18:22:34 +03:00
e775561a89 Merge pull request #29 from HamzaSha1/main
Merge main into dev
2025-08-11 18:21:52 +03:00
241f1ce427 Merge branch 'dev' into main 2025-08-11 18:21:43 +03:00
d883bd2d9a Update junior.repository.ts 2025-08-11 18:19:57 +03:00
cd800ff8b8 Update customer.repository.ts 2025-08-11 18:18:48 +03:00
05a9f04ac8 fix: fix import in migration index 2025-08-11 16:23:19 +03:00
dcc9077392 fix: rename migration timestamp 2025-08-11 16:22:20 +03:00
681d1e5791 fix: fix seed default avatar migration 2025-08-11 16:16:26 +03:00
bf505a65bf fix: fix invalid imports 2025-08-11 16:13:53 +03:00
6bf32d27c7 Merge branch 'dev' of github.com:HamzaSha1/zod-backend into dev 2025-08-11 15:33:39 +03:00
ac63d4cdc7 refactor: refactor the code 2025-08-11 15:33:32 +03:00
150027fb71 Merge branch 'main' into dev 2025-08-11 15:25:16 +03:00
e8ee74d0d7 refactor: remove unsed code 2025-08-11 15:15:41 +03:00
5f2e06edf9 Merge pull request #28 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-08-10 16:16:32 +03:00
99ad17f0f9 feat: add change password api 2025-08-07 15:25:45 +03:00
ee7b365527 feat: kyc process 2025-08-07 14:23:33 +03:00
275984954e feat: working on edit profile ticket 2025-08-05 17:53:38 +03:00
6f7fb2bdcd Merge pull request #27 from HamzaSha1/feat/neoleap-integration
Merge neoleap-integration into dev
2025-08-03 16:24:46 +03:00
1e2b859b92 feat: finish generating signed url for document upload flow 2025-08-03 16:18:06 +03:00
4cc52a1c07 fix: add swagger doc to verify otp api 2025-08-03 14:50:04 +03:00
7461af20dd feat: edit forget password flow 2025-08-03 14:48:14 +03:00
f65a7d2933 feat: generate upload signed url for oci 2025-08-03 14:21:14 +03:00
fce720237f feat: add vpan to card entity 2025-08-03 11:53:16 +03:00
5e0a4e6bd1 feat: fix update card status webhook 2025-07-31 14:42:06 +03:00
f9776e60cf fix: save transaction file 2025-07-31 14:11:53 +03:00
7e63abb2fb feat: add-account-details 2025-07-31 14:07:01 +03:00
a245545811 feat: add login and forget password and refactor code 2025-07-30 15:40:40 +03:00
4cb5814cd3 fix: organize migrations 2025-07-30 14:18:10 +03:00
9e06ea4d71 Merge branch 'dev' into feat/neoleap-integration 2025-07-30 14:09:00 +03:00
cff87c4ecd rollup migrations into one 2025-07-30 13:08:31 +03:00
1541c374ed feat: fix swagger examples 2025-07-27 13:26:21 +03:00
c493bd57e1 feat: onboarding signup journey 2025-07-27 13:15:54 +03:00
421 changed files with 15105 additions and 16549 deletions

24
client/.gitignore vendored
View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,50 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

View File

@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4115
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +0,0 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.3.1",
"@mui/material": "^6.3.1",
"@react-oauth/google": "^0.12.1",
"axios": "^1.7.9",
"react": "^18.3.1",
"react-apple-signin-auth": "^1.1.0",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,127 +0,0 @@
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { LoginForm } from './components/auth/LoginForm';
import { RegisterForm } from './components/auth/RegisterForm';
import { Dashboard } from './components/dashboard/Dashboard';
import { AddJuniorForm } from './components/juniors/AddJuniorForm';
import { JuniorsList } from './components/juniors/JuniorsList';
import { AuthLayout } from './components/layout/AuthLayout';
import { AddTaskForm } from './components/tasks/AddTask';
import { TaskDetails } from './components/tasks/TaskDetails';
import { TasksList } from './components/tasks/TasksList';
import { AuthProvider } from './contexts/AuthContext';
// Create theme
const theme = createTheme({
palette: {
primary: {
main: '#00A7E1', // Bright blue like Zod Wallet
light: '#33B7E7',
dark: '#0074B2',
},
secondary: {
main: '#FF6B6B', // Coral red for accents
light: '#FF8E8E',
dark: '#FF4848',
},
background: {
default: '#F8F9FA',
paper: '#FFFFFF',
},
text: {
primary: '#2D3748', // Dark gray for main text
secondary: '#718096', // Medium gray for secondary text
},
},
typography: {
fontFamily: '"Inter", "Helvetica", "Arial", sans-serif',
h1: {
fontWeight: 700,
fontSize: '2.5rem',
},
h2: {
fontWeight: 600,
fontSize: '2rem',
},
h3: {
fontWeight: 600,
fontSize: '1.75rem',
},
h4: {
fontWeight: 600,
fontSize: '1.5rem',
},
h5: {
fontWeight: 600,
fontSize: '1.25rem',
},
h6: {
fontWeight: 600,
fontSize: '1rem',
},
button: {
textTransform: 'none',
fontWeight: 500,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: '8px',
padding: '8px 16px',
fontWeight: 500,
},
contained: {
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '16px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
},
},
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
{/* Protected routes */}
<Route element={<AuthLayout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/juniors" element={<JuniorsList />} />
<Route path="/juniors/new" element={<AddJuniorForm />} />
<Route path="/tasks" element={<TasksList />} />
<Route path="/tasks/new" element={<AddTaskForm />} />
<Route path="/tasks/:taskId" element={<TaskDetails />} />
</Route>
{/* Redirect root to dashboard or login */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

View File

@ -1,140 +0,0 @@
import axios from 'axios';
import { LoginRequest } from '../types/auth';
import { CreateJuniorRequest, JuniorTheme } from '../types/junior';
import { CreateTaskRequest, TaskStatus, TaskSubmission } from '../types/task';
const API_BASE_URL = 'https://zod.life';
const AUTH_TOKEN = btoa('zod-digital:Zod2025'); // Base64 encode credentials
// Helper function to get auth header
const getAuthHeader = () => {
const token = localStorage.getItem('accessToken');
return token ? `Bearer ${token}` : `Basic ${AUTH_TOKEN}`;
};
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
'x-client-id': 'web-client',
},
});
// Add request interceptor to include current auth header
apiClient.interceptors.request.use((config) => {
config.headers.Authorization = getAuthHeader();
return config;
});
// Add response interceptor to handle errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const errorMessage =
error.response?.data?.message || error.response?.data?.error || error.message || 'An unexpected error occurred';
console.error('API Error:', {
status: error.response?.status,
message: errorMessage,
data: error.response?.data,
});
// Throw error with meaningful message
throw new Error(errorMessage);
},
);
// Auth API
export const authApi = {
register: (countryCode: string, phoneNumber: string) => {
// Ensure phone number is in the correct format (remove any non-digit characters)
const cleanPhoneNumber = phoneNumber.replace(/\D/g, '');
return apiClient.post('/api/auth/register/otp', {
countryCode: countryCode.startsWith('+') ? countryCode : `+${countryCode}`,
phoneNumber: cleanPhoneNumber,
});
},
verifyOtp: (countryCode: string, phoneNumber: string, otp: string) =>
apiClient.post('/api/auth/register/verify', { countryCode, phoneNumber, otp }),
setEmail: (email: string) => {
// Use the stored token from localStorage
const storedToken = localStorage.getItem('accessToken');
if (!storedToken) {
throw new Error('No access token found');
}
return apiClient.post('/api/auth/register/set-email', { email });
},
setPasscode: (passcode: string) => {
// Use the stored token from localStorage
const storedToken = localStorage.getItem('accessToken');
if (!storedToken) {
throw new Error('No access token found');
}
return apiClient.post('/api/auth/register/set-passcode', { passcode });
},
login: ({ grantType, email, password, appleToken, googleToken }: LoginRequest) =>
apiClient.post('/api/auth/login', {
grantType,
email,
password,
appleToken,
googleToken,
fcmToken: 'web-client-token', // Required by API
signature: 'web-login', // Required by API
}),
};
// Juniors API
export const juniorsApi = {
createJunior: (data: CreateJuniorRequest) => apiClient.post('/api/juniors', data),
getJuniors: (page = 1, size = 10) => apiClient.get(`/api/juniors?page=${page}&size=${size}`),
getJunior: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}`),
setTheme: (data: JuniorTheme) => apiClient.post('/api/juniors/set-theme', data),
getQrCode: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}/qr-code`),
validateQrCode: (token: string) => apiClient.get(`/api/juniors/qr-code/${token}/validate`),
};
// Document API
export const documentApi = {
upload: (file: File, documentType: string) => {
const formData = new FormData();
formData.append('document', file);
formData.append('documentType', documentType);
return apiClient.post('/api/document', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
},
};
// Tasks API
export const tasksApi = {
createTask: (data: CreateTaskRequest) => apiClient.post('/api/tasks', data),
getTasks: (status: TaskStatus, page = 1, size = 10, juniorId?: string) => {
const url = new URL('/api/tasks', API_BASE_URL);
url.searchParams.append('status', status);
url.searchParams.append('page', page.toString());
url.searchParams.append('size', size.toString());
if (juniorId) url.searchParams.append('juniorId', juniorId);
return apiClient.get(url.pathname + url.search);
},
getTaskById: (taskId: string) => apiClient.get(`/api/tasks/${taskId}`),
submitTask: (taskId: string, data: TaskSubmission) => apiClient.patch(`/api/tasks/${taskId}/submit`, data),
approveTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/approve`),
rejectTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/reject`),
};

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,69 +0,0 @@
import AppleSignInButton from 'react-apple-signin-auth';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { GrantType } from '../../enums';
interface LoginProps {
setError: (error: string) => void;
setLoading: (loading: boolean) => void;
}
export const AppleLogin = ({ setError, setLoading }: LoginProps) => {
const { login } = useAuth();
const navigate = useNavigate();
const onError = (err: any) => {
setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.');
};
const onSuccess = async (response: any) => {
try {
setLoading(true);
await login({ grantType: GrantType.APPLE, appleToken: response.authorization.id_token });
navigate('/dashboard');
} catch (error) {
setError(error instanceof Error ? error.message : 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
return (
<AppleSignInButton
/** Auth options passed to AppleID.auth.init() */
authOptions={{
/** Client ID - eg: 'com.example.com' */
clientId: process?.env.REACT_APP_APPLE_CLIENT_ID!,
scope: 'email name',
/** Requested scopes, seperated by spaces - eg: 'email name' */
/** Apple's redirectURI - must be one of the URIs you added to the serviceID - the undocumented trick in apple docs is that you should call auth from a page that is listed as a redirectURI, localhost fails */
redirectURI: process?.env.REACT_APP_APPLE_REDIRECT_URI!,
state: 'default',
/** Uses popup auth instead of redirection */
usePopup: true,
}} // REQUIRED
/** General props */
uiType="dark"
/** className */
className="apple-auth-btn"
/** Removes default style tag */
noDefaultStyle={false}
/** Allows to change the button's children, eg: for changing the button text */
buttonExtraChildren="Continue with Apple"
/** Extra controlling props */
/** Called upon signin success in case authOptions.usePopup = true -- which means auth is handled client side */
onSuccess={(response: any) => {
onSuccess(response);
}} // default = undefined
/** Called upon signin error */
onError={(error: any) => onError(error)} // default = undefined
/** Skips loading the apple script if true */
skipScript={false} // default = undefined
/** Apple image props */
/** render function - called with all props - can be used to fully customize the UI by rendering your own component */
/>
);
};

View File

@ -1,40 +0,0 @@
import { GoogleLogin as GoogleApiLogin, GoogleOAuthProvider } from '@react-oauth/google';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { GrantType } from '../../enums';
interface LoginProps {
setError: (error: string) => void;
setLoading: (loading: boolean) => void;
}
export const GoogleLogin = ({ setError, setLoading }: LoginProps) => {
const { login } = useAuth();
const navigate = useNavigate();
const onError = (err: any) => {
setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.');
};
const onSuccess = async (response: any) => {
try {
setLoading(true);
await login({ grantType: GrantType.GOOGLE, googleToken: response.credential });
navigate('/dashboard');
} catch (error) {
setError(error instanceof Error ? error.message : 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
return (
<GoogleOAuthProvider clientId={process.env.GOOGLE_WEB_CLIENT_ID!}>
<GoogleApiLogin
onSuccess={(credentialResponse) => {
onSuccess(credentialResponse);
}}
onError={() => {
onError('Login failed. Please check your credentials.');
}}
/>
</GoogleOAuthProvider>
);
};

View File

@ -1,149 +0,0 @@
import { Alert, Box, Button, Container, Paper, TextField, Typography } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { GrantType } from '../../enums';
import { AppleLogin } from './AppleLogin';
import { GoogleLogin } from './GoogleLogin';
export const LoginForm = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login({ email: formData.email, password: formData.password, grantType: GrantType.PASSWORD });
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'background.default',
}}
>
<Container maxWidth="sm" sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700, color: 'primary.main' }}>
Zod Alkhair | API TEST
</Typography>
<Typography variant="h6" sx={{ color: 'text.secondary', mb: 4 }}>
login to your account.
</Typography>
</Box>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 3,
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.paper',
}}
>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<TextField
fullWidth
margin="normal"
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
required
autoFocus
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
<TextField
fullWidth
margin="normal"
label="Password"
name="password"
type="password"
value={formData.password}
onChange={handleInputChange}
required
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{
mt: 3,
mb: 2,
height: 48,
borderRadius: 2,
textTransform: 'none',
fontSize: '1rem',
}}
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
<Button
fullWidth
variant="text"
sx={{
textTransform: 'none',
fontSize: '1rem',
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
},
}}
onClick={() => navigate('/register')}
>
signup
</Button>
<AppleLogin setError={setError} setLoading={setLoading} />
<GoogleLogin setError={setError} setLoading={setLoading} />
</Box>
</Paper>
</Container>
</Box>
);
};

View File

@ -1,254 +0,0 @@
import { Alert, Box, Button, Container, Paper, Step, StepLabel, Stepper, TextField, Typography } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const steps = ['Phone Verification', 'Email', 'Set Passcode'];
export const RegisterForm = () => {
const { register, verifyOtp, setEmail, setPasscode } = useAuth();
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
countryCode: '+962',
phoneNumber: '',
otp: '',
email: '',
passcode: '',
confirmPasscode: '',
otpRequested: false
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
switch (activeStep) {
case 0:
if (!formData.otpRequested) {
// Request OTP
await register(formData.countryCode, formData.phoneNumber);
setFormData(prev => ({ ...prev, otpRequested: true }));
} else {
// Verify OTP
await verifyOtp(formData.countryCode, formData.phoneNumber, formData.otp);
setActiveStep(1);
}
break;
case 1:
await setEmail(formData.email);
setActiveStep(2);
break;
case 2:
if (formData.passcode !== formData.confirmPasscode) {
throw new Error('Passcodes do not match');
}
await setPasscode(formData.passcode);
navigate('/dashboard');
break;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const renderStepContent = () => {
switch (activeStep) {
case 0:
return (
<>
<TextField
fullWidth
margin="normal"
label="Phone Number"
name="phoneNumber"
value={formData.phoneNumber}
onChange={handleInputChange}
placeholder="7XXXXXXXX"
required
disabled={formData.otpRequested}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
{formData.otpRequested && (
<TextField
fullWidth
margin="normal"
label="OTP"
name="otp"
value={formData.otp}
onChange={handleInputChange}
placeholder="Enter OTP"
required
autoFocus
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
)}
</>
);
case 1:
return (
<TextField
fullWidth
margin="normal"
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
required
autoFocus
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
);
case 2:
return (
<>
<TextField
fullWidth
margin="normal"
label="Passcode"
name="passcode"
type="password"
value={formData.passcode}
onChange={handleInputChange}
required
autoFocus
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
<TextField
fullWidth
margin="normal"
label="Confirm Passcode"
name="confirmPasscode"
type="password"
value={formData.confirmPasscode}
onChange={handleInputChange}
required
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
}
}}
/>
</>
);
default:
return null;
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'background.default',
}}
>
<Container maxWidth="sm" sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700, color: 'primary.main' }}>
Zod Alkhair | API TEST
</Typography>
<Typography variant="h6" sx={{ color: 'text.secondary', mb: 4 }}>
signup
</Typography>
</Box>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 3,
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.paper'
}}
>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
{renderStepContent()}
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{
mt: 3,
mb: 2,
height: 48,
borderRadius: 2,
textTransform: 'none',
fontSize: '1rem'
}}
disabled={loading}
>
{loading ? 'Processing...' : activeStep === 0 && !formData.otpRequested ? 'Send OTP' : 'Continue'}
</Button>
<Button
fullWidth
variant="text"
sx={{
textTransform: 'none',
fontSize: '1rem',
color: 'text.secondary',
'&:hover': {
color: 'primary.main'
}
}}
onClick={() => navigate('/login')}
>
sign in
</Button>
</Box>
</Paper>
</Container>
</Box>
);
};

View File

@ -1,151 +0,0 @@
import {
People as PeopleIcon,
Assignment as TaskIcon,
TrendingUp as TrendingUpIcon,
AccountBalance as WalletIcon
} from '@mui/icons-material';
import {
Box,
Button,
Card,
CardContent,
Grid,
Paper,
Typography,
useTheme
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
export const Dashboard = () => {
const theme = useTheme();
const navigate = useNavigate();
const stats = [
{
title: 'Total Juniors',
value: '3',
icon: <PeopleIcon sx={{ fontSize: 40, color: 'primary.main' }} />,
action: () => navigate('/juniors')
},
{
title: 'Active Tasks',
value: '5',
icon: <TaskIcon sx={{ fontSize: 40, color: 'secondary.main' }} />,
action: () => navigate('/tasks')
},
{
title: 'Total Balance',
value: 'SAR 500',
icon: <WalletIcon sx={{ fontSize: 40, color: 'success.main' }} />,
action: () => { }
},
{
title: 'Monthly Growth',
value: '+15%',
icon: <TrendingUpIcon sx={{ fontSize: 40, color: 'info.main' }} />,
action: () => { }
}
];
return (
<Box>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 600, color: 'text.primary', mb: 1 }}>
Welcome to Zod Alkhair,
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
This is the API Testing client
</Typography>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
{stats.map((stat, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Card
sx={{
height: '100%',
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': {
transform: 'translateY(-4px)'
}
}}
onClick={stat.action}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
{stat.icon}
</Box>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 1 }}>
{stat.value}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{stat.title}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Grid container spacing={3}>
<Grid item xs={12} md={8}>
<Paper
sx={{
p: 3,
height: '100%',
backgroundColor: theme.palette.primary.main,
color: 'white'
}}
>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Quick Actions
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Button
fullWidth
variant="contained"
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)'
}
}}
onClick={() => navigate('/juniors/new')}
>
Add New Junior
</Button>
</Grid>
<Grid item xs={12} sm={6}>
<Button
fullWidth
variant="contained"
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)'
}
}}
onClick={() => navigate('/tasks/new')}
>
Create New Task
</Button>
</Grid>
</Grid>
</Paper>
</Grid>
<Grid item xs={12} md={4}>
<Paper sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Recent Activity
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 4 }}>
No recent activity
</Typography>
</Paper>
</Grid>
</Grid>
</Box>
);
};

View File

@ -1,86 +0,0 @@
import { CloudUpload as CloudUploadIcon } from '@mui/icons-material';
import { Alert, Box, Button, CircularProgress } from '@mui/material';
import { AxiosError } from 'axios';
import React, { useState } from 'react';
import { documentApi } from '../../api/client';
import { ApiError } from '../../types/api';
import { DocumentType } from '../../types/document';
interface DocumentUploadProps {
onUploadSuccess: (documentId: string) => void;
documentType: DocumentType;
label: string;
}
export const DocumentUpload = ({ onUploadSuccess, documentType, label }: DocumentUploadProps) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setLoading(true);
setError('');
setSuccess(false);
try {
const response = await documentApi.upload(file, documentType);
console.log('Document upload response:', response.data);
const documentId = response.data.data.id;
console.log('Extracted document ID:', documentId);
onUploadSuccess(documentId);
setSuccess(true);
} catch (err) {
if (err instanceof AxiosError && err.response?.data) {
const apiError = err.response.data as ApiError;
const messages = Array.isArray(apiError.message)
? apiError.message.map((m) => `${m.field}: ${m.message}`).join('\n')
: apiError.message;
setError(messages);
} else {
setError(err instanceof Error ? err.message : 'Failed to upload document');
}
} finally {
setLoading(false);
}
};
const now = new Date();
return (
<Box>
<input
accept="image/*,.pdf"
style={{ display: 'none' }}
id={`upload-${documentType}-${now.getTime()}`}
type="file"
onChange={handleFileChange}
disabled={loading}
/>
<label htmlFor={`upload-${documentType}-${now.getTime()}`}>
<Button
variant="outlined"
component="span"
startIcon={loading ? <CircularProgress size={20} /> : <CloudUploadIcon />}
disabled={loading}
fullWidth
>
{loading ? 'Uploading...' : label}
</Button>
</label>
{error && (
<Alert severity="error" sx={{ mt: 1, whiteSpace: 'pre-line' }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mt: 1 }}>
Document uploaded successfully
</Alert>
)}
</Box>
);
};

View File

@ -1,266 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Button,
Typography,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Alert,
SelectChangeEvent,
Divider
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { juniorsApi } from '../../api/client';
import { CreateJuniorRequest } from '../../types/junior';
import { DocumentUpload } from '../document/DocumentUpload';
import { DocumentType } from '../../types/document';
import { ApiError } from '../../types/api';
import { AxiosError } from 'axios';
export const AddJuniorForm = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState<CreateJuniorRequest>({
countryCode: '+962',
phoneNumber: '',
firstName: '',
lastName: '',
dateOfBirth: '',
email: '',
relationship: 'PARENT',
civilIdFrontId: '',
civilIdBackId: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Form data:', formData);
setError('');
setLoading(true);
try {
if (!formData.civilIdFrontId || !formData.civilIdBackId) {
console.log('Missing documents - Front:', formData.civilIdFrontId, 'Back:', formData.civilIdBackId);
throw new Error('Please upload both front and back civil ID documents');
}
console.log('Submitting data:', formData);
const dataToSubmit = {
...formData,
civilIdFrontId: formData.civilIdFrontId.trim(),
civilIdBackId: formData.civilIdBackId.trim()
};
await juniorsApi.createJunior(dataToSubmit);
navigate('/juniors');
} catch (err) {
console.error('Create junior error:', err);
if (err instanceof AxiosError && err.response?.data) {
const apiError = err.response.data as ApiError;
const messages = Array.isArray(apiError.message)
? apiError.message.map(m => `${m.field}: ${m.message}`).join('\n')
: apiError.message;
setError(messages);
} else {
setError(err instanceof Error ? err.message : 'Failed to create junior');
}
} finally {
setLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSelectChange = (e: SelectChangeEvent) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name as string]: value
}));
};
useEffect(() => {
console.log('Form data updated:', formData);
}, [formData]);
const handleCivilIdFrontUpload = (documentId: string) => {
console.log('Front ID uploaded:', documentId);
setFormData(prev => ({
...prev,
civilIdFrontId: documentId
}));
};
const handleCivilIdBackUpload = (documentId: string) => {
console.log('Back ID uploaded:', documentId);
setFormData(prev => ({
...prev,
civilIdBackId: documentId
}));
};
return (
<Box p={3}>
<Typography variant="h4" gutterBottom>
Add New Junior
</Typography>
<Paper sx={{ p: 3, maxWidth: 600, mx: 'auto' }}>
{error && (
<Alert severity="error" sx={{ mb: 3, whiteSpace: 'pre-line' }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Country Code</InputLabel>
<Select
name="countryCode"
value={formData.countryCode}
label="Country Code"
onChange={handleSelectChange}
>
<MenuItem value="+962">Jordan (+962)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Phone Number"
name="phoneNumber"
value={formData.phoneNumber}
onChange={handleInputChange}
placeholder="7XXXXXXXX"
required
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="First Name"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
required
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Last Name"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
required
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
required
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Date of Birth"
name="dateOfBirth"
type="date"
value={formData.dateOfBirth}
onChange={handleInputChange}
required
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>Relationship</InputLabel>
<Select
name="relationship"
value={formData.relationship}
label="Relationship"
onChange={handleSelectChange}
>
<MenuItem value="PARENT">Parent</MenuItem>
<MenuItem value="GUARDIAN">Guardian</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }}>
<Typography variant="body2" color="textSecondary">
Civil ID Documents
</Typography>
</Divider>
</Grid>
<Grid item xs={12} sm={6}>
<DocumentUpload
documentType={DocumentType.PASSPORT}
label="Upload Civil ID Front"
onUploadSuccess={handleCivilIdFrontUpload}
/>
{formData.civilIdFrontId && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Civil ID Front uploaded (ID: {formData.civilIdFrontId})
</Typography>
)}
</Grid>
<Grid item xs={12} sm={6}>
<DocumentUpload
documentType={DocumentType.PASSPORT}
label="Upload Civil ID Back"
onUploadSuccess={handleCivilIdBackUpload}
/>
{formData.civilIdBackId && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Civil ID Back uploaded (ID: {formData.civilIdBackId})
</Typography>
)}
</Grid>
</Grid>
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
onClick={() => navigate('/juniors')}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={loading}
>
{loading ? 'Adding...' : 'Add Junior'}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
};

View File

@ -1,121 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
CardMedia,
Button,
CircularProgress,
Pagination
} from '@mui/material';
import { juniorsApi } from '../../api/client';
import { Junior, PaginatedResponse } from '../../types/junior';
import { useNavigate } from 'react-router-dom';
export const JuniorsList = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [juniors, setJuniors] = useState<Junior[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const navigate = useNavigate();
const fetchJuniors = async (pageNum: number) => {
try {
setLoading(true);
const response = await juniorsApi.getJuniors(pageNum);
const data = response.data as PaginatedResponse<Junior>;
setJuniors(data.data);
setTotalPages(data.meta.pageCount);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load juniors');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchJuniors(page);
}, [page]);
const handlePageChange = (event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box p={3}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4">Juniors</Typography>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/juniors/new')}
>
Add Junior
</Button>
</Box>
<Grid container spacing={3}>
{juniors.map((junior) => (
<Grid item xs={12} sm={6} md={4} key={junior.id}>
<Card>
<CardMedia
component="img"
height="140"
image={junior.profilePicture?.url || '/default-avatar.png'}
alt={junior.fullName}
sx={{ objectFit: 'contain', bgcolor: 'grey.100' }}
/>
<CardContent>
<Typography variant="h6" gutterBottom>
{junior.fullName}
</Typography>
<Typography color="textSecondary">
{junior.relationship}
</Typography>
<Box mt={2}>
<Button
variant="outlined"
fullWidth
onClick={() => navigate(`/juniors/${junior.id}`)}
>
View Details
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" mt={4}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</Box>
);
};

View File

@ -1,175 +0,0 @@
import React from 'react';
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
import {
AppBar,
Toolbar,
Typography,
Button,
Box,
Container,
List,
ListItem,
Drawer,
Divider
} from '@mui/material';
import {
Dashboard as DashboardIcon,
People as PeopleIcon,
Assignment as TasksIcon,
Person as ProfileIcon
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
export const AuthLayout = () => {
const { isAuthenticated, user, logout } = useAuth();
const navigate = useNavigate();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
backgroundColor: 'background.paper',
boxShadow: 'none',
borderBottom: '1px solid',
borderColor: 'divider'
}}
>
<Toolbar>
<Typography variant="h5" component="div" sx={{ flexGrow: 1, color: 'text.primary', fontWeight: 600 }}>
Zod Alkhair | API Testting client
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{user && (
<Typography variant="body1" sx={{ color: 'text.primary' }}>
{user.firstName} {user.lastName}
</Typography>
)}
<Button
variant="outlined"
color="primary"
onClick={logout}
size="small"
>
Logout
</Button>
</Box>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: 280,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: 280,
boxSizing: 'border-box',
marginTop: '64px',
backgroundColor: 'background.paper',
borderRight: '1px solid',
borderColor: 'divider',
padding: 2
},
}}
>
<Box sx={{ overflow: 'auto' }}>
<List>
<ListItem component="div">
<Button
fullWidth
sx={{
justifyContent: 'flex-start',
pl: 2,
py: 1.5,
borderRadius: 2,
color: 'text.primary',
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText'
}
}}
onClick={() => navigate('/dashboard')}
startIcon={<DashboardIcon />}
>
Dashboard
</Button>
</ListItem>
<ListItem component="div">
<Button
fullWidth
sx={{
justifyContent: 'flex-start',
pl: 2,
py: 1.5,
borderRadius: 2,
color: 'text.primary',
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText'
}
}}
onClick={() => navigate('/juniors')}
startIcon={<PeopleIcon />}
>
Juniors
</Button>
</ListItem>
<ListItem component="div">
<Button
fullWidth
sx={{
justifyContent: 'flex-start',
pl: 2,
py: 1.5,
borderRadius: 2,
color: 'text.primary',
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText'
}
}}
onClick={() => navigate('/tasks')}
startIcon={<TasksIcon />}
>
Tasks
</Button>
</ListItem>
</List>
<Divider />
<List>
<ListItem component="div">
<Button
fullWidth
sx={{
justifyContent: 'flex-start',
pl: 2,
py: 1.5,
borderRadius: 2,
color: 'text.primary',
'&:hover': {
backgroundColor: 'primary.light',
color: 'primary.contrastText'
}
}}
onClick={() => navigate('/profile')}
startIcon={<ProfileIcon />}
>
Profile
</Button>
</ListItem>
</List>
</Box>
</Drawer>
<Container component="main" sx={{ flexGrow: 1, p: 4, marginLeft: '280px', marginTop: '64px' }}>
<Outlet />
</Container>
</Box>
);
};

View File

@ -1,245 +0,0 @@
import {
Alert,
Box,
Button,
Checkbox,
FormControl,
FormControlLabel,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
SelectChangeEvent,
TextField,
Typography,
} from '@mui/material';
import { AxiosError } from 'axios';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { juniorsApi, tasksApi } from '../../api/client';
import { ApiError } from '../../types/api';
import { DocumentType } from '../../types/document';
import { Junior, PaginatedResponse } from '../../types/junior';
import { CreateTaskRequest } from '../../types/task';
import { DocumentUpload } from '../document/DocumentUpload';
export const AddTaskForm = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState<CreateTaskRequest>({
title: '',
description: '',
dueDate: '',
rewardAmount: 0,
isProofRequired: false,
juniorId: '',
imageId: '',
});
const [juniors, setJuniors] = useState<Junior[]>([]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Form data:', formData);
setError('');
setLoading(true);
try {
if (!formData.imageId) {
console.log('Proof is required but no image uploaded');
}
console.log('Submitting data:', formData);
const dataToSubmit = {
...formData,
rewardAmount: Number(formData.rewardAmount),
imageId: formData.imageId,
};
await tasksApi.createTask(dataToSubmit);
navigate('/tasks');
} catch (err) {
console.error('Create junior error:', err);
if (err instanceof AxiosError && err.response?.data) {
const apiError = err.response.data as ApiError;
const messages = Array.isArray(apiError.message)
? apiError.message.map((m) => `${m.field}: ${m.message}`).join('\n')
: apiError.message;
setError(messages);
} else {
setError(err instanceof Error ? err.message : 'Failed to create Task');
}
} finally {
setLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
console.log(name, value);
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const fetchJuniors = async () => {
try {
const response = await juniorsApi.getJuniors(1, 50);
const data = response.data as PaginatedResponse<Junior>;
setJuniors(data.data);
} catch (err) {
console.error('Failed to load juniors:', err);
}
};
const handleSelectChange = (e: SelectChangeEvent) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name as string]: value,
}));
};
useEffect(() => {
console.log('Form data updated:', formData);
}, [formData]);
useEffect(() => {
fetchJuniors();
}, []);
const handleTaskImageUpload = (documentId: string) => {
console.log('task image ID uploaded:', documentId);
setFormData((prev) => ({
...prev,
imageId: documentId,
}));
};
const handleCheckedInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
isProofRequired: e.target.checked,
}));
};
return (
<Box p={3}>
<Typography variant="h4" gutterBottom>
Add New Task
</Typography>
<Paper sx={{ p: 3, maxWidth: 600, mx: 'auto' }}>
{error && (
<Alert severity="error" sx={{ mb: 3, whiteSpace: 'pre-line' }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="Task Title"
required
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Task Description"
required
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Due Date"
name="dueDate"
type="date"
value={formData.dueDate}
onChange={handleInputChange}
required
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Reward Amount"
name="rewardAmount"
type="number"
value={formData.rewardAmount}
onChange={handleInputChange}
required
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>Junior</InputLabel>
<Select name="juniorId" value={formData.juniorId} label="Junior" onChange={handleSelectChange}>
<MenuItem value="">Select Junior</MenuItem>
{juniors.map((junior) => (
<MenuItem key={junior.id} value={junior.id}>
{junior.fullName}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={12}>
<DocumentUpload
documentType={DocumentType.PASSPORT}
label="Upload Task Image"
onUploadSuccess={handleTaskImageUpload}
/>
{formData.imageId && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Task Image uploaded (ID: {formData.imageId})
</Typography>
)}
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<FormControlLabel
control={
<Checkbox checked={formData.isProofRequired} onChange={handleCheckedInputChange} color="primary" />
}
label="Proof Required"
/>
</FormControl>
</Grid>
</Grid>
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="outlined" onClick={() => navigate('/juniors')}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? 'Adding...' : 'Add Task'}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
};

View File

@ -1,87 +0,0 @@
import { Box, Card, CardContent, Chip, CircularProgress, Typography } from '@mui/material';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { tasksApi } from '../../api/client';
import { Task } from '../../types/task';
export const TaskDetails = () => {
useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const statusColors = {
PENDING: 'warning',
IN_PROGRESS: 'info',
COMPLETED: 'success',
} as const;
const { taskId } = useParams();
if (!taskId) {
throw new Error('Task ID is required');
}
const [task, setTask] = useState<Task>();
const fetchTask = async () => {
try {
setLoading(true);
const response = await tasksApi.getTaskById(taskId);
setTask(response.data.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load task');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTask();
}, []);
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Typography color="error">{error}</Typography>
</Box>
);
}
if (!task) {
return (
<Box p={3}>
<Typography color="error">Task not found</Typography>
</Box>
);
}
console.log(task);
return (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Typography variant="h6" gutterBottom>
{task.title}
</Typography>
<Chip label={task.status} color={statusColors[task.status]} size="small" />
</Box>
<Typography color="textSecondary" gutterBottom>
Due: {new Date(task.dueDate).toLocaleDateString()}
</Typography>
<Typography variant="body2" gutterBottom>
{task.description}
</Typography>
<Typography color="primary" gutterBottom>
Reward: ${task.rewardAmount}
</Typography>
<Typography variant="body2" color="textSecondary">
Assigned to: {task.junior.fullName}
</Typography>
</CardContent>
</Card>
);
};

View File

@ -1,200 +0,0 @@
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Pagination,
Select,
SelectChangeEvent,
Typography
} from '@mui/material';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { juniorsApi, tasksApi } from '../../api/client';
import { Junior, PaginatedResponse } from '../../types/junior';
import { Task, TaskStatus } from '../../types/task';
const statusColors = {
PENDING: 'warning',
IN_PROGRESS: 'info',
COMPLETED: 'success'
} as const;
export const TasksList = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tasks, setTasks] = useState<Task[]>([]);
const [juniors, setJuniors] = useState<Junior[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [status, setStatus] = useState<TaskStatus>('PENDING');
const [selectedJuniorId, setSelectedJuniorId] = useState<string>('');
const navigate = useNavigate();
const fetchJuniors = async () => {
try {
const response = await juniorsApi.getJuniors(1, 50);
const data = response.data as PaginatedResponse<Junior>;
setJuniors(data.data);
} catch (err) {
console.error('Failed to load juniors:', err);
}
};
const fetchTasks = async (pageNum: number) => {
try {
setLoading(true);
const response = await tasksApi.getTasks(status, pageNum, 10, selectedJuniorId || undefined);
const data = response.data as PaginatedResponse<Task>;
setTasks(data.data);
setTotalPages(data.meta.pageCount);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tasks');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchJuniors();
}, []);
useEffect(() => {
fetchTasks(page);
}, [page, status, selectedJuniorId]);
const handlePageChange = (event: React.ChangeEvent<unknown>, value: number) => {
setPage(value);
};
const handleStatusChange = (event: SelectChangeEvent) => {
setStatus(event.target.value as TaskStatus);
setPage(1);
};
const handleJuniorChange = (event: SelectChangeEvent) => {
setSelectedJuniorId(event.target.value);
setPage(1);
};
if (loading && page === 1) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Typography color="error">{error}</Typography>
</Box>
);
}
return (
<Box p={3}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4">Tasks</Typography>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/tasks/new')}
>
Create Task
</Button>
</Box>
<Box display="flex" gap={2} mb={3}>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select
value={status}
label="Status"
onChange={handleStatusChange}
>
<MenuItem value="PENDING">Pending</MenuItem>
<MenuItem value="IN_PROGRESS">In Progress</MenuItem>
<MenuItem value="COMPLETED">Completed</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Junior</InputLabel>
<Select
value={selectedJuniorId}
label="Junior"
onChange={handleJuniorChange}
>
<MenuItem value="">All Juniors</MenuItem>
{juniors.map(junior => (
<MenuItem key={junior.id} value={junior.id}>
{junior.fullName}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Grid container spacing={3}>
{tasks.map((task) => (
<Grid item xs={12} sm={6} md={4} key={task.id}>
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Typography variant="h6" gutterBottom>
{task.title}
</Typography>
<Chip
label={task.status}
color={statusColors[task.status]}
size="small"
/>
</Box>
<Typography color="textSecondary" gutterBottom>
Due: {new Date(task.dueDate).toLocaleDateString()}
</Typography>
<Typography variant="body2" gutterBottom>
{task.description}
</Typography>
<Typography color="primary" gutterBottom>
Reward: ${task.rewardAmount}
</Typography>
<Typography variant="body2" color="textSecondary">
Assigned to: {task.junior.fullName}
</Typography>
<Box mt={2}>
<Button
variant="outlined"
fullWidth
onClick={() => navigate(`/tasks/${task.id}`)}
>
View Details
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Box display="flex" justifyContent="center" mt={4}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Box>
)}
</Box>
);
};

View File

@ -1,119 +0,0 @@
import React, { createContext, useCallback, useContext, useState } from 'react';
import { authApi } from '../api/client';
import { LoginRequest, LoginResponse, User } from '../types/auth';
interface AuthContextType {
isAuthenticated: boolean;
user: User | null;
login: (loginRequest: LoginRequest) => Promise<void>;
logout: () => void;
register: (countryCode: string, phoneNumber: string) => Promise<void>;
verifyOtp: (countryCode: string, phoneNumber: string, otp: string) => Promise<string>;
setEmail: (email: string) => Promise<void>;
setPasscode: (passcode: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (loginRequest: LoginRequest) => {
try {
const response = await authApi.login(loginRequest);
const loginData = response.data.data as LoginResponse;
setUser(loginData.user);
// Store tokens
localStorage.setItem('accessToken', loginData.accessToken);
localStorage.setItem('refreshToken', loginData.refreshToken);
setIsAuthenticated(true);
// Store tokens or other auth data in localStorage if needed
} catch (error) {
console.error('Login failed:', error);
throw error;
}
}, []);
const logout = useCallback(() => {
setUser(null);
setIsAuthenticated(false);
// Clear any stored auth data
localStorage.clear();
}, []);
// Registration state
const [registrationData, setRegistrationData] = useState<{
countryCode?: string;
phoneNumber?: string;
email?: string;
token?: string;
}>({});
const register = useCallback(async (countryCode: string, phoneNumber: string) => {
try {
await authApi.register(countryCode, phoneNumber);
setRegistrationData({ countryCode, phoneNumber });
} catch (error) {
console.error('Registration failed:', error);
throw error;
}
}, []);
const verifyOtp = useCallback(async (countryCode: string, phoneNumber: string, otp: string) => {
try {
const response = await authApi.verifyOtp(countryCode, phoneNumber, otp);
console.log('OTP verification response:', response.data);
const { accessToken } = response.data.data;
console.log('Access token:', accessToken);
// Store token in localStorage immediately
localStorage.setItem('accessToken', accessToken);
setRegistrationData((prev) => ({ ...prev, token: accessToken }));
return accessToken;
} catch (error) {
console.error('OTP verification failed:', error);
throw error;
}
}, []);
const setEmail = useCallback(async (email: string) => {
try {
await authApi.setEmail(email);
setRegistrationData((prev) => ({ ...prev, email }));
} catch (error) {
console.error('Setting email failed:', error);
throw error;
}
}, []);
const setPasscode = useCallback(async (passcode: string) => {
try {
await authApi.setPasscode(passcode);
setIsAuthenticated(true);
} catch (error) {
console.error('Setting passcode failed:', error);
throw error;
}
}, []);
const value = {
isAuthenticated,
user,
login,
logout,
register,
verifyOtp,
setEmail,
setPasscode,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@ -1,6 +0,0 @@
export enum GrantType {
PASSWORD = 'PASSWORD',
APPLE = 'APPLE',
GOOGLE = 'GOOGLE',
BIOMETRIC = 'BIOMETRIC',
}

View File

@ -1 +0,0 @@
export * from './grantType.enum';

View File

@ -1,52 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #F8F9FA;
color: #2D3748;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #F8F9FA;
}
::-webkit-scrollbar-thumb {
background: #CBD5E0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #A0AEC0;
}
/* Smooth transitions */
a, button {
transition: all 0.2s ease-in-out;
}
/* Remove focus outline for mouse users, keep for keyboard users */
:focus:not(:focus-visible) {
outline: none;
}
/* Keep focus outline for keyboard users */
:focus-visible {
outline: 2px solid #00A7E1;
outline-offset: 2px;
}

View File

@ -1,13 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -1,14 +0,0 @@
interface ApiErrorField {
field: string;
message: string;
}
export interface ApiError {
statusCode: number;
message: string | ApiErrorField[];
error: string;
}
export interface ApiResponse<T> {
data: T;
}

View File

@ -1,27 +0,0 @@
import { GrantType } from '../enums';
export interface User {
id: string;
email: string;
customerStatus?: string;
firstName?: string;
lastName?: string;
dateOfBirth?: string;
countryOfResidence?: string;
isJunior?: boolean;
isGuardian?: boolean;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: User;
}
export interface LoginRequest {
email?: string;
password?: string;
grantType: GrantType;
googleToken?: string;
appleToken?: string;
}

View File

@ -1,9 +0,0 @@
export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT',
DEFAULT_AVATAR = 'DEFAULT_AVATAR',
DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO',
CUSTOM_AVATAR = 'CUSTOM_AVATAR',
CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO',
GOALS = 'GOALS'
}

View File

@ -1,41 +0,0 @@
export interface Junior {
id: string;
fullName: string;
relationship: string;
profilePicture?: {
id: string;
name: string;
extension: string;
documentType: string;
url: string;
};
}
export interface CreateJuniorRequest {
countryCode: string;
phoneNumber: string;
firstName: string;
lastName: string;
dateOfBirth: string;
email: string;
relationship: string;
civilIdFrontId: string;
civilIdBackId: string;
}
export interface JuniorTheme {
color: string;
avatarId: string;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
size: number;
itemCount: number;
pageCount: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
};
}

View File

@ -1,42 +0,0 @@
import { Junior } from './junior';
export interface Task {
id: string;
title: string;
description: string;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';
dueDate: string;
rewardAmount: number;
isProofRequired: boolean;
submission?: {
imageId?: string;
submittedAt?: string;
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
};
junior: Junior;
image?: {
id: string;
name: string;
extension: string;
documentType: string;
url: string;
};
createdAt: string;
updatedAt: string;
}
export interface CreateTaskRequest {
title: string;
description: string;
dueDate: string;
rewardAmount: number;
isProofRequired: boolean;
imageId?: string;
juniorId: string;
}
export interface TaskSubmission {
imageId: string;
}
export type TaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED';

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,16 +0,0 @@
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.join(process.cwd(), '..'), '');
return {
define: {
'process.env.REACT_APP_APPLE_CLIENT_ID': JSON.stringify(env.REACT_APP_APPLE_CLIENT_ID),
'process.env.REACT_APP_APPLE_REDIRECT_URI': JSON.stringify(env.REACT_APP_APPLE_REDIRECT_URI),
'process.env.GOOGLE_WEB_CLIENT_ID': JSON.stringify(env.GOOGLE_WEB_CLIENT_ID),
},
plugins: [react()],
};
});

9003
package-lock.json generated

File diff suppressed because it is too large Load Diff

0
queries/Query.sql Normal file
View File

View File

@ -1,20 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JuniorModule } from '~/junior/junior.module';
import { AllowanceChangeRequestController, AllowancesController } from './controllers';
import { Allowance, AllowanceChangeRequest } from './entities';
import { AllowanceChangeRequestsRepository, AllowancesRepository } from './repositories';
import { AllowanceChangeRequestsService, AllowancesService } from './services';
@Module({
controllers: [AllowancesController, AllowanceChangeRequestController],
imports: [TypeOrmModule.forFeature([Allowance, AllowanceChangeRequest]), JuniorModule],
providers: [
AllowancesService,
AllowancesRepository,
AllowanceChangeRequestsService,
AllowanceChangeRequestsRepository,
],
exports: [AllowancesService],
})
export class AllowanceModule {}

View File

@ -1,81 +0,0 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequestResponseDto } from '../dtos/response';
import { AllowanceChangeRequestsService } from '../services';
@Controller('allowance-change-requests')
@ApiTags('Allowance Change Requests')
@ApiBearerAuth()
@ApiLangRequestHeader()
export class AllowanceChangeRequestController {
constructor(private readonly allowanceChangeRequestsService: AllowanceChangeRequestsService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@HttpCode(HttpStatus.NO_CONTENT)
requestAllowanceChange(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsService.createAllowanceChangeRequest(sub, body);
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequests(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [requests, itemCount] = await this.allowanceChangeRequestsService.findAllowanceChangeRequests(sub, query);
return ResponseFactory.dataPage(
requests.map((request) => new AllowanceChangeRequestResponseDto(request)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get('/:changeRequestId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequestById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
const request = await this.allowanceChangeRequestsService.findAllowanceChangeRequestById(sub, changeRequestId);
return ResponseFactory.data(new AllowanceChangeRequestResponseDto(request));
}
@Patch(':changeRequestId/approve')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
approveAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.approveAllowanceChangeRequest(sub, changeRequestId);
}
@Patch(':changeRequestId/reject')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
rejectAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.rejectAllowanceChangeRequest(sub, changeRequestId);
}
}

View File

@ -1,73 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { AllowanceResponseDto } from '../dtos/response';
import { AllowancesService } from '../services';
@Controller('allowances')
@ApiTags('Allowances')
@ApiBearerAuth()
@ApiLangRequestHeader()
export class AllowancesController {
constructor(private readonly allowancesService: AllowancesService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async createAllowance(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceRequestDto) {
const allowance = await this.allowancesService.createAllowance(sub, body);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceResponseDto)
async findAllowances(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [allowances, itemCount] = await this.allowancesService.findAllowances(sub, query);
return ResponseFactory.dataPage(
allowances.map((allowance) => new AllowanceResponseDto(allowance)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async findAllowanceById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
const allowance = await this.allowancesService.findAllowanceById(allowanceId, sub);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Delete(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllowance(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
return this.allowancesService.deleteAllowance(sub, allowanceId);
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.controller';
export * from './allowances.controller';

View File

@ -1,28 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class CreateAllowanceChangeRequestDto {
@ApiProperty({ example: 'I want to change the amount of the allowance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowanceChangeRequest.reason' }) })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowanceChangeRequest.reason' }),
})
reason!: string;
@ApiProperty({ example: 100 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowanceChangeRequest.amount' }) },
)
@IsPositive({
message: i18n('validation.IsPositive', { path: 'general', property: 'allowanceChangeRequest.amount' }),
})
amount!: number;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
@IsUUID('4', {
message: i18n('validation.IsUUID', { path: 'general', property: 'allowanceChangeRequest.allowanceId' }),
})
allowanceId!: string;
}

View File

@ -1,52 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDate, IsEnum, IsInt, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID, ValidateIf } from 'class-validator';
import moment from 'moment';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
export class CreateAllowanceRequestDto {
@ApiProperty({ example: 'Allowance name' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowance.name' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.name' }) })
name!: string;
@ApiProperty({ example: 100 })
@IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
@IsEnum(AllowanceFrequency, {
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }),
})
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
@IsEnum(AllowanceType, { message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.type' }) })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.startDate' }) })
@Transform(({ value }) => moment(value).startOf('day').toDate())
startDate!: Date;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.endDate' }) })
@Transform(({ value }) => moment(value).endOf('day').toDate())
@ValidateIf((o) => o.type === AllowanceType.BY_END_DATE)
endDate?: Date;
@ApiProperty({ example: 10 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.numberOfTransactions' }) },
)
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
@ValidateIf((o) => o.type === AllowanceType.BY_COUNT)
numberOfTransactions?: number;
@ApiProperty({ example: 'e7b1b3b4-4b3b-4b3b-4b3b-4b3b4b3b4b3b' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'allowance.juniorId' }) })
juniorId!: string;
}

View File

@ -1,2 +0,0 @@
export * from './create-allowance-change.request.dto';
export * from './create-allowance.request.dto';

View File

@ -1,45 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { AllowanceChangeRequest } from '~/allowance/entities';
import { AllowanceChangeRequestStatus } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceChangeRequestResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: AllowanceChangeRequestStatus.APPROVED })
status!: AllowanceChangeRequestStatus;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: '100' })
oldAmount!: number;
@ApiProperty({ example: '200' })
newAmount!: number;
@ApiProperty({ example: 'Some reason' })
reason!: string;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
allowanceId!: string;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
constructor(allowanceChangeRequest: AllowanceChangeRequest) {
this.id = allowanceChangeRequest.id;
this.status = allowanceChangeRequest.status;
this.name = allowanceChangeRequest.allowance.name;
this.oldAmount = allowanceChangeRequest.allowance.amount;
this.newAmount = allowanceChangeRequest.amount;
this.reason = allowanceChangeRequest.reason;
this.allowanceId = allowanceChangeRequest.allowanceId;
this.junior = new JuniorResponseDto(allowanceChangeRequest.allowance.junior);
this.createdAt = allowanceChangeRequest.createdAt;
}
}

View File

@ -1,53 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allowance } from '~/allowance/entities';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: 100 })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
startDate!: Date;
@ApiProperty({ example: new Date() })
endDate?: Date;
@ApiProperty({ example: 10 })
numberOfTransactions?: number;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
@ApiProperty({ example: new Date() })
updatedAt!: Date;
constructor(allowance: Allowance) {
this.id = allowance.id;
this.name = allowance.name;
this.amount = allowance.amount;
this.frequency = allowance.frequency;
this.type = allowance.type;
this.startDate = allowance.startDate;
this.endDate = allowance.endDate;
this.numberOfTransactions = allowance.numberOfTransactions;
this.junior = new JuniorResponseDto(allowance.junior);
this.createdAt = allowance.createdAt;
this.updatedAt = allowance.updatedAt;
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.response.dto';
export * from './allowance.response.dto';

View File

@ -1,45 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { AllowanceChangeRequestStatus } from '../enums';
import { Allowance } from './allowance.entity';
@Entity('allowance_change_requests')
export class AllowanceChangeRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', name: 'reason' })
reason!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'status', default: AllowanceChangeRequestStatus.PENDING })
status!: AllowanceChangeRequestStatus;
@Column({ type: 'uuid', name: 'allowance_id' })
allowanceId!: string;
@ManyToOne(() => Allowance, (allowance) => allowance.changeRequests)
@JoinColumn({ name: 'allowance_id' })
allowance!: Allowance;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -1,107 +0,0 @@
import moment from 'moment';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { AllowanceFrequency, AllowanceType } from '../enums';
import { AllowanceChangeRequest } from './allowance-change-request.entity';
@Entity('allowances')
export class Allowance {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'name' })
name!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'frequency' })
frequency!: AllowanceFrequency;
@Column({ type: 'varchar', length: 255, name: 'type' })
type!: AllowanceType;
@Column({ type: 'timestamp with time zone', name: 'start_date' })
startDate!: Date;
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate?: Date;
@Column({ type: 'int', name: 'number_of_transactions', nullable: true })
numberOfTransactions?: number;
@Column({ type: 'uuid', name: 'guardian_id' })
guardianId!: string;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Guardian, (guardian) => guardian.allowances)
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@ManyToOne(() => Junior, (junior) => junior.allowances)
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@OneToMany(() => AllowanceChangeRequest, (changeRequest) => changeRequest.allowance)
changeRequests!: AllowanceChangeRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
deletedAt?: Date;
get nextPaymentDate(): Date | null {
const startDate = moment(this.startDate).clone().startOf('day');
const endDate = this.endDate ? moment(this.endDate).endOf('day') : null;
const now = moment().startOf('day');
if (endDate && moment().isAfter(endDate)) {
return null;
}
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
const diff = now.diff(startDate, unit);
const nextDate = startDate.clone().add(diff, unit);
const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit);
if (endDate && adjustedDate.isAfter(endDate)) {
return null;
}
return adjustedDate.toDate();
};
switch (this.frequency) {
case AllowanceFrequency.DAILY:
return calculateNextDate('days');
case AllowanceFrequency.WEEKLY:
return calculateNextDate('weeks');
case AllowanceFrequency.MONTHLY:
return calculateNextDate('months');
default:
return null;
}
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.entity';
export * from './allowance.entity';

View File

@ -1,5 +0,0 @@
export enum AllowanceChangeRequestStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

@ -1,5 +0,0 @@
export enum AllowanceFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

View File

@ -1,4 +0,0 @@
export enum AllowanceType {
BY_END_DATE = 'BY_END_DATE',
BY_COUNT = 'BY_COUNT',
}

View File

@ -1,3 +0,0 @@
export * from './allowance-change-request-status.enum';
export * from './allowance-frequency.enum';
export * from './allowance-type.enum';

View File

@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
const ONE = 1;
@Injectable()
export class AllowanceChangeRequestsRepository {
constructor(
@InjectRepository(AllowanceChangeRequest)
private readonly allowanceChangeRequestsRepository: Repository<AllowanceChangeRequest>,
) {}
createAllowanceChangeRequest(allowanceId: string, body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsRepository.save(
this.allowanceChangeRequestsRepository.create({
allowanceId,
amount: body.amount,
reason: body.reason,
}),
);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>, withRelations = false) {
const relations = withRelations
? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture']
: [];
return this.allowanceChangeRequestsRepository.findOne({ where, relations });
}
updateAllowanceChangeRequestStatus(requestId: string, status: AllowanceChangeRequestStatus) {
return this.allowanceChangeRequestsRepository.update({ id: requestId }, { status });
}
findAllowanceChangeRequests(guardianId: string, query: PageOptionsRequestDto) {
return this.allowanceChangeRequestsRepository.findAndCount({
where: { allowance: { guardianId } },
take: query.size,
skip: query.size * (query.page - ONE),
relations: [
'allowance',
'allowance.junior',
'allowance.junior.customer',
'allowance.junior.customer.profilePicture',
],
});
}
}

View File

@ -1,64 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
const ONE = 1;
@Injectable()
export class AllowancesRepository {
constructor(@InjectRepository(Allowance) private readonly allowancesRepository: Repository<Allowance>) {}
createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
return this.allowancesRepository.save(
this.allowancesRepository.create({
guardianId,
name: body.name,
amount: body.amount,
frequency: body.frequency,
type: body.type,
startDate: body.startDate,
endDate: body.endDate,
numberOfTransactions: body.numberOfTransactions,
juniorId: body.juniorId,
}),
);
}
findAllowanceById(allowanceId: string, guardianId?: string) {
return this.allowancesRepository.findOne({
where: { id: allowanceId, guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
});
}
findAllowances(guardianId: string, query: PageOptionsRequestDto) {
return this.allowancesRepository.findAndCount({
where: { guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
take: query.size,
skip: query.size * (query.page - ONE),
});
}
deleteAllowance(guardianId: string, allowanceId: string) {
return this.allowancesRepository.softDelete({ id: allowanceId, guardianId });
}
async *findAllowancesChunks(chunkSize: number) {
let offset = 0;
while (true) {
const allowances = await this.allowancesRepository.find({
take: chunkSize,
skip: offset,
});
if (!allowances.length) {
break;
}
yield allowances;
offset += chunkSize;
}
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-request.repository';
export * from './allowances.repository';

View File

@ -1,132 +0,0 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
import { AllowanceChangeRequestsRepository } from '../repositories';
import { AllowancesService } from './allowances.service';
@Injectable()
export class AllowanceChangeRequestsService {
private readonly logger = new Logger(AllowanceChangeRequestsService.name);
constructor(
private readonly allowanceChangeRequestsRepository: AllowanceChangeRequestsRepository,
private readonly ociService: OciService,
private readonly allowanceService: AllowancesService,
) {}
async createAllowanceChangeRequest(juniorId: string, body: CreateAllowanceChangeRequestDto) {
this.logger.log(`Creating allowance change request for junior ${juniorId}`);
const allowance = await this.allowanceService.validateAllowanceForJunior(juniorId, body.allowanceId);
if (allowance.amount === body.amount) {
this.logger.error(`Amount is the same as the current allowance amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT');
}
const requestWithTheSameAmount = await this.findAllowanceChangeRequestBy({
allowanceId: body.allowanceId,
amount: body.amount,
status: AllowanceChangeRequestStatus.PENDING,
});
if (requestWithTheSameAmount) {
this.logger.error(`There is a pending request with the same amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING');
}
return this.allowanceChangeRequestsRepository.createAllowanceChangeRequest(body.allowanceId, body);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>) {
this.logger.log(`Finding allowance change request by ${JSON.stringify(where)}`);
return this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(where);
}
async approveAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Approving allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.APPROVED) {
this.logger.error(`Allowance change request ${requestId} already approved`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_APPROVED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.APPROVED,
);
}
async rejectAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Rejecting allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.REJECTED) {
this.logger.error(`Allowance change request ${requestId} already rejected`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_REJECTED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.REJECTED,
);
}
async findAllowanceChangeRequests(
guardianId: string,
query: PageOptionsRequestDto,
): Promise<[AllowanceChangeRequest[], number]> {
this.logger.log(`Finding allowance change requests for guardian ${guardianId}`);
const [requests, itemCount] = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequests(
guardianId,
query,
);
await this.prepareAllowanceChangeRequestsImages(requests);
this.logger.log(`Returning allowance change requests for guardian ${guardianId}`);
return [requests, itemCount];
}
async findAllowanceChangeRequestById(guardianId: string, requestId: string) {
this.logger.log(`Finding allowance change request ${requestId} for guardian ${guardianId}`);
const request = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(
{
id: requestId,
allowance: { guardianId },
},
true,
);
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
await this.prepareAllowanceChangeRequestsImages([request]);
this.logger.log(`Allowance change request ${requestId} found successfully`);
return request;
}
private prepareAllowanceChangeRequestsImages(requests: AllowanceChangeRequest[]) {
this.logger.log(`Preparing allowance change requests images`);
return Promise.all(
requests.map(async (request) => {
const profilePicture = request.allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -1,110 +0,0 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { JuniorService } from '~/junior/services';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
import { AllowancesRepository } from '../repositories';
@Injectable()
export class AllowancesService {
private readonly logger = new Logger(AllowancesService.name);
constructor(
private readonly allowancesRepository: AllowancesRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
this.logger.log(`Creating allowance for junior ${body.juniorId} by guardian ${guardianId}`);
if (moment(body.startDate).isBefore(moment().startOf('day'))) {
this.logger.error(`Start date ${body.startDate} is before today`);
throw new BadRequestException('ALLOWANCE.START_DATE_BEFORE_TODAY');
}
if (moment(body.startDate).isAfter(body.endDate)) {
this.logger.error(`Start date ${body.startDate} is after end date ${body.endDate}`);
throw new BadRequestException('ALLOWANCE.START_DATE_AFTER_END_DATE');
}
const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(guardianId, body.juniorId);
if (!doesJuniorBelongToGuardian) {
this.logger.error(`Junior ${body.juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN');
}
const allowance = await this.allowancesRepository.createAllowance(guardianId, body);
this.logger.log(`Allowance ${allowance.id} created successfully`);
return this.findAllowanceById(allowance.id);
}
async findAllowanceById(allowanceId: string, guardianId?: string) {
this.logger.log(`Finding allowance ${allowanceId} ${guardianId ? `by guardian ${guardianId}` : ''}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId, guardianId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found ${guardianId ? `for guardian ${guardianId}` : ''}`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
await this.prepareAllowanceDocuments([allowance]);
this.logger.log(`Allowance ${allowanceId} found successfully`);
return allowance;
}
async findAllowances(guardianId: string, query: PageOptionsRequestDto): Promise<[Allowance[], number]> {
this.logger.log(`Finding allowances for guardian ${guardianId}`);
const [allowances, itemCount] = await this.allowancesRepository.findAllowances(guardianId, query);
await this.prepareAllowanceDocuments(allowances);
this.logger.log(`Returning allowances for guardian ${guardianId}`);
return [allowances, itemCount];
}
async deleteAllowance(guardianId: string, allowanceId: string) {
this.logger.log(`Deleting allowance ${allowanceId} for guardian ${guardianId}`);
const { affected } = await this.allowancesRepository.deleteAllowance(guardianId, allowanceId);
if (!affected) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
this.logger.log(`Allowance ${allowanceId} deleted successfully`);
}
async validateAllowanceForJunior(juniorId: string, allowanceId: string) {
this.logger.log(`Validating allowance ${allowanceId} for junior ${juniorId}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
if (allowance.juniorId !== juniorId) {
this.logger.error(`Allowance ${allowanceId} does not belong to junior ${juniorId}`);
throw new BadRequestException('ALLOWANCE.DOES_NOT_BELONG_TO_JUNIOR');
}
return allowance;
}
async findAllowancesChunks(chunkSize: number) {
this.logger.log(`Finding allowances chunks`);
const allowances = await this.allowancesRepository.findAllowancesChunks(chunkSize);
this.logger.log(`Returning allowances chunks`);
return allowances;
}
private async prepareAllowanceDocuments(allowance: Allowance[]) {
this.logger.log(`Preparing document for allowances`);
await Promise.all(
allowance.map(async (allowance) => {
const profilePicture = allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -1,2 +0,0 @@
export * from './allowance-change-requests.service';
export * from './allowances.service';

View File

@ -8,8 +8,8 @@ import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { AllowanceModule } from './allowance/allowance.module';
import { AuthModule } from './auth/auth.module';
import { CardModule } from './card/card.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { NeoLeapModule } from './common/modules/neoleap/neoleap.module';
@ -23,15 +23,12 @@ import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module';
import { migrations } from './db';
import { DocumentModule } from './document/document.module';
import { GiftModule } from './gift/gift.module';
import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module';
import { MoneyRequestModule } from './money-request/money-request.module';
import { SavingGoalsModule } from './saving-goals/saving-goals.module';
import { TaskModule } from './task/task.module';
import { UserModule } from './user/user.module';
import { CardModule } from './card/card.module';
import { WebhookModule } from './webhook/webhook.module';
import { MoneyRequestModule } from './money-request/money-request.module';
@Module({
controllers: [],
@ -43,7 +40,6 @@ import { CardModule } from './card/card.module';
useFactory: (config: ConfigService) => {
return buildTypeormOptions(config, migrations);
},
/* eslint-disable require-await */
async dataSourceFactory(options) {
if (!options) {
throw new Error('Invalid options passed');
@ -51,7 +47,6 @@ import { CardModule } from './card/card.module';
return addTransactionalDataSource(new DataSource(options));
},
/* eslint-enable require-await */
}),
LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config),
@ -63,15 +58,13 @@ import { CardModule } from './card/card.module';
ScheduleModule.forRoot(),
// App modules
AuthModule,
UserModule,
CustomerModule,
JuniorModule,
TaskModule,
GuardianModule,
SavingGoalsModule,
AllowanceModule,
MoneyRequestModule,
GiftModule,
CardModule,
NotificationModule,
OtpModule,
DocumentModule,
@ -79,11 +72,10 @@ import { CardModule } from './card/card.module';
HealthModule,
UserModule,
CronModule,
NeoLeapModule,
CardModule,
WebhookModule,
MoneyRequestModule,
],
providers: [
// Global Pipes

View File

@ -4,12 +4,12 @@ import { JwtModule } from '@nestjs/jwt';
import { JuniorModule } from '~/junior/junior.module';
import { UserModule } from '~/user/user.module';
import { AuthController } from './controllers';
import { AuthService, Oauth2Service } from './services';
import { AuthService } from './services';
import { AccessTokenStrategy } from './strategies';
@Module({
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
providers: [AuthService, AccessTokenStrategy, Oauth2Service],
providers: [AuthService, AccessTokenStrategy],
controllers: [AuthController],
exports: [],
})

View File

@ -6,23 +6,20 @@ import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import {
AppleLoginRequestDto,
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
GoogleLoginRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto,
VerifyLoginOtpRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
import { LoginResponseDto } from '../dtos/response/login.response.dto';
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
import { IJwtPayload } from '../interfaces';
import { AuthService } from '../services';
@ -44,98 +41,54 @@ export class AuthController {
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login/otp')
@HttpCode(HttpStatus.NO_CONTENT)
async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) {
return this.authService.sendLoginOtp(data);
}
@Post('login/verify')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) {
const [token, user] = await this.authService.verifyLoginOtp(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/google')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithGoogle(@Body() data: GoogleLoginRequestDto) {
const [token, user] = await this.authService.loginWithGoogle(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('login/apple')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async loginWithApple(@Body() data: AppleLoginRequestDto) {
const [token, user] = await this.authService.loginWithApple(data);
return ResponseFactory.data(new LoginResponseDto(token, user));
}
@Post('register/set-email')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) {
await this.authService.setEmail(sub, setEmailDto);
}
@Post('register/set-passcode')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) {
await this.authService.setPasscode(sub, passcode);
}
// @Post('register/set-phone/otp')
// @UseGuards(AccessTokenGuard)
// async setPhoneNumber(
// @AuthenticatedUser() { sub }: IJwtPayload,
// @Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto,
// ) {
// const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto);
// return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber));
// }
// @Post('register/set-phone/verify')
// @HttpCode(HttpStatus.NO_CONTENT)
// @UseGuards(AccessTokenGuard)
// async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) {
// await this.authService.verifyPhoneNumber(sub, otp);
// }
@Post('biometric/enable')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) {
return this.authService.enableBiometric(sub, enableBiometricDto);
}
@Post('biometric/disable')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) {
return this.authService.disableBiometric(sub, disableBiometricDto);
@Post('login')
async login(@Body() verifyUserDto: LoginRequestDto) {
const [res, user] = await this.authService.loginWithPassword(verifyUserDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('forget-password/otp')
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email));
const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber));
}
@Post('forget-password/verify')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(VerifyForgetPasswordOtpResponseDto)
async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) {
const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user));
}
@Post('forget-password/reset')
@HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
return this.authService.resetPassword(forgetPasswordDto);
}
@Post('junior/set-passcode')
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
changePassword(@AuthenticatedUser() { sub }: IJwtPayload, @Body() forgetPasswordDto: ChangePasswordRequestDto) {
return this.authService.changePassword(sub, forgetPasswordDto);
}
@Post('junior/set-password')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPasscode(setPasscodeDto);
setJuniorPasscode(@Body() setPassworddto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPassword(setPassworddto);
}
@Post('junior/login')
@HttpCode(HttpStatus.OK)
@ApiDataResponse(LoginResponseDto)
async juniorLogin(@Body() juniorLoginDto: JuniorLoginRequestDto) {
const [res, user] = await this.authService.juniorLogin(juniorLoginDto);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('refresh-token')

View File

@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class AppleAdditionalData {
@ApiProperty({ example: 'Ahmad' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
firstName!: string;
@ApiProperty({ example: 'Khan' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
}

View File

@ -1,21 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AppleAdditionalData } from './apple-additional-data.request.dto';
export class AppleLoginRequestDto {
@ApiProperty({ example: 'apple_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
appleToken!: string;
@ApiProperty({ type: AppleAdditionalData })
@ValidateNested({
each: true,
message: i18n('validation.ValidateNested', { path: 'general', property: 'auth.apple.additionalData' }),
})
@IsOptional()
@Type(() => AppleAdditionalData)
additionalData?: AppleAdditionalData;
}

View File

@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { PASSWORD_REGEX } from '~/auth/constants';
export class ChangePasswordRequestDto {
@ApiProperty({ example: 'currentPassword@123' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.currentPassword' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.currentPassword' }) })
currentPassword!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.newPassword' }),
})
newPassword!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmNewPassword' }),
})
confirmNewPassword!: string;
}

View File

@ -1,14 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { OmitType } from '@nestjs/swagger';
import { VerifyUserRequestDto } from './verify-user.request.dto';
export class CreateUnverifiedUserRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail(
{},
{
message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }),
},
)
email!: string;
}
export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {}

View File

@ -1,4 +0,0 @@
import { PickType } from '@nestjs/swagger';
import { EnableBiometricRequestDto } from './enable-biometric.request.dto';
export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {}

View File

@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class EnableBiometricRequestDto {
@ApiProperty({ example: 'device-id' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.deviceId' }) })
deviceId!: string;
@ApiProperty({ example: 'publicKey' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.publicKey' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.publicKey' }) })
publicKey!: string;
}

View File

@ -1,32 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator';
import { IsString, Matches } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class ForgetPasswordRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
@ApiProperty({ example: '+962' })
@Matches(COUNTRY_CODE_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode!: string;
@ApiProperty({ example: 'password' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) })
@ApiProperty({ example: '787259134' })
@IsValidPhoneNumber({
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
})
phoneNumber!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }),
})
password!: string;
@ApiProperty({ example: 'password' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) })
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
})
confirmPassword!: string;
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) },
)
@MaxLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
@MinLength(DEFAULT_OTP_LENGTH, {
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
@ApiProperty({ example: 'reset-token-32423123' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) })
resetPasswordToken!: string;
}

View File

@ -1,10 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class GoogleLoginRequestDto {
@ApiProperty({ example: 'google_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) })
googleToken!: string;
}

View File

@ -1,16 +1,11 @@
export * from './apple-login.request.dto';
export * from './change-password.request.dto';
export * from './create-unverified-user.request.dto';
export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto';
export * from './forget-password.request.dto';
export * from './google-login.request.dto';
export * from './junior-login.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './send-login-otp.request.dto';
export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto';
export * from './verify-login-otp.request.dto';
export * from './verify-forget-password-otp.request.dto';
export * from './verify-otp.request.dto';
export * from './verify-user.request.dto';

View File

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class JuniorLoginRequestDto {
@ApiProperty({ example: 'test@junior.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
@ApiProperty({ example: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
password!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -1,43 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator';
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
import { GrantType } from '~/auth/enums';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class LoginRequestDto {
@ApiProperty({ example: GrantType.APPLE })
@IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) })
grantType!: GrantType;
@ApiProperty({ example: '+962' })
@Matches(COUNTRY_CODE_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode!: string;
@ApiProperty({ example: 'test@test.com' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
@ValidateIf((o) => o.grantType !== GrantType.APPLE && o.grantType !== GrantType.GOOGLE)
email!: string;
@ApiProperty({ example: '787259134' })
@IsValidPhoneNumber({
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
})
phoneNumber!: string;
@ApiProperty({ example: '123456' })
@ApiProperty({ example: 'Abcd1234@' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string;
@ApiProperty({ example: 'Login signature' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) })
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
signature!: string;
@ApiProperty({ example: 'google_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) })
@ValidateIf((o) => o.grantType === GrantType.GOOGLE)
googleToken!: string;
@ApiProperty({ example: 'apple_token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) })
@ValidateIf((o) => o.grantType === GrantType.APPLE)
appleToken!: string;
@ApiProperty({ example: 'fcm-device-token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) })
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -1,4 +1,4 @@
import { PickType } from '@nestjs/swagger';
import { LoginRequestDto } from './login.request.dto';
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {}
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {}

View File

@ -1,9 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class SendLoginOtpRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
email!: string;
}

View File

@ -1,8 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, PickType } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { SetPasscodeRequestDto } from './set-passcode.request.dto';
export class setJuniorPasswordRequestDto extends SetPasscodeRequestDto {
import { ChangePasswordRequestDto } from './change-password.request.dto';
export class setJuniorPasswordRequestDto extends PickType(ChangePasswordRequestDto, [
'newPassword',
'confirmNewPassword',
]) {
@ApiProperty()
@IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.qrToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.qrToken' }) })

View File

@ -1,15 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
const PASSCODE_LENGTH = 6;
export class SetPasscodeRequestDto {
@ApiProperty({ example: '123456' })
@IsNumberString(
{ no_symbols: true },
{ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.passcode' }) },
)
@MinLength(PASSCODE_LENGTH, { message: i18n('validation.MinLength', { path: 'general', property: 'auth.passcode' }) })
@MaxLength(PASSCODE_LENGTH, { message: i18n('validation.MaxLength', { path: 'general', property: 'auth.passcode' }) })
passcode!: string;
}

View File

@ -1,10 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, PickType } from '@nestjs/swagger';
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { SendLoginOtpRequestDto } from './send-login-otp.request.dto';
import { ForgetPasswordRequestDto } from './forget-password.request.dto';
export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto {
export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [
'countryCode',
'phoneNumber',
]) {
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },

View File

@ -1,21 +1,32 @@
import { ApiProperty, PickType } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsNotEmpty,
IsNumberString,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants';
import { CountryIso } from '~/common/enums';
import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
import { IsAbove18 } from '~/core/decorators/validations';
import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto';
import { IsValidPhoneNumber } from '~/core/decorators/validations';
export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDto, ['email']) {
export class VerifyUserRequestDto {
@ApiProperty({ example: '+962' })
@Matches(COUNTRY_CODE_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
})
countryCode!: string;
@ApiProperty({ example: '787259134' })
@IsValidPhoneNumber({
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
})
phoneNumber!: string;
@ApiProperty({ example: 'John' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) })
@ -26,11 +37,6 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
lastName!: string;
@ApiProperty({ example: '2021-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
dateOfBirth!: Date;
@ApiProperty({ example: 'JO' })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
@ -38,6 +44,51 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt
@IsOptional()
countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA;
// Address fields (optional during registration, required for card creation)
@ApiProperty({ example: 'SA', description: 'Country code', required: false })
@IsEnum(CountryIso, {
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.country' }),
})
@IsOptional()
country?: CountryIso;
@ApiProperty({ example: 'Riyadh', description: 'Region/Province', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.region' }) })
@IsOptional()
region?: string;
@ApiProperty({ example: 'Riyadh', description: 'City', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.city' }) })
@IsOptional()
city?: string;
@ApiProperty({ example: 'Al Olaya', description: 'Neighborhood/District', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.neighborhood' }) })
@IsOptional()
neighborhood?: string;
@ApiProperty({ example: 'King Fahd Road', description: 'Street name', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.street' }) })
@IsOptional()
street?: string;
@ApiProperty({ example: '123', description: 'Building number', required: false })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.building' }) })
@IsOptional()
building?: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }),
})
password!: string;
@ApiProperty({ example: 'Abcd1234@' })
@Matches(PASSWORD_REGEX, {
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
})
confirmPassword!: string;
@ApiProperty({ example: '111111' })
@IsNumberString(
{ no_symbols: true },
@ -50,4 +101,27 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
})
otp!: string;
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
deviceId?: string;
@ApiProperty({
example: 'cXYzABC:APA91bHunvwY7rKpn8N7y6vDxS0qmQ5RZx2C8K...',
description: 'Firebase Cloud Messaging token for push notifications',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
fcmToken?: string;
@ApiProperty({
example: 'Asia/Riyadh',
description: 'Device timezone (auto-detected from device OS)',
required: false,
})
@IsOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) })
timezone?: string;
}

View File

@ -17,7 +17,7 @@ export class LoginResponseDto {
@ApiProperty({ example: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ example: CustomerResponseDto })
@ApiProperty({ type: CustomerResponseDto })
customer!: CustomerResponseDto | null;
constructor(IVerifyUserResponse: ILoginResponse, user: User) {

View File

@ -1,7 +1,7 @@
export class SendForgetPasswordOtpResponseDto {
email!: string;
maskedNumber!: string;
constructor(email: string) {
this.email = email;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}

View File

@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpResponseDto {
@ApiProperty()
email!: string;
maskedNumber!: string;
constructor(email: string) {
this.email = email;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class SendRegisterOtpV2ResponseDto {
@ApiProperty()
maskedNumber!: string;
constructor(maskedNumber: string) {
this.maskedNumber = maskedNumber;
}
}

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
export class UserResponseDto {
@ -7,42 +8,47 @@ export class UserResponseDto {
id!: string;
@ApiProperty()
email!: string;
countryCode!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
countryCode!: string;
email!: string;
@ApiProperty()
isPasswordSet!: boolean;
firstName!: string;
@ApiProperty()
isProfileCompleted!: boolean;
lastName!: string;
@ApiProperty()
isSmsEnabled!: boolean;
dateOfBirth!: Date;
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
profilePicture!: DocumentMetaResponseDto | null;
@ApiProperty()
isEmailEnabled!: boolean;
isPhoneVerified!: boolean;
@ApiProperty()
isPushEnabled!: boolean;
isEmailVerified!: boolean;
@ApiPropertyOptional({ enum: Gender, nullable: true })
gender!: Gender | null;
@ApiProperty()
roles!: Roles[];
constructor(user: User) {
this.id = user.id;
this.email = user.email;
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode;
this.isPasswordSet = user.isPasswordSet;
this.isProfileCompleted = user.isProfileCompleted;
this.isSmsEnabled = user.isSmsEnabled;
this.isEmailEnabled = user.isEmailEnabled;
this.isPushEnabled = user.isPushEnabled;
this.roles = user.roles;
this.phoneNumber = user.phoneNumber;
this.dateOfBirth = user.customer?.dateOfBirth;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
this.isEmailVerified = user.isEmailVerified;
this.isPhoneVerified = user.isPhoneVerified;
this.gender = (user.customer?.gender as Gender) || null;
}
}

View File

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/user/entities';
export class VerifyForgetPasswordOtpResponseDto {
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
countryCode!: string;
@ApiProperty()
resetPasswordToken!: string;
constructor(token: string, user: User) {
this.phoneNumber = user.phoneNumber;
this.countryCode = user.countryCode;
this.resetPasswordToken = token;
}
}

View File

@ -1,6 +1,4 @@
export enum GrantType {
PASSWORD = 'PASSWORD',
BIOMETRIC = 'BIOMETRIC',
GOOGLE = 'GOOGLE',
APPLE = 'APPLE',
}

View File

@ -1,11 +0,0 @@
export interface ApplePayload {
iss: string;
aud: string;
exp: number;
iat: number;
sub: string;
c_hash: string;
auth_time: number;
nonce_supported: boolean;
email?: string;
}

View File

@ -1,3 +1,2 @@
export * from './apple-payload.interface';
export * from './jwt-payload.interface';
export * from './login-response.interface';

View File

@ -3,33 +3,26 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { Request } from 'express';
import { ArrayContains } from 'typeorm';
import moment from 'moment';
import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services';
import { UserType } from '~/user/enums';
import { DeviceService, UserService, UserTokenService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
AppleLoginRequestDto,
ChangePasswordRequestDto,
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
GoogleLoginRequestDto,
JuniorLoginRequestDto,
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SendLoginOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
VerifyLoginOtpRequestDto,
VerifyForgetPasswordOtpRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { Roles } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { Oauth2Service } from './oauth2.service';
const ONE_THOUSAND = 1000;
const SALT_ROUNDS = 10;
@ -45,38 +38,45 @@ export class AuthService {
private readonly deviceService: DeviceService,
private readonly userTokenService: UserTokenService,
private readonly cacheService: CacheService,
private readonly oauth2Service: Oauth2Service,
) {}
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
this.logger.log(`Sending OTP to ${body.email}`);
const user = await this.userService.findOrCreateUser(body);
async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) {
if (body.password !== body.confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`);
const user = await this.userService.findOrCreateUser(body);
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.email,
scope: OtpScope.VERIFY_EMAIL,
otpType: OtpType.EMAIL,
recipient: user.fullPhoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
});
}
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
this.logger.log(`Verifying user with email ${verifyUserDto.email}`);
const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email });
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({
phoneNumber: verifyUserDto.phoneNumber,
countryCode: verifyUserDto.countryCode,
});
if (user.isEmailVerified) {
this.logger.error(`User with email ${verifyUserDto.email} already verified`);
throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED');
if (user.isPhoneVerified) {
this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`);
throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED');
}
const isOtpValid = await this.otpService.verifyOtp({
userId: user.id,
scope: OtpScope.VERIFY_EMAIL,
otpType: OtpType.EMAIL,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
value: verifyUserDto.otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${verifyUserDto.email}`);
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
@ -85,172 +85,125 @@ export class AuthService {
await user.reload();
const tokens = await this.generateAuthToken(user);
this.logger.log(`User with email ${verifyUserDto.email} verified successfully`);
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
// Register/update device with FCM token and timezone if provided
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone);
}
return [tokens, user];
}
async setEmail(userId: string, { email }: SetEmailRequestDto) {
this.logger.log(`Setting email for user with id ${userId}`);
const user = await this.userService.findUserOrThrow({ id: userId });
if (user.email) {
this.logger.error(`Email already set for user with id ${userId}`);
throw new BadRequestException('USER.EMAIL_ALREADY_SET');
}
const existingUser = await this.userService.findUser({ email });
if (existingUser) {
this.logger.error(`Email ${email} already taken`);
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
}
return this.userService.setEmail(userId, email);
}
async setPasscode(userId: string, passcode: string) {
this.logger.log(`Setting passcode for user with id ${userId}`);
const user = await this.userService.findUserOrThrow({ id: userId });
if (user.password) {
this.logger.error(`Passcode already set for user with id ${userId}`);
throw new BadRequestException('AUTH.PASSCODE_ALREADY_SET');
}
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(passcode, salt);
await this.userService.setPasscode(userId, hashedPasscode, salt);
this.logger.log(`Passcode set successfully for user with id ${userId}`);
}
// async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
// const user = await this.userService.findUserOrThrow({ id: userId });
// if (user.phoneNumber || user.countryCode) {
// this.logger.error(`Phone number already set for user with id ${userId}`);
// throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET');
// }
// const existingUser = await this.userService.findUser({ phoneNumber, countryCode });
// if (existingUser) {
// this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`);
// throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN');
// }
// await this.userService.setPhoneNumber(userId, phoneNumber, countryCode);
// return this.otpService.generateAndSendOtp({
// userId,
// recipient: countryCode + phoneNumber,
// scope: OtpScope.VERIFY_PHONE,
// otpType: OtpType.SMS,
// });
// }
// async verifyPhoneNumber(userId: string, otp: string) {
// const isOtpValid = await this.otpService.verifyOtp({
// otpType: OtpType.SMS,
// scope: OtpScope.VERIFY_PHONE,
// userId,
// value: otp,
// });
// if (!isOtpValid) {
// this.logger.error(`Invalid OTP for user with id ${userId}`);
// throw new BadRequestException('OTP.INVALID_OTP');
// }
// return this.userService.verifyPhoneNumber(userId);
// }
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
this.logger.log(`Enabling biometric for user with id ${userId}`);
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.log(`Device not found, creating new device for user with id ${userId}`);
return this.deviceService.createDevice({
deviceId,
userId,
publicKey,
});
}
if (device.publicKey) {
this.logger.error(`Biometric already enabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED');
}
return this.deviceService.updateDevice(deviceId, { publicKey });
}
async disableBiometric(userId: string, { deviceId }: DisableBiometricRequestDto) {
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.error(`Device not found for user with id ${userId} and device id ${deviceId}`);
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
}
if (!device.publicKey) {
this.logger.error(`Biometric already disabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
}
return this.deviceService.updateDevice(deviceId, { publicKey: null });
}
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
this.logger.log(`Sending forget password OTP to ${email}`);
const user = await this.userService.findUserOrThrow({ email });
if (!user.isProfileCompleted) {
this.logger.error(`Profile not completed for user with email ${email}`);
throw new BadRequestException('USER.PROFILE_NOT_COMPLETED');
}
async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) {
this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.email,
recipient: user.fullPhoneNumber,
scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.EMAIL,
otpType: OtpType.SMS,
});
}
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) {
this.logger.log(`Verifying forget password OTP for ${email}`);
const user = await this.userService.findUserOrThrow({ email });
if (!user.isProfileCompleted) {
this.logger.error(`Profile not completed for user with email ${email}`);
throw new BadRequestException('USER.PROFILE_NOT_COMPLETED');
}
async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
const isOtpValid = await this.otpService.verifyOtp({
userId: user.id,
scope: OtpScope.FORGET_PASSWORD,
otpType: OtpType.EMAIL,
otpType: OtpType.SMS,
value: otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
this.validatePassword(password, confirmPassword, user);
// generate a token for the user to reset password
const token = await this.userTokenService.generateToken(user.id, moment().add(5, 'minutes').toDate());
return { token, user };
}
async resetPassword({
countryCode,
phoneNumber,
resetPasswordToken,
password,
confirmPassword,
}: ForgetPasswordRequestDto) {
this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`);
const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber });
await this.userTokenService.validateToken(
resetPasswordToken,
user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR,
);
if (password !== confirmPassword) {
this.logger.error('Password and confirm password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const isOldPassword = bcrypt.compareSync(password, user.password);
if (isOldPassword) {
this.logger.error(
`New password cannot be the same as the current password for user with phone number ${user.fullPhoneNumber}`,
);
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
const hashedPassword = bcrypt.hashSync(password, user.salt);
await this.userService.setPasscode(user.id, hashedPassword, user.salt);
this.logger.log(`Passcode updated successfully for user with email ${email}`);
await this.userService.setPassword(user.id, hashedPassword, user.salt);
await this.userTokenService.invalidateToken(resetPasswordToken);
this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`);
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
async changePassword(userId: string, { currentPassword, newPassword, confirmNewPassword }: ChangePasswordRequestDto) {
const user = await this.userService.findUserOrThrow({ id: userId });
if (!user.isPasswordSet) {
this.logger.error(`Password not set for user with id ${userId}`);
throw new BadRequestException('AUTH.PASSWORD_NOT_SET');
}
if (currentPassword === newPassword) {
this.logger.error('New password cannot be the same as current password');
throw new BadRequestException('AUTH.PASSWORD_SAME_AS_CURRENT');
}
if (newPassword !== confirmNewPassword) {
this.logger.error('New password and confirm new password do not match');
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
this.logger.log(`Validating current password for user with id ${userId}`);
const isCurrentPasswordValid = bcrypt.compareSync(currentPassword, user.password);
if (!isCurrentPasswordValid) {
this.logger.error(`Invalid current password for user with id ${userId}`);
throw new UnauthorizedException('AUTH.INVALID_CURRENT_PASSWORD');
}
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedNewPassword = bcrypt.hashSync(newPassword, salt);
await this.userService.setPassword(user.id, hashedNewPassword, salt);
this.logger.log(`Password changed successfully for user with id ${userId}`);
}
async setJuniorPassword(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
if (body.newPassword != body.confirmNewPassword) {
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
await this.userService.setPasscode(juniorId!, hashedPasscode, salt);
const hashedPasscode = bcrypt.hashSync(body.newPassword, salt);
await this.userService.setPassword(juniorId!, hashedPasscode, salt);
await this.userTokenService.invalidateToken(body.qrToken);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
}
@ -291,40 +244,6 @@ export class AuthService {
}
}
async sendLoginOtp({ email }: SendLoginOtpRequestDto) {
const user = await this.userService.findUserOrThrow({ email });
this.logger.log(`Sending login OTP to ${email}`);
return this.otpService.generateAndSendOtp({
recipient: email,
scope: OtpScope.LOGIN,
otpType: OtpType.EMAIL,
userId: user.id,
});
}
async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ email });
this.logger.log(`Verifying login OTP for ${email}`);
const isOtpValid = await this.otpService.verifyOtp({
otpType: OtpType.EMAIL,
scope: OtpScope.LOGIN,
userId: user.id,
value: otp,
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
throw new BadRequestException('OTP.INVALID_OTP');
}
this.logger.log(`Login OTP verified successfully for ${email}`);
const token = await this.generateAuthToken(user);
return [token, user];
}
logout(req: Request) {
this.logger.log('Logging out');
const accessToken = req.headers.authorization?.split(' ')[1] as string;
@ -332,146 +251,126 @@ export class AuthService {
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
}
private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ email: loginDto.email });
async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({
countryCode: loginDto.countryCode,
phoneNumber: loginDto.phoneNumber,
});
this.logger.log(`validating password for user with email ${loginDto.email}`);
if (!user) {
this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
if (!user.password) {
this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED');
}
this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${loginDto.email}`);
this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user with email ${loginDto.email}`);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token and timezone if provided
if (loginDto.fcmToken && loginDto.deviceId) {
await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken, loginDto.timezone);
}
return [tokens, user];
}
private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUserOrThrow({ email: loginDto.email });
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
const user = await this.userService.findUser({ email: juniorLoginDto.email });
this.logger.log(`validating biometric for user with email ${loginDto.email}`);
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
if (!device) {
this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`);
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND');
if (!user || !user.roles.includes(Roles.JUNIOR)) {
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
if (!device.publicKey) {
this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
}
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
const cleanToken = removePadding(loginDto.signature);
const isValidToken = await verifySignature(
device.publicKey,
cleanToken,
`${user.email} - ${device.deviceId}`,
'SHA1',
);
if (!isValidToken) {
this.logger.error(`Invalid biometric for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`);
this.logger.log(`Password validated successfully for user`);
// Register/update device with FCM token and timezone if provided
if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) {
await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken, juniorLoginDto.timezone);
}
return [tokens, user];
}
async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> {
const {
email,
sub,
given_name: firstName,
family_name: lastName,
} = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken);
const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([
this.userService.findUser({ googleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
this.userService.findUser({ email }),
]);
/**
* Register or update device with FCM token and timezone
* This method handles:
* 1. Device already exists for this user → Update FCM token and timezone
* 2. Device exists for different user → Transfer device to new user
* 3. Device doesn't exist → Create new device
*/
private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string, timezone?: string): Promise<void> {
try {
this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`);
if (isJunior) {
this.logger.error(`User with email ${email} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
// Step 1: Check if device already exists for this user
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!existingUser && existingUserWithEmail) {
this.logger.error(`User with email ${email} already exists adding google id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
if (!existingUser && !existingUserWithEmail) {
this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`);
const user = await this.userService.createGoogleUser(sub, email, firstName, lastName);
const tokens = await this.generateAuthToken(user);
return [tokens, user];
}
const tokens = await this.generateAuthToken(existingUser!);
return [tokens, existingUser!];
}
async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> {
const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken);
const [existingUserWithSub, isJunior] = await Promise.all([
this.userService.findUser({ appleId: sub }),
this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }),
]);
if (isJunior) {
this.logger.error(`User with apple id ${sub} is an already registered junior`);
throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
}
if (email) {
const existingUserWithEmail = await this.userService.findUser({ email });
if (existingUserWithEmail && !existingUserWithSub) {
{
this.logger.error(`User with email ${email} already exists adding apple id to existing user`);
await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub });
const tokens = await this.generateAuthToken(existingUserWithEmail);
return [tokens, existingUserWithEmail];
}
}
}
if (!existingUserWithSub) {
// Apple only provides email if user authorized zod for the first time
if (!email || !loginDto.additionalData) {
this.logger.error(`User authorized zod before but his email is not stored in the database`);
throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED');
if (existingDeviceForUser) {
// Device exists for this user → Update FCM token, timezone, and last access time
await this.deviceService.updateDevice(deviceId, {
fcmToken,
userId,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} updated with new FCM token and timezone for user ${userId}`);
return;
}
this.logger.debug(`User with apple id ${sub} not found, creating new user`);
const user = await this.userService.createAppleUser(
sub,
email,
loginDto.additionalData.firstName,
loginDto.additionalData.lastName,
);
// Step 2: Check if device exists for any user (different user scenario)
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
const tokens = await this.generateAuthToken(user);
if (existingDevice) {
// Device exists for different user → Transfer device to new user
this.logger.log(
`Device ${deviceId} exists for user ${existingDevice.userId}, transferring to user ${userId}`
);
await this.deviceService.updateDevice(deviceId, {
userId,
fcmToken,
timezone, // Update timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`);
return;
}
return [tokens, user];
// Step 3: Device doesn't exist → Create new device
await this.deviceService.createDevice({
deviceId,
userId,
fcmToken,
timezone, // Store timezone if provided
lastAccessOn: new Date(),
});
this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`);
} catch (error) {
// Log error but don't fail the login/signup process
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to register device token for user ${userId}: ${errorMessage}`, errorStack);
}
const tokens = await this.generateAuthToken(existingUserWithSub);
this.logger.log(`User with apple id ${sub} logged in successfully`);
return [tokens, existingUserWithSub];
}
private async generateAuthToken(user: User) {
@ -496,19 +395,4 @@ export class AuthService {
this.logger.log(`Auth token generated successfully for user with id ${user.id}`);
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
}
private validatePassword(password: string, confirmPassword: string, user: User) {
this.logger.log(`Validating password for user with id ${user.id}`);
if (password !== confirmPassword) {
this.logger.error(`Password mismatch for user with id ${user.id}`);
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
if (!PASSCODE_REGEX.test(password)) {
this.logger.error(`Invalid password for user with id ${user.id}`);
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
}
private validateGoogleToken(googleToken: string) {}
}

View File

@ -1,2 +1 @@
export * from './auth.service';
export * from './oauth2.service';

View File

@ -1,83 +0,0 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { OAuth2Client } from 'google-auth-library';
import jwkToPem from 'jwk-to-pem';
import { lastValueFrom } from 'rxjs';
import { ApplePayload } from '../interfaces';
@Injectable()
export class Oauth2Service {
private readonly logger = new Logger(Oauth2Service.name);
private appleKeysEndpoint = 'https://appleid.apple.com/auth/keys';
private appleIssuer = 'https://appleid.apple.com';
private readonly googleWebClientId = this.configService.getOrThrow('GOOGLE_WEB_CLIENT_ID');
private readonly googleAndroidClientId = this.configService.getOrThrow('GOOGLE_ANDROID_CLIENT_ID');
private readonly googleIosClientId = this.configService.getOrThrow('GOOGLE_IOS_CLIENT_ID');
private readonly client = new OAuth2Client();
constructor(
private readonly httpService: HttpService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async verifyAppleToken(appleToken: string): Promise<ApplePayload> {
try {
const response = await lastValueFrom(this.httpService.get(this.appleKeysEndpoint));
const keys = response.data.keys;
const decodedHeader = this.jwtService.decode(appleToken, { complete: true })?.header;
if (!decodedHeader) {
this.logger.error(`Invalid apple token`);
throw new UnauthorizedException();
}
const keyId = decodedHeader.kid;
const appleKey = keys.find((key: any) => key.kid === keyId);
if (!appleKey) {
this.logger.error(`Invalid apple token`);
throw new UnauthorizedException();
}
const publicKey = jwkToPem(appleKey);
const payload = this.jwtService.verify(appleToken, {
publicKey,
algorithms: ['RS256'],
audience: this.configService.getOrThrow('APPLE_CLIENT_ID').split(','),
issuer: this.appleIssuer,
});
return payload;
} catch (error) {
this.logger.error(`Error verifying apple token: ${error} `);
throw new UnauthorizedException(error);
}
}
async verifyGoogleToken(googleToken: string): Promise<any> {
try {
const ticket = await this.client.verifyIdToken({
idToken: googleToken,
audience: [this.googleWebClientId, this.googleAndroidClientId, this.googleIosClientId],
});
const payload = ticket.getPayload();
if (!payload) {
this.logger.error(`payload not found in google token`);
throw new UnauthorizedException();
}
return payload;
} catch (error) {
this.logger.error(`Invalid google token`, error);
throw new UnauthorizedException();
}
}
}

View File

@ -1,5 +1,8 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NeoLeapModule } from '~/common/modules/neoleap/neoleap.module';
import { CustomerModule } from '~/customer/customer.module';
import { CardsController } from './controllers';
import { Card } from './entities';
import { Account } from './entities/account.entity';
import { Transaction } from './entities/transaction.entity';
@ -11,7 +14,11 @@ import { AccountService } from './services/account.service';
import { TransactionService } from './services/transaction.service';
@Module({
imports: [TypeOrmModule.forFeature([Card, Account, Transaction])],
imports: [
TypeOrmModule.forFeature([Card, Account, Transaction]),
forwardRef(() => NeoLeapModule),
forwardRef(() => CustomerModule), // <-- add forwardRef here
],
providers: [
CardService,
CardRepository,
@ -20,6 +27,7 @@ import { TransactionService } from './services/transaction.service';
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
exports: [CardService, TransactionService, AccountService],
controllers: [CardsController],
})
export class CardModule {}

View File

@ -0,0 +1,86 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { FundIbanRequestDto } from '../dtos/requests';
import { AccountIbanResponseDto, CardResponseDto, ChildCardResponseDto } from '../dtos/responses';
import { CardService } from '../services';
@Controller('cards')
@ApiBearerAuth()
@ApiTags('Cards')
@UseGuards(AccessTokenGuard)
export class CardsController {
constructor(private readonly cardService: CardService) {}
@Post()
@ApiDataResponse(CardResponseDto)
async createCard(@AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.createCard(sub);
return ResponseFactory.data(new CardResponseDto(card));
}
@Get('child-cards')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCards(@AuthenticatedUser() { sub }: IJwtPayload) {
const cards = await this.cardService.getChildCards(sub);
return ResponseFactory.data(cards.map((card) => new ChildCardResponseDto(card)));
}
@Get('child-cards/:childid')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCardById(@Param('childid') childId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByChildId(sub, childId);
return ResponseFactory.data(new ChildCardResponseDto(card));
}
@Get('child-cards/:cardid/embossing-details')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(CardEmbossingDetailsResponseDto)
async getChildCardEmbossingDetails(@Param('cardid') cardId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const res = await this.cardService.getChildCardEmbossingInformation(cardId, sub);
return ResponseFactory.data(res);
}
@Get('current')
@ApiDataResponse(CardResponseDto)
async getCurrentCard(@AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByCustomerId(sub);
return ResponseFactory.data(new CardResponseDto(card));
}
@Get('embossing-details')
@ApiDataResponse(CardEmbossingDetailsResponseDto)
async getCardById(@AuthenticatedUser() { sub }: IJwtPayload) {
const res = await this.cardService.getEmbossingInformation(sub);
return ResponseFactory.data(res);
}
@Get('iban')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AccountIbanResponseDto)
async getCardIban(@AuthenticatedUser() { sub }: IJwtPayload) {
const iban = await this.cardService.getIbanInformation(sub);
return ResponseFactory.data(new AccountIbanResponseDto(iban));
}
@Post('mock/fund-iban')
@ApiOperation({ summary: 'Mock endpoint to fund the IBAN - For testing purposes only' })
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
fundIban(@Body() { amount, iban }: FundIbanRequestDto) {
return this.cardService.fundIban(iban, amount);
}
}

View File

@ -0,0 +1 @@
export * from './cards.controller';

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { TransferToJuniorRequestDto } from '~/junior/dtos/request';
export class FundIbanRequestDto extends TransferToJuniorRequestDto {
@ApiProperty({ example: 'DE89370400440532013000' })
@IsString()
iban!: string;
}

View File

@ -0,0 +1 @@
export * from './fund-iban.request.dto';

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