mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 18:41:46 +00:00
Compare commits
273 Commits
feat/forge
...
8d56a8da0f
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d56a8da0f | |||
| 1a0bd0bf91 | |||
| 52fb5f3984 | |||
| db946b9531 | |||
| d34508dca9 | |||
| 25ede3c9e7 | |||
| 47b825c4b2 | |||
| f5c3b03264 | |||
| 6a250efd5e | |||
| a09b84e475 | |||
| 604cb7ce25 | |||
| 4305c4b75f | |||
| ef5572440c | |||
| 64623c7cea | |||
| 4ca8123a67 | |||
| 7c9e0f0b51 | |||
| e734060c52 | |||
| 1086166e04 | |||
| 5e6c8d96de | |||
| 6d6dc1471f | |||
| 0f56381703 | |||
| 1b0d6cb284 | |||
| 887bd20217 | |||
| c963b57904 | |||
| 2e21acac7f | |||
| 145e6c62b8 | |||
| 652359b1bf | |||
| 45acf73a4a | |||
| 2d6524be9f | |||
| d3ff755439 | |||
| 3ab00dfc29 | |||
| 21653efc46 | |||
| 11b2b25adc | |||
| 63b0a42eca | |||
| ed8cf4b4f8 | |||
| b1cda5e7dc | |||
| 2c8de913f8 | |||
| 98f6aaf01f | |||
| 170aa903c7 | |||
| 16f8756b74 | |||
| 2f74aa36a9 | |||
| f849003142 | |||
| 2562515574 | |||
| 93b509b256 | |||
| 9c93a35093 | |||
| d77d59a793 | |||
| 110a6fb0ee | |||
| 83787c7c67 | |||
| 24bcb10d76 | |||
| a3cdf50cb7 | |||
| cfd02e8c30 | |||
| 0fb76d712d | |||
| 5e708c16fe | |||
| fe11f35b32 | |||
| 3200f60821 | |||
| 24521c4223 | |||
| e8127970f6 | |||
| 07d4a83cf9 | |||
| ce1f6341b7 | |||
| 2a62787c3b | |||
| 91dea22f45 | |||
| ef28c75f9b | |||
| c007ac584f | |||
| d2d83549b2 | |||
| 506974afc8 | |||
| 95f8cfbfdf | |||
| 8b00cda23d | |||
| 12cc88a50e | |||
| 2172051093 | |||
| a6a573957c | |||
| d6fb5f48d9 | |||
| b0011eb7cc | |||
| 99af65a300 | |||
| 0c9b40132a | |||
| 3b295ea79f | |||
| 5ffe18ede3 | |||
| a3a61b4923 | |||
| 39d5fc1869 | |||
| 05a6ad2d84 | |||
| 5649d24724 | |||
| bbeece9e03 | |||
| 596562f6dc | |||
| 10de8f69c9 | |||
| 8a6b1cc900 | |||
| d16ae66252 | |||
| e966f95463 | |||
| 2714255dd1 | |||
| 39a0b131b8 | |||
| 4f778f7904 | |||
| 7e9bc397a9 | |||
| 7bfc14f0d9 | |||
| d2e084d3e4 | |||
| f81714a525 | |||
| f3282a680b | |||
| 7b57277a7f | |||
| fdd2e23669 | |||
| d70ab09960 | |||
| 297a2fe5ad | |||
| 33b4f13ec8 | |||
| 310233c519 | |||
| 15621124ad | |||
| 7fc1918de0 | |||
| f6fa74897a | |||
| dd6886ff2b | |||
| 649191f3f4 | |||
| 183f6b4475 | |||
| 8f601b26ae | |||
| 918b15c315 | |||
| 1830d92cbd | |||
| 44124b9964 | |||
| 454ded627f | |||
| f1484e125b | |||
| df4d2e3c1f | |||
| 872d231f72 | |||
| cc4c8254f6 | |||
| 039c95aa56 | |||
| e1f50decfa | |||
| 11712bedf3 | |||
| e6642b5a15 | |||
| 954aa422a2 | |||
| 15a48e4884 | |||
| d768da70f2 | |||
| 9b0e1791da | |||
| 44b5937f7a | |||
| edddc2f457 | |||
| 88730a2b2b | |||
| 3df34c0017 | |||
| 7dd309e0e3 | |||
| 4552a7fc93 | |||
| 740135051d | |||
| 3222aa4a66 | |||
| 6602414779 | |||
| 7291447c5a | |||
| d437b21dc3 | |||
| e06642225a | |||
| c06086f899 | |||
| e775561a89 | |||
| 241f1ce427 | |||
| d883bd2d9a | |||
| cd800ff8b8 | |||
| 05a9f04ac8 | |||
| dcc9077392 | |||
| 681d1e5791 | |||
| bf505a65bf | |||
| 6bf32d27c7 | |||
| ac63d4cdc7 | |||
| 150027fb71 | |||
| e8ee74d0d7 | |||
| 5f2e06edf9 | |||
| 99ad17f0f9 | |||
| ee7b365527 | |||
| 275984954e | |||
| 6f7fb2bdcd | |||
| 1e2b859b92 | |||
| 4cc52a1c07 | |||
| 7461af20dd | |||
| f65a7d2933 | |||
| fce720237f | |||
| 5e0a4e6bd1 | |||
| f9776e60cf | |||
| 7e63abb2fb | |||
| a245545811 | |||
| 4cb5814cd3 | |||
| 9e06ea4d71 | |||
| cff87c4ecd | |||
| 1541c374ed | |||
| c493bd57e1 | |||
| bf43e62b17 | |||
| 5a780eeb17 | |||
| 038b8ef6e3 | |||
| 3b3f8c0104 | |||
| 2770cf8774 | |||
| bea3ccfbbc | |||
| 492e538eb8 | |||
| d3057beb54 | |||
| 19fa53c981 | |||
| d2cc02fb60 | |||
| 4cbbfd8136 | |||
| 6c859a25d2 | |||
| d1a6d3e715 | |||
| 1ea1f42169 | |||
| d4fe3b3fc3 | |||
| b44bc5d5cc | |||
| 9aa6c487ed | |||
| 42e4d75d70 | |||
| a358cd2e7a | |||
| 641a665beb | |||
| 49326e983f | |||
| 881d88c8d8 | |||
| 35ab3df7c1 | |||
| cbade0a87d | |||
| 4c6ef17525 | |||
| ffca6996fd | |||
| a3f88c774c | |||
| ec38b82a7b | |||
| 9b5f863577 | |||
| 54ce5b022d | |||
| dae9cb6323 | |||
| 270753cfd7 | |||
| 6b1cb3a84e | |||
| ebd4b293e9 | |||
| 87bb1a2709 | |||
| 663e8972c4 | |||
| 8ff9f921e8 | |||
| 6d2d2b558a | |||
| 5aa3d3774d | |||
| 221f3bae4f | |||
| 62621c1a15 | |||
| 756e947c8a | |||
| db02a28b4d | |||
| afc087ff08 | |||
| ee433a5c8c | |||
| 3ab0f179d8 | |||
| 25ef549417 | |||
| 084d39096c | |||
| eca84b4e75 | |||
| aefa866ae7 | |||
| 557ef4cd33 | |||
| eea6302dda | |||
| c0fafd3f7c | |||
| f7290419d2 | |||
| 0fd2066c4a | |||
| cb54311a7b | |||
| ca71632755 | |||
| ebf335eabd | |||
| f383f6d14d | |||
| 5663a287f9 | |||
| a7028fa64c | |||
| 0750509a85 | |||
| 4d9ebe729e | |||
| bb8cc33d53 | |||
| e933cacdcf | |||
| 3719498c2f | |||
| c7470302bd | |||
| 5e9e83cb74 | |||
| 4cef58580e | |||
| 0ba09cbf8b | |||
| 28a2cb5d75 | |||
| 4961a192ea | |||
| 8ab47f3835 | |||
| 8112fb81a2 | |||
| 2c3c862c4a | |||
| 93f5d83825 | |||
| ea60ac3d7b | |||
| 0748695f23 | |||
| a201692c0c | |||
| fd6c1d1442 | |||
| ed57ce6e91 | |||
| 33453b193f | |||
| b0972f1a0a | |||
| 7437403756 | |||
| 4d2f6f57f4 | |||
| 24d990592d | |||
| 5b7b7ff689 | |||
| 6fccacd085 | |||
| 51fa61dbc6 | |||
| 4867a5f858 | |||
| 687b6a5c6d | |||
| e6ed1772f7 | |||
| 1f0a14fee4 | |||
| eb70828ae0 | |||
| 220a03cc46 | |||
| 39b1e76bb5 | |||
| 83fc634d25 | |||
| 35b434bc3d | |||
| 749ee5457f | |||
| d539073f29 | |||
| 66e1bb0f28 | |||
| 577f91b796 | |||
| 7ed37c30e1 | |||
| c2f63ccc72 | |||
| 970a41c895 | |||
| 3fd29b3905 |
11
.env.example
11
.env.example
@ -28,4 +28,13 @@ MAIL_HOST=smtp.gmail.com
|
|||||||
MAIL_USER=aahalhmad@gmail.com
|
MAIL_USER=aahalhmad@gmail.com
|
||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_FROM=UBA
|
MAIL_FROM=UBA
|
||||||
|
|
||||||
|
|
||||||
|
BRANCH_IO_URL=https://api2.branch.io/v1/url
|
||||||
|
BRANCH_IO_KEY=
|
||||||
|
ZOD_BASE_URL=http://localhost:5001
|
||||||
|
ANDROID_PACKAGE_NAME=com.zod
|
||||||
|
IOS_PACKAGE_NAME=com.zod
|
||||||
|
ANDRIOD_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate
|
||||||
|
IOS_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate
|
||||||
@ -29,7 +29,7 @@ module.exports = {
|
|||||||
'require-await': ['error'],
|
'require-await': ['error'],
|
||||||
'no-console': ['error'],
|
'no-console': ['error'],
|
||||||
'no-multi-assign': ['error'],
|
'no-multi-assign': ['error'],
|
||||||
'no-magic-numbers': ['error', { ignoreArrayIndexes: true }],
|
'no-magic-numbers': ['error', { ignoreArrayIndexes: true}],
|
||||||
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
|
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
|
||||||
'max-len': [
|
'max-len': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -53,3 +53,5 @@ pids
|
|||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
|
||||||
|
zod-certs
|
||||||
|
|||||||
291
ALLOWANCE_SCHEDULING_SYSTEM.md
Normal file
291
ALLOWANCE_SCHEDULING_SYSTEM.md
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# Allowance Scheduling System (Backend)
|
||||||
|
|
||||||
|
This document captures the complete allowance scheduling feature as implemented in the backend.
|
||||||
|
It is intended as a long-term reference for how the system works, why it was built this way,
|
||||||
|
and how to operate/extend it safely.
|
||||||
|
|
||||||
|
## Goals and Scope
|
||||||
|
|
||||||
|
- Allow parents/guardians to create a recurring allowance schedule for a child.
|
||||||
|
- Credit the allowance automatically based on the schedule.
|
||||||
|
- Scale safely with multiple workers and avoid duplicate credits.
|
||||||
|
- Keep cron lightweight and offload work to RabbitMQ workers.
|
||||||
|
|
||||||
|
Non-goals in the current implementation:
|
||||||
|
- UI changes
|
||||||
|
- Analytics/reporting views
|
||||||
|
- Advanced scheduling (e.g., custom weekdays)
|
||||||
|
|
||||||
|
## High-Level Flow
|
||||||
|
|
||||||
|
1. Parent creates a schedule via API.
|
||||||
|
2. Cron runs every 5 minutes and enqueues due schedules into RabbitMQ.
|
||||||
|
3. Workers consume queue messages, credit the allowance, and update the next run.
|
||||||
|
4. Idempotency is enforced in the database to prevent duplicate credits.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Table: `allowance_schedules`
|
||||||
|
|
||||||
|
Purpose: Stores the schedule definition (amount, frequency, status, next run).
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
- `guardian_id`, `junior_id`: who funds and who receives.
|
||||||
|
- `amount`: the allowance amount.
|
||||||
|
- `frequency`: DAILY / WEEKLY / MONTHLY.
|
||||||
|
- `status`: ON / OFF.
|
||||||
|
- `next_run_at`, `last_run_at`: scheduling metadata.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Unique `(guardian_id, junior_id)` ensures one schedule per child.
|
||||||
|
|
||||||
|
Entity: `src/allowance/entities/allowance-schedule.entity.ts`
|
||||||
|
|
||||||
|
### Table: `allowance_credits`
|
||||||
|
|
||||||
|
Purpose: Audit log and idempotency guard for each credit run.
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
- `schedule_id`: which schedule executed.
|
||||||
|
- `transaction_id`: the resulting transaction (nullable).
|
||||||
|
- `amount`, `run_at`, `credited_at`.
|
||||||
|
|
||||||
|
Idempotency:
|
||||||
|
- Unique `(schedule_id, run_at)` prevents duplicates even with multiple workers.
|
||||||
|
|
||||||
|
Entity: `src/allowance/entities/allowance-credit.entity.ts`
|
||||||
|
|
||||||
|
### Table: `cron_runs`
|
||||||
|
|
||||||
|
Purpose: shared audit table for all cron jobs (not just allowance).
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
- `job_name`: unique identifier for the cron.
|
||||||
|
- `status`: SUCCESS / FAILED.
|
||||||
|
- `processed_count`: number of schedules processed.
|
||||||
|
- `error_message`: failure reason.
|
||||||
|
- `started_at`, `finished_at`.
|
||||||
|
|
||||||
|
Entity: `src/cron/entities/cron-run.entity.ts`
|
||||||
|
|
||||||
|
## API (Schedule Creation)
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
- `POST /guardians/me/allowances/:juniorId`
|
||||||
|
|
||||||
|
Input DTO:
|
||||||
|
- `amount` (required, numeric, positive)
|
||||||
|
- `frequency` (required, enum)
|
||||||
|
- `status` (required, enum)
|
||||||
|
|
||||||
|
DTO: `src/allowance/dtos/request/create-allowance-schedule.request.dto.ts`
|
||||||
|
|
||||||
|
Response DTO:
|
||||||
|
- `AllowanceScheduleResponseDto` with schedule fields.
|
||||||
|
|
||||||
|
DTO: `src/allowance/dtos/response/allowance-schedule.response.dto.ts`
|
||||||
|
|
||||||
|
Business validation:
|
||||||
|
- Child must belong to guardian.
|
||||||
|
- No duplicate schedule for the same guardian+child.
|
||||||
|
|
||||||
|
Service: `src/allowance/services/allowance.service.ts`
|
||||||
|
|
||||||
|
## Cron Producer (Queue Enqueue)
|
||||||
|
|
||||||
|
Cron job:
|
||||||
|
- Runs every 5 minutes.
|
||||||
|
- Batches schedules (cursor-based) to avoid large load.
|
||||||
|
- Enqueues each schedule to RabbitMQ.
|
||||||
|
|
||||||
|
Cron: `src/cron/tasks/allowance-schedule.cron.ts`
|
||||||
|
|
||||||
|
Batch behavior:
|
||||||
|
- Batch size = 100
|
||||||
|
- Uses a cursor (`nextRunAt`, `id`) for stable pagination.
|
||||||
|
- Prevents re-reading the same rows.
|
||||||
|
|
||||||
|
Locking:
|
||||||
|
- `BaseCronService` uses cache lock to ensure only one instance runs.
|
||||||
|
- Each cron run is logged to `cron_runs` with status and processed count.
|
||||||
|
|
||||||
|
Lock: `src/cron/services/base-cron.service.ts`
|
||||||
|
|
||||||
|
## Queue Publisher
|
||||||
|
|
||||||
|
Queue publisher:
|
||||||
|
- Asserts queue + retry/DLQ exchanges.
|
||||||
|
- Enqueues jobs with message id (for traceability).
|
||||||
|
|
||||||
|
Service: `src/allowance/services/allowance-queue.service.ts`
|
||||||
|
|
||||||
|
Queue names:
|
||||||
|
- `allowance.schedule` (main)
|
||||||
|
- `allowance.schedule.retry` (retry with TTL)
|
||||||
|
- `allowance.schedule.dlq` (dead-letter queue)
|
||||||
|
|
||||||
|
Constants: `src/allowance/constants/allowance-queue.constants.ts`
|
||||||
|
|
||||||
|
## Worker Consumer (Transfers)
|
||||||
|
|
||||||
|
Worker:
|
||||||
|
- Consumes `allowance.schedule` queue.
|
||||||
|
- Validates schedule is due and active.
|
||||||
|
- Creates `allowance_credits` record for idempotency.
|
||||||
|
- Transfers money via `cardService.transferToChild`.
|
||||||
|
- Updates `last_run_at` and `next_run_at`.
|
||||||
|
|
||||||
|
Worker: `src/allowance/services/allowance-worker.service.ts`
|
||||||
|
|
||||||
|
Transfer:
|
||||||
|
- Uses existing logic in `card.service.ts` for balance updates and transaction creation.
|
||||||
|
|
||||||
|
Service: `src/card/services/card.service.ts`
|
||||||
|
|
||||||
|
### Idempotency details
|
||||||
|
|
||||||
|
1. Worker inserts `allowance_credits` row first.
|
||||||
|
2. Unique constraint blocks duplicates.
|
||||||
|
3. If transfer fails, the credit row is removed so the job can retry.
|
||||||
|
|
||||||
|
This makes multiple workers safe.
|
||||||
|
|
||||||
|
## Retry + DLQ Strategy
|
||||||
|
|
||||||
|
Retry delay:
|
||||||
|
- Failed jobs are dead-lettered to retry queue.
|
||||||
|
- Retry queue has `messageTtl = 10 minutes`.
|
||||||
|
- After TTL, job is routed back to main queue.
|
||||||
|
|
||||||
|
DLQ:
|
||||||
|
- If a job fails `ALLOWANCE_MAX_RETRIES` times (default 5), it is routed to the DLQ.
|
||||||
|
- This prevents endless loops and allows manual inspection.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
- `ALLOWANCE_MAX_RETRIES` (default 5)
|
||||||
|
|
||||||
|
## Redis Usage
|
||||||
|
|
||||||
|
Redis is used by `BaseCronService` to enforce a **distributed lock** for the cron job.
|
||||||
|
This prevents multiple backend instances from enqueuing the same schedules at the same time.
|
||||||
|
|
||||||
|
Lock behavior:
|
||||||
|
- If the lock key exists, cron exits early.
|
||||||
|
- If the lock key is absent, cron sets it with a TTL and proceeds.
|
||||||
|
- TTL ensures the lock is released even if a node crashes.
|
||||||
|
|
||||||
|
Service: `src/cron/services/base-cron.service.ts`
|
||||||
|
|
||||||
|
## RabbitMQ Setup and Behavior
|
||||||
|
|
||||||
|
The allowance system uses RabbitMQ for asynchronous processing. Cron publishes due schedules,
|
||||||
|
and workers consume them.
|
||||||
|
|
||||||
|
### Exchanges and Queues
|
||||||
|
|
||||||
|
Main queue:
|
||||||
|
- `allowance.schedule`
|
||||||
|
|
||||||
|
Retry setup:
|
||||||
|
- Exchange: `allowance.schedule.retry.exchange`
|
||||||
|
- Queue: `allowance.schedule.retry`
|
||||||
|
- Binding key: `allowance.schedule`
|
||||||
|
- TTL: 10 minutes
|
||||||
|
- Dead-letter route: back to `allowance.schedule`
|
||||||
|
|
||||||
|
Dead-letter queue (DLQ):
|
||||||
|
- Exchange: `allowance.schedule.dlq.exchange`
|
||||||
|
- Queue: `allowance.schedule.dlq`
|
||||||
|
- Binding key: `allowance.schedule`
|
||||||
|
|
||||||
|
### Flow Summary
|
||||||
|
|
||||||
|
1. Cron enqueues jobs to `allowance.schedule`.
|
||||||
|
2. Worker consumes jobs from `allowance.schedule`.
|
||||||
|
3. On failure, job is **dead-lettered** to `allowance.schedule.retry`.
|
||||||
|
4. After 10 minutes, it returns to `allowance.schedule`.
|
||||||
|
5. After max retries, worker publishes the job to `allowance.schedule.dlq`.
|
||||||
|
|
||||||
|
### Where it is configured
|
||||||
|
|
||||||
|
- Publisher setup: `src/allowance/services/allowance-queue.service.ts`
|
||||||
|
- Worker consumer: `src/allowance/services/allowance-worker.service.ts`
|
||||||
|
- Queue constants: `src/allowance/constants/allowance-queue.constants.ts`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `RABBITMQ_URL` (required for queue/worker)
|
||||||
|
- `ALLOWANCE_QUEUE_NAME` (optional, defaults to `allowance.schedule`)
|
||||||
|
- `ALLOWANCE_MAX_RETRIES` (optional, defaults to 5)
|
||||||
|
- `ALLOWANCE_RETRY_DELAY_MS` (optional, defaults to 10 minutes)
|
||||||
|
|
||||||
|
### Example .env snippet
|
||||||
|
|
||||||
|
```
|
||||||
|
RABBITMQ_URL=amqp://guest:guest@localhost:5672
|
||||||
|
ALLOWANCE_QUEUE_NAME=allowance.schedule
|
||||||
|
ALLOWANCE_MAX_RETRIES=5
|
||||||
|
ALLOWANCE_RETRY_DELAY_MS=600000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operational Checklist
|
||||||
|
|
||||||
|
- Ensure Redis is running (cron locking).
|
||||||
|
- Ensure RabbitMQ is running (queue + workers).
|
||||||
|
- Start at least one worker process.
|
||||||
|
- Monitor DLQ for failures.
|
||||||
|
|
||||||
|
## Manual Test Checklist
|
||||||
|
|
||||||
|
1. Create schedule:
|
||||||
|
- POST `/guardians/me/allowances/:juniorId`
|
||||||
|
- Valid amount, frequency, status.
|
||||||
|
2. Duplicate schedule:
|
||||||
|
- Expect `ALLOWANCE.ALREADY_EXISTS`.
|
||||||
|
3. Cron enqueue:
|
||||||
|
- Wait for cron interval or manually trigger.
|
||||||
|
- Confirm messages appear in RabbitMQ.
|
||||||
|
4. Worker:
|
||||||
|
- Ensure worker is running.
|
||||||
|
- Verify transfers happen and `allowance_credits` is created.
|
||||||
|
5. Failure paths:
|
||||||
|
- Simulate transfer failure and verify retry queue behavior.
|
||||||
|
- Confirm DLQ after max retries.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- For large volumes, scale workers horizontally.
|
||||||
|
- Keep cron lightweight; do not perform transfers in cron.
|
||||||
|
- Monitor queue depth and DLQ entries.
|
||||||
|
|
||||||
|
## Known Limitations (Current)
|
||||||
|
|
||||||
|
- Only one schedule per child (guardian+junior unique).
|
||||||
|
- No custom weekdays or complex schedules.
|
||||||
|
- Retry delay is fixed at 10 minutes (can be configurable).
|
||||||
|
|
||||||
|
## File Map (Quick Reference)
|
||||||
|
|
||||||
|
- API:
|
||||||
|
- `src/allowance/controllers/allowance.controller.ts`
|
||||||
|
- `src/allowance/services/allowance.service.ts`
|
||||||
|
- `src/allowance/dtos/request/create-allowance-schedule.request.dto.ts`
|
||||||
|
- `src/allowance/dtos/response/allowance-schedule.response.dto.ts`
|
||||||
|
|
||||||
|
- Cron:
|
||||||
|
- `src/cron/tasks/allowance-schedule.cron.ts`
|
||||||
|
- `src/cron/services/base-cron.service.ts`
|
||||||
|
|
||||||
|
- Queue/Worker:
|
||||||
|
- `src/allowance/services/allowance-queue.service.ts`
|
||||||
|
- `src/allowance/services/allowance-worker.service.ts`
|
||||||
|
- `src/allowance/constants/allowance-queue.constants.ts`
|
||||||
|
|
||||||
|
- Repositories:
|
||||||
|
- `src/allowance/repositories/allowance-schedule.repository.ts`
|
||||||
|
- `src/allowance/repositories/allowance-credit.repository.ts`
|
||||||
|
|
||||||
|
- Entities:
|
||||||
|
- `src/allowance/entities/allowance-schedule.entity.ts`
|
||||||
|
- `src/allowance/entities/allowance-credit.entity.ts`
|
||||||
|
|
||||||
@ -10,6 +10,8 @@
|
|||||||
"include": "config",
|
"include": "config",
|
||||||
"exclude": "**/*.md"
|
"exclude": "**/*.md"
|
||||||
},
|
},
|
||||||
|
{ "include": "common/modules/**/templates/**/*", "watchAssets": true },
|
||||||
|
{ "include": "common/modules/neoleap/zod-certs" },
|
||||||
"i18n",
|
"i18n",
|
||||||
"files"
|
"files"
|
||||||
]
|
]
|
||||||
|
|||||||
11265
package-lock.json
generated
11265
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -23,11 +23,13 @@
|
|||||||
"migration:generate": "npm run typeorm:cli-d migration:generate",
|
"migration:generate": "npm run typeorm:cli-d migration:generate",
|
||||||
"migration:create": "npm run typeorm:cli migration:create",
|
"migration:create": "npm run typeorm:cli migration:create",
|
||||||
"migration:up": "npm run typeorm:cli-d migration:run",
|
"migration:up": "npm run typeorm:cli-d migration:run",
|
||||||
"migration:down": "npm run typeorm:cli-d migration:revert"
|
"migration:down": "npm run typeorm:cli-d migration:revert",
|
||||||
|
"seed": "TS_NODE_PROJECT=tsconfig.json ts-node -r tsconfig-paths/register src/scripts/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@abdalhamid/hello": "^2.0.0",
|
"@abdalhamid/hello": "^2.0.0",
|
||||||
"@hamid/hello": "file:../libraries/test-package",
|
"@hamid/hello": "file:../libraries/test-package",
|
||||||
|
"@keyv/redis": "^4.0.2",
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
"@nestjs/axios": "^3.1.2",
|
"@nestjs/axios": "^3.1.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
@ -38,6 +40,7 @@
|
|||||||
"@nestjs/microservices": "^10.4.7",
|
"@nestjs/microservices": "^10.4.7",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.8",
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"@nestjs/swagger": "^8.0.5",
|
"@nestjs/swagger": "^8.0.5",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
"@nestjs/throttler": "^6.2.1",
|
"@nestjs/throttler": "^6.2.1",
|
||||||
@ -45,15 +48,20 @@
|
|||||||
"amqp-connection-manager": "^4.1.14",
|
"amqp-connection-manager": "^4.1.14",
|
||||||
"amqplib": "^0.10.4",
|
"amqplib": "^0.10.4",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"cacheable": "^1.8.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"firebase-admin": "^13.0.2",
|
||||||
"google-libphonenumber": "^3.2.39",
|
"google-libphonenumber": "^3.2.39",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"ioredis": "^5.4.1",
|
"handlebars-layouts": "^3.1.4",
|
||||||
|
"jwk-to-pem": "^2.0.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nestjs-i18n": "^10.4.9",
|
"nestjs-i18n": "^10.4.9",
|
||||||
"nestjs-pino": "^4.1.0",
|
"nestjs-pino": "^4.1.0",
|
||||||
|
"nestjs-twilio": "^4.4.0",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"oci-common": "^2.99.0",
|
"oci-common": "^2.99.0",
|
||||||
"oci-sdk": "^2.99.0",
|
"oci-sdk": "^2.99.0",
|
||||||
@ -62,9 +70,11 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"qrcode": "^1.5.4",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20"
|
"typeorm": "^0.3.20",
|
||||||
|
"typeorm-transactional": "^0.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@golevelup/ts-jest": "^0.6.0",
|
"@golevelup/ts-jest": "^0.6.0",
|
||||||
@ -75,20 +85,25 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/google-libphonenumber": "^7.4.30",
|
"@types/google-libphonenumber": "^7.4.30",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/jwk-to-pem": "^2.0.3",
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/nodemailer": "^6.4.16",
|
"@types/nodemailer": "^6.4.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||||
"@typescript-eslint/parser": "^5.59.2",
|
"@typescript-eslint/parser": "^5.59.2",
|
||||||
"eslint": "^8.39.0",
|
"eslint": "^8.39.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
"eslint-plugin-security": "^1.7.1",
|
"eslint-plugin-security": "^1.7.1",
|
||||||
|
"i": "^0.3.7",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"lint-staged": "^13.2.2",
|
"lint-staged": "^13.2.2",
|
||||||
|
"npm": "^10.9.2",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
22
src/allowance/allowance.module.ts
Normal file
22
src/allowance/allowance.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CardModule } from '~/card/card.module';
|
||||||
|
import { JuniorModule } from '~/junior/junior.module';
|
||||||
|
import { AllowanceController } from './controllers';
|
||||||
|
import { AllowanceCredit, AllowanceSchedule } from './entities';
|
||||||
|
import { AllowanceCreditRepository, AllowanceScheduleRepository } from './repositories';
|
||||||
|
import { AllowanceQueueService, AllowanceService, AllowanceWorkerService } from './services';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AllowanceSchedule, AllowanceCredit]), JuniorModule, CardModule],
|
||||||
|
controllers: [AllowanceController],
|
||||||
|
providers: [
|
||||||
|
AllowanceService,
|
||||||
|
AllowanceScheduleRepository,
|
||||||
|
AllowanceCreditRepository,
|
||||||
|
AllowanceQueueService,
|
||||||
|
AllowanceWorkerService,
|
||||||
|
],
|
||||||
|
exports: [AllowanceScheduleRepository, AllowanceQueueService, AllowanceCreditRepository],
|
||||||
|
})
|
||||||
|
export class AllowanceModule {}
|
||||||
5
src/allowance/constants/allowance-queue.constants.ts
Normal file
5
src/allowance/constants/allowance-queue.constants.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const ALLOWANCE_QUEUE_NAME = 'allowance.schedule';
|
||||||
|
export const ALLOWANCE_RETRY_QUEUE_NAME = 'allowance.schedule.retry';
|
||||||
|
export const ALLOWANCE_DLQ_NAME = 'allowance.schedule.dlq';
|
||||||
|
export const ALLOWANCE_RETRY_EXCHANGE = 'allowance.schedule.retry.exchange';
|
||||||
|
export const ALLOWANCE_DLQ_EXCHANGE = 'allowance.schedule.dlq.exchange';
|
||||||
1
src/allowance/constants/index.ts
Normal file
1
src/allowance/constants/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './allowance-queue.constants';
|
||||||
32
src/allowance/controllers/allowance.controller.ts
Normal file
32
src/allowance/controllers/allowance.controller.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Body, Controller, Param, Post, 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 { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||||
|
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||||
|
import { ResponseFactory } from '~/core/utils';
|
||||||
|
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||||
|
import { AllowanceScheduleResponseDto } from '../dtos/response';
|
||||||
|
import { AllowanceService } from '../services';
|
||||||
|
|
||||||
|
@Controller('guardians/me/allowances')
|
||||||
|
@ApiTags('Allowances')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiLangRequestHeader()
|
||||||
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
|
@AllowedRoles(Roles.GUARDIAN)
|
||||||
|
export class AllowanceController {
|
||||||
|
constructor(private readonly allowanceService: AllowanceService) {}
|
||||||
|
|
||||||
|
@Post(':juniorId')
|
||||||
|
@ApiDataResponse(AllowanceScheduleResponseDto)
|
||||||
|
async createSchedule(
|
||||||
|
@AuthenticatedUser() { sub }: IJwtPayload,
|
||||||
|
@Param('juniorId') juniorId: string,
|
||||||
|
@Body() body: CreateAllowanceScheduleRequestDto,
|
||||||
|
) {
|
||||||
|
const schedule = await this.allowanceService.createSchedule(sub, juniorId, body);
|
||||||
|
return ResponseFactory.data(new AllowanceScheduleResponseDto(schedule));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/allowance/controllers/index.ts
Normal file
1
src/allowance/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './allowance.controller';
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsNotEmpty, IsNumber, IsPositive } from 'class-validator';
|
||||||
|
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
|
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
|
||||||
|
|
||||||
|
export class CreateAllowanceScheduleRequestDto {
|
||||||
|
@ApiProperty({ example: 400 })
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.amount' }),
|
||||||
|
})
|
||||||
|
@IsNumber(
|
||||||
|
{ maxDecimalPlaces: 2 },
|
||||||
|
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) },
|
||||||
|
)
|
||||||
|
@IsPositive({
|
||||||
|
message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }),
|
||||||
|
})
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AllowanceFrequency, example: AllowanceFrequency.WEEKLY })
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.frequency' }),
|
||||||
|
})
|
||||||
|
@IsEnum(AllowanceFrequency, {
|
||||||
|
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }),
|
||||||
|
})
|
||||||
|
frequency!: AllowanceFrequency;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AllowanceScheduleStatus, example: AllowanceScheduleStatus.ON })
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.status' }),
|
||||||
|
})
|
||||||
|
@IsEnum(AllowanceScheduleStatus, {
|
||||||
|
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.status' }),
|
||||||
|
})
|
||||||
|
status!: AllowanceScheduleStatus;
|
||||||
|
|
||||||
|
}
|
||||||
1
src/allowance/dtos/request/index.ts
Normal file
1
src/allowance/dtos/request/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './create-allowance-schedule.request.dto';
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
|
||||||
|
import { AllowanceFrequency, AllowanceScheduleStatus } from '~/allowance/enums';
|
||||||
|
|
||||||
|
export class AllowanceScheduleResponseDto {
|
||||||
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||||
|
guardianId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||||
|
juniorId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 400 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AllowanceFrequency, example: AllowanceFrequency.WEEKLY })
|
||||||
|
frequency!: AllowanceFrequency;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AllowanceScheduleStatus, example: AllowanceScheduleStatus.ON })
|
||||||
|
status!: AllowanceScheduleStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
|
||||||
|
nextRunAt!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: null })
|
||||||
|
lastRunAt!: Date | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
constructor(data: AllowanceSchedule) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.guardianId = data.guardianId;
|
||||||
|
this.juniorId = data.juniorId;
|
||||||
|
this.amount = Number(data.amount);
|
||||||
|
this.frequency = data.frequency;
|
||||||
|
this.status = data.status;
|
||||||
|
this.nextRunAt = data.nextRunAt;
|
||||||
|
this.lastRunAt = data.lastRunAt;
|
||||||
|
this.createdAt = data.createdAt;
|
||||||
|
this.updatedAt = data.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/allowance/dtos/response/index.ts
Normal file
1
src/allowance/dtos/response/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './allowance-schedule.response.dto';
|
||||||
42
src/allowance/entities/allowance-credit.entity.ts
Normal file
42
src/allowance/entities/allowance-credit.entity.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Transaction } from '~/card/entities/transaction.entity';
|
||||||
|
import { AllowanceSchedule } from './allowance-schedule.entity';
|
||||||
|
|
||||||
|
@Entity('allowance_credits')
|
||||||
|
export class AllowanceCredit extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'amount' })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', name: 'run_at' })
|
||||||
|
runAt!: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'credited_at' })
|
||||||
|
creditedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'schedule_id' })
|
||||||
|
scheduleId!: string;
|
||||||
|
|
||||||
|
|
||||||
|
@ManyToOne(() => AllowanceSchedule, (schedule) => schedule.credits, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'schedule_id' })
|
||||||
|
schedule!: AllowanceSchedule;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'transaction_id', nullable: true })
|
||||||
|
transactionId!: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Transaction, { onDelete: 'SET NULL', nullable: true })
|
||||||
|
@JoinColumn({ name: 'transaction_id' })
|
||||||
|
transaction!: Transaction | null;
|
||||||
|
|
||||||
|
}
|
||||||
60
src/allowance/entities/allowance-schedule.entity.ts
Normal file
60
src/allowance/entities/allowance-schedule.entity.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||||
|
import { Junior } from '~/junior/entities';
|
||||||
|
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
|
||||||
|
import { AllowanceCredit } from './allowance-credit.entity';
|
||||||
|
|
||||||
|
@Entity('allowance_schedules')
|
||||||
|
export class AllowanceSchedule extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'amount' })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', name: 'frequency' })
|
||||||
|
frequency!: AllowanceFrequency;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', name: 'status', default: AllowanceScheduleStatus.ON })
|
||||||
|
status!: AllowanceScheduleStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', name: 'next_run_at' })
|
||||||
|
nextRunAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', name: 'last_run_at', nullable: true })
|
||||||
|
lastRunAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'guardian_id' })
|
||||||
|
guardianId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Guardian, (guardian) => guardian.allowanceSchedules, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'guardian_id' })
|
||||||
|
guardian!: Guardian;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'junior_id' })
|
||||||
|
juniorId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Junior, (junior) => junior.allowanceSchedules, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'junior_id' })
|
||||||
|
junior!: Junior;
|
||||||
|
|
||||||
|
@OneToMany(() => AllowanceCredit, (credit) => credit.schedule)
|
||||||
|
credits!: AllowanceCredit[];
|
||||||
|
|
||||||
|
}
|
||||||
2
src/allowance/entities/index.ts
Normal file
2
src/allowance/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './allowance-credit.entity';
|
||||||
|
export * from './allowance-schedule.entity';
|
||||||
5
src/allowance/enums/allowance-frequency.enum.ts
Normal file
5
src/allowance/enums/allowance-frequency.enum.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum AllowanceFrequency {
|
||||||
|
DAILY = 'DAILY',
|
||||||
|
WEEKLY = 'WEEKLY',
|
||||||
|
MONTHLY = 'MONTHLY',
|
||||||
|
}
|
||||||
4
src/allowance/enums/allowance-schedule-status.enum.ts
Normal file
4
src/allowance/enums/allowance-schedule-status.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum AllowanceScheduleStatus {
|
||||||
|
ON = 'ON',
|
||||||
|
OFF = 'OFF',
|
||||||
|
}
|
||||||
2
src/allowance/enums/index.ts
Normal file
2
src/allowance/enums/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './allowance-frequency.enum';
|
||||||
|
export * from './allowance-schedule-status.enum';
|
||||||
32
src/allowance/repositories/allowance-credit.repository.ts
Normal file
32
src/allowance/repositories/allowance-credit.repository.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AllowanceCredit } from '~/allowance/entities';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowanceCreditRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AllowanceCredit)
|
||||||
|
private readonly allowanceCreditRepository: Repository<AllowanceCredit>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
createCredit(scheduleId: string, amount: number, runAt: Date): Promise<AllowanceCredit> {
|
||||||
|
return this.allowanceCreditRepository.save(
|
||||||
|
this.allowanceCreditRepository.create({
|
||||||
|
scheduleId,
|
||||||
|
amount,
|
||||||
|
runAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByScheduleAndRunAt(scheduleId: string, runAt: Date): Promise<AllowanceCredit | null> {
|
||||||
|
return this.allowanceCreditRepository.findOne({
|
||||||
|
where: { scheduleId, runAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteById(id: string): Promise<void> {
|
||||||
|
return this.allowanceCreditRepository.delete({ id }).then(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/allowance/repositories/allowance-schedule.repository.ts
Normal file
67
src/allowance/repositories/allowance-schedule.repository.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AllowanceSchedule } from '~/allowance/entities/allowance-schedule.entity';
|
||||||
|
import { CreateAllowanceScheduleRequestDto } from '~/allowance/dtos/request';
|
||||||
|
import { AllowanceScheduleStatus } from '~/allowance/enums';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowanceScheduleRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AllowanceSchedule)
|
||||||
|
private readonly allowanceScheduleRepository: Repository<AllowanceSchedule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findByGuardianAndJunior(guardianId: string, juniorId: string): Promise<AllowanceSchedule | null> {
|
||||||
|
return this.allowanceScheduleRepository.findOne({
|
||||||
|
where: { guardianId, juniorId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createSchedule(guardianId: string, juniorId: string, body: CreateAllowanceScheduleRequestDto, nextRunAt: Date) {
|
||||||
|
return this.allowanceScheduleRepository.save(
|
||||||
|
this.allowanceScheduleRepository.create({
|
||||||
|
guardianId,
|
||||||
|
juniorId,
|
||||||
|
amount: body.amount,
|
||||||
|
frequency: body.frequency,
|
||||||
|
status: body.status,
|
||||||
|
nextRunAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findDueSchedulesBatch(
|
||||||
|
limit: number,
|
||||||
|
cursor?: { nextRunAt: Date; id: string },
|
||||||
|
): Promise<AllowanceSchedule[]> {
|
||||||
|
const query = this.allowanceScheduleRepository
|
||||||
|
.createQueryBuilder('schedule')
|
||||||
|
.where('schedule.status = :status', { status: AllowanceScheduleStatus.ON })
|
||||||
|
.andWhere('schedule.nextRunAt <= :now', { now: new Date() });
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
query.andWhere(
|
||||||
|
'(schedule.nextRunAt > :cursorDate OR (schedule.nextRunAt = :cursorDate AND schedule.id > :cursorId))',
|
||||||
|
{
|
||||||
|
cursorDate: cursor.nextRunAt,
|
||||||
|
cursorId: cursor.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.orderBy('schedule.nextRunAt', 'ASC')
|
||||||
|
.addOrderBy('schedule.id', 'ASC')
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<AllowanceSchedule | null> {
|
||||||
|
return this.allowanceScheduleRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScheduleRun(id: string, lastRunAt: Date, nextRunAt: Date) {
|
||||||
|
return this.allowanceScheduleRepository.update({ id }, { lastRunAt, nextRunAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/allowance/repositories/index.ts
Normal file
2
src/allowance/repositories/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './allowance-credit.repository';
|
||||||
|
export * from './allowance-schedule.repository';
|
||||||
88
src/allowance/services/allowance-queue.service.ts
Normal file
88
src/allowance/services/allowance-queue.service.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AmqpConnectionManager, ChannelWrapper, connect } from 'amqp-connection-manager';
|
||||||
|
import {
|
||||||
|
ALLOWANCE_DLQ_EXCHANGE,
|
||||||
|
ALLOWANCE_DLQ_NAME,
|
||||||
|
ALLOWANCE_QUEUE_NAME,
|
||||||
|
ALLOWANCE_RETRY_EXCHANGE,
|
||||||
|
ALLOWANCE_RETRY_QUEUE_NAME,
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowanceQueueService implements OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(AllowanceQueueService.name);
|
||||||
|
private connection?: AmqpConnectionManager;
|
||||||
|
private channel?: ChannelWrapper;
|
||||||
|
private readonly queueName: string;
|
||||||
|
private readonly rabbitUrl?: string;
|
||||||
|
private readonly retryDelayMs: number;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME;
|
||||||
|
this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
|
||||||
|
this.retryDelayMs = Number(this.configService.get<string>('ALLOWANCE_RETRY_DELAY_MS') || 10 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueueSchedule(scheduleId: string, runAt: Date): Promise<void> {
|
||||||
|
if (!this.rabbitUrl) {
|
||||||
|
this.logger.warn('RABBITMQ_URL is not set; skipping allowance enqueue.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.connection || !this.channel) {
|
||||||
|
this.connection = connect([this.rabbitUrl]);
|
||||||
|
this.connection.on('connect', () => {
|
||||||
|
this.logger.log('RabbitMQ connected (publisher).');
|
||||||
|
});
|
||||||
|
this.connection.on('disconnect', (params) => {
|
||||||
|
this.logger.error(`RabbitMQ disconnected (publisher): ${params?.err?.message || 'unknown error'}`);
|
||||||
|
});
|
||||||
|
this.channel = this.connection.createChannel({
|
||||||
|
setup: async (channel: any) => {
|
||||||
|
await channel.assertExchange(ALLOWANCE_RETRY_EXCHANGE, 'direct', { durable: true });
|
||||||
|
await channel.assertExchange(ALLOWANCE_DLQ_EXCHANGE, 'direct', { durable: true });
|
||||||
|
|
||||||
|
await channel.assertQueue(ALLOWANCE_RETRY_QUEUE_NAME, {
|
||||||
|
durable: true,
|
||||||
|
messageTtl: this.retryDelayMs,
|
||||||
|
deadLetterExchange: '',
|
||||||
|
deadLetterRoutingKey: this.queueName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await channel.assertQueue(ALLOWANCE_DLQ_NAME, {
|
||||||
|
durable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await channel.bindQueue(ALLOWANCE_RETRY_QUEUE_NAME, ALLOWANCE_RETRY_EXCHANGE, this.queueName);
|
||||||
|
await channel.bindQueue(ALLOWANCE_DLQ_NAME, ALLOWANCE_DLQ_EXCHANGE, this.queueName);
|
||||||
|
|
||||||
|
await channel.assertQueue(this.queueName, {
|
||||||
|
durable: true,
|
||||||
|
deadLetterExchange: ALLOWANCE_RETRY_EXCHANGE,
|
||||||
|
deadLetterRoutingKey: this.queueName,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
scheduleId,
|
||||||
|
runAt: runAt.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageId = `allowance:${scheduleId}:${runAt.toISOString()}`;
|
||||||
|
const options: any = {
|
||||||
|
persistent: true,
|
||||||
|
messageId,
|
||||||
|
contentType: 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.channel.sendToQueue(this.queueName, Buffer.from(JSON.stringify(payload)), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.channel?.close();
|
||||||
|
await this.connection?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/allowance/services/allowance-worker.service.ts
Normal file
162
src/allowance/services/allowance-worker.service.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AmqpConnectionManager, ChannelWrapper, connect } from 'amqp-connection-manager';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { CardService } from '~/card/services';
|
||||||
|
import { AllowanceScheduleRepository } from '../repositories/allowance-schedule.repository';
|
||||||
|
import { AllowanceCreditRepository } from '../repositories/allowance-credit.repository';
|
||||||
|
import { ALLOWANCE_DLQ_EXCHANGE, ALLOWANCE_QUEUE_NAME } from '../constants';
|
||||||
|
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
|
||||||
|
|
||||||
|
type AllowanceQueuePayload = {
|
||||||
|
scheduleId: string;
|
||||||
|
runAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowanceWorkerService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(AllowanceWorkerService.name);
|
||||||
|
private connection?: AmqpConnectionManager;
|
||||||
|
private channel?: ChannelWrapper;
|
||||||
|
private readonly queueName: string;
|
||||||
|
private readonly rabbitUrl?: string;
|
||||||
|
private readonly maxRetries: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
|
||||||
|
private readonly allowanceCreditRepository: AllowanceCreditRepository,
|
||||||
|
private readonly cardService: CardService,
|
||||||
|
) {
|
||||||
|
this.queueName = this.configService.get<string>('ALLOWANCE_QUEUE_NAME') || ALLOWANCE_QUEUE_NAME;
|
||||||
|
this.rabbitUrl = this.configService.get<string>('RABBITMQ_URL');
|
||||||
|
this.maxRetries = Number(this.configService.get<string>('ALLOWANCE_MAX_RETRIES') || 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
if (!this.rabbitUrl) {
|
||||||
|
this.logger.warn('RABBITMQ_URL is not set; allowance worker is disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection = connect([this.rabbitUrl]);
|
||||||
|
this.connection.on('connect', () => {
|
||||||
|
this.logger.log('RabbitMQ connected (worker).');
|
||||||
|
});
|
||||||
|
this.connection.on('disconnect', (params) => {
|
||||||
|
this.logger.error(`RabbitMQ disconnected (worker): ${params?.err?.message || 'unknown error'}`);
|
||||||
|
});
|
||||||
|
this.channel = this.connection.createChannel({
|
||||||
|
setup: async (channel: any) => {
|
||||||
|
await channel.assertQueue(this.queueName, { durable: true });
|
||||||
|
await channel.prefetch(10);
|
||||||
|
await channel.consume(this.queueName, (msg: any) => this.handleMessage(channel, msg), {
|
||||||
|
noAck: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.channel?.close();
|
||||||
|
await this.connection?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(channel: any, msg: any) {
|
||||||
|
if (!msg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: AllowanceQueuePayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(msg.content.toString()) as AllowanceQueuePayload;
|
||||||
|
} catch (error) {
|
||||||
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
this.logger.error('Invalid allowance queue payload', stack || error);
|
||||||
|
channel.ack(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processAllowanceJob(payload);
|
||||||
|
channel.ack(msg);
|
||||||
|
} catch (error) {
|
||||||
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
this.logger.error(`Allowance job failed for schedule ${payload.scheduleId}`, stack || error);
|
||||||
|
const retryCount = this.getRetryCount(msg);
|
||||||
|
if (retryCount >= this.maxRetries) {
|
||||||
|
this.logger.error(`Allowance job exceeded max retries (${this.maxRetries}). Sending to DLQ.`);
|
||||||
|
channel.publish(ALLOWANCE_DLQ_EXCHANGE, this.queueName, msg.content, {
|
||||||
|
contentType: msg.properties.contentType,
|
||||||
|
messageId: msg.properties.messageId,
|
||||||
|
headers: msg.properties.headers,
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
channel.ack(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
channel.nack(msg, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processAllowanceJob(payload: AllowanceQueuePayload): Promise<void> {
|
||||||
|
const runAt = new Date(payload.runAt);
|
||||||
|
if (!payload.scheduleId || Number.isNaN(runAt.getTime())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await this.allowanceScheduleRepository.findById(payload.scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.status !== AllowanceScheduleStatus.ON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.nextRunAt > runAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credit = null;
|
||||||
|
try {
|
||||||
|
credit = await this.allowanceCreditRepository.createCredit(schedule.id, schedule.amount, runAt);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === '23505') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.cardService.transferToChild(schedule.juniorId, schedule.amount);
|
||||||
|
const nextRunAt = this.computeNextRunAt(schedule.frequency);
|
||||||
|
await this.allowanceScheduleRepository.updateScheduleRun(schedule.id, runAt, nextRunAt);
|
||||||
|
} catch (error) {
|
||||||
|
if (credit) {
|
||||||
|
await this.allowanceCreditRepository.deleteById(credit.id);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeNextRunAt(frequency: AllowanceFrequency): Date {
|
||||||
|
const base = moment();
|
||||||
|
switch (frequency) {
|
||||||
|
case AllowanceFrequency.DAILY:
|
||||||
|
return base.add(1, 'day').toDate();
|
||||||
|
case AllowanceFrequency.WEEKLY:
|
||||||
|
return base.add(1, 'week').toDate();
|
||||||
|
case AllowanceFrequency.MONTHLY:
|
||||||
|
return base.add(1, 'month').toDate();
|
||||||
|
default:
|
||||||
|
return base.toDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRetryCount(msg: any): number {
|
||||||
|
const deaths = (msg.properties.headers?.['x-death'] as Array<{ count?: number }> | undefined) || [];
|
||||||
|
const retryDeath = deaths.find((death) => death?.count != null);
|
||||||
|
return retryDeath?.count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/allowance/services/allowance.service.ts
Normal file
55
src/allowance/services/allowance.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { JuniorService } from '~/junior/services';
|
||||||
|
import { CreateAllowanceScheduleRequestDto } from '../dtos/request';
|
||||||
|
import { AllowanceSchedule } from '../entities/allowance-schedule.entity';
|
||||||
|
import { AllowanceFrequency, AllowanceScheduleStatus } from '../enums';
|
||||||
|
import { AllowanceScheduleRepository } from '../repositories';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowanceService {
|
||||||
|
private readonly logger = new Logger(AllowanceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly allowanceScheduleRepository: AllowanceScheduleRepository,
|
||||||
|
private readonly juniorService: JuniorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createSchedule(
|
||||||
|
guardianId: string,
|
||||||
|
juniorId: string,
|
||||||
|
body: CreateAllowanceScheduleRequestDto,
|
||||||
|
): Promise<AllowanceSchedule> {
|
||||||
|
const doesBelong = await this.juniorService.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||||
|
if (!doesBelong) {
|
||||||
|
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
||||||
|
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSchedule = await this.allowanceScheduleRepository.findByGuardianAndJunior(guardianId, juniorId);
|
||||||
|
if (existingSchedule) {
|
||||||
|
throw new BadRequestException('ALLOWANCE.ALREADY_EXISTS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRunAt = this.computeNextRunAt(body.frequency, body.status);
|
||||||
|
return this.allowanceScheduleRepository.createSchedule(guardianId, juniorId, body, nextRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeNextRunAt(frequency: AllowanceFrequency, status: AllowanceScheduleStatus): Date {
|
||||||
|
const base = moment();
|
||||||
|
if (status === AllowanceScheduleStatus.OFF) {
|
||||||
|
return base.toDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (frequency) {
|
||||||
|
case AllowanceFrequency.DAILY:
|
||||||
|
return base.add(1, 'day').toDate();
|
||||||
|
case AllowanceFrequency.WEEKLY:
|
||||||
|
return base.add(1, 'week').toDate();
|
||||||
|
case AllowanceFrequency.MONTHLY:
|
||||||
|
return base.add(1, 'month').toDate();
|
||||||
|
default:
|
||||||
|
return base.toDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/allowance/services/index.ts
Normal file
3
src/allowance/services/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './allowance-queue.service';
|
||||||
|
export * from './allowance.service';
|
||||||
|
export * from './allowance-worker.service';
|
||||||
@ -1,19 +1,36 @@
|
|||||||
import { MiddlewareConsumer, Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
|
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { addTransactionalDataSource } from 'typeorm-transactional';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AllowanceModule } from './allowance/allowance.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';
|
||||||
|
import { NotificationModule } from './common/modules/notification/notification.module';
|
||||||
import { OtpModule } from './common/modules/otp/otp.module';
|
import { OtpModule } from './common/modules/otp/otp.module';
|
||||||
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
|
||||||
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
|
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
|
||||||
import { buildI18nOptions } from './core/module-options/i18n-options';
|
import { buildI18nOptions } from './core/module-options/i18n-options';
|
||||||
import { buildValidationPipe } from './core/pipes';
|
import { buildValidationPipe } from './core/pipes';
|
||||||
|
import { CronModule } from './cron/cron.module';
|
||||||
import { CustomerModule } from './customer/customer.module';
|
import { CustomerModule } from './customer/customer.module';
|
||||||
import { migrations } from './db';
|
import { migrations } from './db';
|
||||||
import { DocumentModule } from './document/document.module';
|
import { DocumentModule } from './document/document.module';
|
||||||
|
import { GuardianModule } from './guardian/guardian.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { JuniorModule } from './junior/junior.module';
|
||||||
|
import { UserModule } from './user/user.module';
|
||||||
|
import { WebhookModule } from './webhook/webhook.module';
|
||||||
|
import { MoneyRequestModule } from './money-request/money-request.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [],
|
controllers: [],
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,19 +38,46 @@ import { HealthModule } from './health/health.module';
|
|||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [],
|
imports: [],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => buildTypeormOptions(config, migrations),
|
useFactory: (config: ConfigService) => {
|
||||||
|
return buildTypeormOptions(config, migrations);
|
||||||
|
},
|
||||||
|
async dataSourceFactory(options) {
|
||||||
|
if (!options) {
|
||||||
|
throw new Error('Invalid options passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return addTransactionalDataSource(new DataSource(options));
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
useFactory: (config: ConfigService) => buildLoggerOptions(config),
|
useFactory: (config: ConfigService) => buildLoggerOptions(config),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
I18nModule.forRoot(buildI18nOptions()),
|
I18nModule.forRoot(buildI18nOptions()),
|
||||||
|
CacheModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
// App modules
|
// App modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AllowanceModule,
|
||||||
|
UserModule,
|
||||||
|
|
||||||
CustomerModule,
|
CustomerModule,
|
||||||
DocumentModule,
|
JuniorModule,
|
||||||
HealthModule,
|
GuardianModule,
|
||||||
|
CardModule,
|
||||||
|
|
||||||
|
NotificationModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
DocumentModule,
|
||||||
|
LookupModule,
|
||||||
|
|
||||||
|
HealthModule,
|
||||||
|
|
||||||
|
CronModule,
|
||||||
|
NeoLeapModule,
|
||||||
|
WebhookModule,
|
||||||
|
MoneyRequestModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Pipes
|
// Global Pipes
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { JuniorModule } from '~/junior/junior.module';
|
||||||
|
import { UserModule } from '~/user/user.module';
|
||||||
import { AuthController } from './controllers';
|
import { AuthController } from './controllers';
|
||||||
import { Device, User, UserNotificationSettings } from './entities';
|
import { AuthService } from './services';
|
||||||
import { DeviceRepository, UserRepository } from './repositories';
|
|
||||||
import { AuthService, DeviceService } from './services';
|
|
||||||
import { UserService } from './services/user.service';
|
|
||||||
import { AccessTokenStrategy } from './strategies';
|
import { AccessTokenStrategy } from './strategies';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([User, UserNotificationSettings, Device]), JwtModule.register({})],
|
imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule],
|
||||||
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
|
providers: [AuthService, AccessTokenStrategy],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [UserService],
|
exports: [],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -1,28 +1,32 @@
|
|||||||
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { DEVICE_ID_HEADER } from '~/common/constants';
|
import { Request } from 'express';
|
||||||
import { AuthenticatedUser } from '~/common/decorators';
|
import { AuthenticatedUser, Public } from '~/common/decorators';
|
||||||
import { AccessTokenGuard } from '~/common/guards';
|
import { AccessTokenGuard } from '~/common/guards';
|
||||||
|
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||||
import { ResponseFactory } from '~/core/utils';
|
import { ResponseFactory } from '~/core/utils';
|
||||||
import {
|
import {
|
||||||
|
ChangePasswordRequestDto,
|
||||||
CreateUnverifiedUserRequestDto,
|
CreateUnverifiedUserRequestDto,
|
||||||
DisableBiometricRequestDto,
|
|
||||||
EnableBiometricRequestDto,
|
|
||||||
ForgetPasswordRequestDto,
|
ForgetPasswordRequestDto,
|
||||||
|
JuniorLoginRequestDto,
|
||||||
LoginRequestDto,
|
LoginRequestDto,
|
||||||
|
RefreshTokenRequestDto,
|
||||||
SendForgetPasswordOtpRequestDto,
|
SendForgetPasswordOtpRequestDto,
|
||||||
SetEmailRequestDto,
|
setJuniorPasswordRequestDto,
|
||||||
SetPasscodeRequestDto,
|
VerifyForgetPasswordOtpRequestDto,
|
||||||
VerifyUserRequestDto,
|
VerifyUserRequestDto,
|
||||||
} from '../dtos/request';
|
} from '../dtos/request';
|
||||||
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
|
import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response';
|
||||||
import { LoginResponseDto } from '../dtos/response/login.response.dto';
|
import { LoginResponseDto } from '../dtos/response/login.response.dto';
|
||||||
|
import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto';
|
||||||
import { IJwtPayload } from '../interfaces';
|
import { IJwtPayload } from '../interfaces';
|
||||||
import { AuthService } from '../services';
|
import { AuthService } from '../services';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@ApiLangRequestHeader()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
@Post('register/otp')
|
@Post('register/otp')
|
||||||
@ -37,49 +41,67 @@ export class AuthController {
|
|||||||
return ResponseFactory.data(new LoginResponseDto(res, user));
|
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('register/set-email')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
async login(@Body() verifyUserDto: LoginRequestDto) {
|
||||||
@UseGuards(AccessTokenGuard)
|
const [res, user] = await this.authService.loginWithPassword(verifyUserDto);
|
||||||
async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) {
|
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||||
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('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('forget-password/otp')
|
@Post('forget-password/otp')
|
||||||
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
|
async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) {
|
||||||
const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
|
const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto);
|
||||||
return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email));
|
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')
|
@Post('forget-password/reset')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
|
resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) {
|
||||||
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
|
return this.authService.resetPassword(forgetPasswordDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('change-password')
|
||||||
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
const [res, user] = await this.authService.login(loginDto, deviceId);
|
@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() 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));
|
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('refresh-token')
|
||||||
|
@Public()
|
||||||
|
async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) {
|
||||||
|
const [res, user] = await this.authService.refreshToken(refreshToken);
|
||||||
|
return ResponseFactory.data(new LoginResponseDto(res, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@UseGuards(AccessTokenGuard)
|
||||||
|
async logout(@Req() request: Request) {
|
||||||
|
await this.authService.logout(request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/auth/dtos/request/change-password.request.dto.ts
Normal file
23
src/auth/dtos/request/change-password.request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,19 +1,4 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { OmitType } from '@nestjs/swagger';
|
||||||
import { Matches } from 'class-validator';
|
import { VerifyUserRequestDto } from './verify-user.request.dto';
|
||||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
|
||||||
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
|
|
||||||
import { IsValidPhoneNumber } from '~/core/decorators/validations';
|
|
||||||
|
|
||||||
export class CreateUnverifiedUserRequestDto {
|
export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {}
|
||||||
@ApiProperty({ example: '+962' })
|
|
||||||
@Matches(COUNTRY_CODE_REGEX, {
|
|
||||||
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
|
|
||||||
})
|
|
||||||
countryCode: string = '+966';
|
|
||||||
|
|
||||||
@ApiProperty({ example: '787259134' })
|
|
||||||
@IsValidPhoneNumber({
|
|
||||||
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
|
|
||||||
})
|
|
||||||
phoneNumber!: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PickType } from '@nestjs/swagger';
|
|
||||||
import { EnableBiometricRequestDto } from './enable-biometric.request.dto';
|
|
||||||
|
|
||||||
export class DisableBiometricRequestDto extends PickType(EnableBiometricRequestDto, ['deviceId']) {}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,32 +1,34 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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 { 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 {
|
export class ForgetPasswordRequestDto {
|
||||||
@ApiProperty({ example: 'test@test.com' })
|
@ApiProperty({ example: '+962' })
|
||||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
|
@Matches(COUNTRY_CODE_REGEX, {
|
||||||
email!: string;
|
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
|
||||||
|
})
|
||||||
|
countryCode!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password' })
|
@ApiProperty({ example: '787259134' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
|
@IsValidPhoneNumber({
|
||||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) })
|
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;
|
password!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password' })
|
@ApiProperty({ example: 'Abcd1234@' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) })
|
@Matches(PASSWORD_REGEX, {
|
||||||
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) })
|
message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }),
|
||||||
|
})
|
||||||
confirmPassword!: string;
|
confirmPassword!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '111111' })
|
@ApiProperty({ example: 'reset-token-32423123' })
|
||||||
@IsNumberString(
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) })
|
||||||
{ no_symbols: true },
|
resetPasswordToken!: string;
|
||||||
{ 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
export * from './change-password.request.dto';
|
||||||
export * from './create-unverified-user.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 './forget-password.request.dto';
|
||||||
|
export * from './junior-login.request.dto';
|
||||||
export * from './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-forget-password-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-forget-password-otp.request.dto';
|
||||||
|
export * from './verify-otp.request.dto';
|
||||||
export * from './verify-user.request.dto';
|
export * from './verify-user.request.dto';
|
||||||
|
|||||||
35
src/auth/dtos/request/junior-login.request.dto.ts
Normal file
35
src/auth/dtos/request/junior-login.request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,24 +1,47 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEmail, IsEnum, IsString, ValidateIf } from 'class-validator';
|
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator';
|
||||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
|
import { COUNTRY_CODE_REGEX } from '~/auth/constants';
|
||||||
import { GrantType } from '~/auth/enums';
|
import { GrantType } from '~/auth/enums';
|
||||||
|
import { IsValidPhoneNumber } from '~/core/decorators/validations';
|
||||||
export class LoginRequestDto {
|
export class LoginRequestDto {
|
||||||
@ApiProperty({ example: GrantType.PASSWORD })
|
@ApiProperty({ example: '+962' })
|
||||||
@IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) })
|
@Matches(COUNTRY_CODE_REGEX, {
|
||||||
grantType!: GrantType;
|
message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }),
|
||||||
|
})
|
||||||
|
countryCode!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'test@test.com' })
|
@ApiProperty({ example: '787259134' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) })
|
@IsValidPhoneNumber({
|
||||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) })
|
message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }),
|
||||||
email!: string;
|
})
|
||||||
|
phoneNumber!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456' })
|
@ApiProperty({ example: 'Abcd1234@' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) })
|
||||||
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
|
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'device-token' })
|
@ApiProperty({ example: 'device-123', description: 'Unique device identifier', required: false })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) })
|
@IsOptional()
|
||||||
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceId' }) })
|
||||||
deviceToken!: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/auth/dtos/request/refresh-token.request.dto.ts
Normal file
9
src/auth/dtos/request/refresh-token.request.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
|
export class RefreshTokenRequestDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString({ message: i18n('validation.isString', { path: 'general', property: 'auth.refreshToken' }) })
|
||||||
|
@IsNotEmpty({ message: i18n('validation.required', { path: 'general', property: 'auth.refreshToken' }) })
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { PickType } from '@nestjs/swagger';
|
import { PickType } from '@nestjs/swagger';
|
||||||
import { LoginRequestDto } from './login.request.dto';
|
import { LoginRequestDto } from './login.request.dto';
|
||||||
|
|
||||||
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {}
|
export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {}
|
||||||
|
|||||||
13
src/auth/dtos/request/set-junior-password.request.dto.ts
Normal file
13
src/auth/dtos/request/set-junior-password.request.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty, PickType } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
|
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' }) })
|
||||||
|
qrToken!: string;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
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 { ForgetPasswordRequestDto } from './forget-password.request.dto';
|
||||||
|
|
||||||
|
export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [
|
||||||
|
'countryCode',
|
||||||
|
'phoneNumber',
|
||||||
|
]) {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
19
src/auth/dtos/request/verify-otp.request.dto.ts
Normal file
19
src/auth/dtos/request/verify-otp.request.dto.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } 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';
|
||||||
|
|
||||||
|
export class VerifyOtpRequestDto {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@ -1,10 +1,94 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNumberString, MaxLength, MinLength } from 'class-validator';
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumberString,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
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 { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants';
|
||||||
import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto';
|
import { IsValidPhoneNumber } from '~/core/decorators/validations';
|
||||||
|
|
||||||
|
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' }) })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Doe' })
|
||||||
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) })
|
||||||
|
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'JO' })
|
||||||
|
@IsEnum(CountryIso, {
|
||||||
|
message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }),
|
||||||
|
})
|
||||||
|
@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;
|
||||||
|
|
||||||
export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto {
|
|
||||||
@ApiProperty({ example: '111111' })
|
@ApiProperty({ example: '111111' })
|
||||||
@IsNumberString(
|
@IsNumberString(
|
||||||
{ no_symbols: true },
|
{ no_symbols: true },
|
||||||
@ -17,4 +101,27 @@ export class VerifyUserRequestDto extends CreateUnverifiedUserRequestDto {
|
|||||||
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }),
|
||||||
})
|
})
|
||||||
otp!: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { User } from '~/auth/entities';
|
|
||||||
import { ILoginResponse } from '~/auth/interfaces';
|
import { ILoginResponse } from '~/auth/interfaces';
|
||||||
import { CustomerResponseDto } from '~/customer/dtos/response';
|
import { CustomerResponseDto } from '~/customer/dtos/response';
|
||||||
|
import { User } from '~/user/entities';
|
||||||
import { UserResponseDto } from './user.response.dto';
|
import { UserResponseDto } from './user.response.dto';
|
||||||
|
|
||||||
export class LoginResponseDto {
|
export class LoginResponseDto {
|
||||||
@ -17,12 +17,12 @@ export class LoginResponseDto {
|
|||||||
@ApiProperty({ example: UserResponseDto })
|
@ApiProperty({ example: UserResponseDto })
|
||||||
user!: UserResponseDto;
|
user!: UserResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ example: CustomerResponseDto })
|
@ApiProperty({ type: CustomerResponseDto })
|
||||||
customer!: CustomerResponseDto;
|
customer!: CustomerResponseDto | null;
|
||||||
|
|
||||||
constructor(IVerifyUserResponse: ILoginResponse, user: User) {
|
constructor(IVerifyUserResponse: ILoginResponse, user: User) {
|
||||||
this.user = new UserResponseDto(user);
|
this.user = new UserResponseDto(user);
|
||||||
this.customer = new CustomerResponseDto(user.customer);
|
this.customer = user.customer ? new CustomerResponseDto(user.customer) : null;
|
||||||
this.accessToken = IVerifyUserResponse.accessToken;
|
this.accessToken = IVerifyUserResponse.accessToken;
|
||||||
this.refreshToken = IVerifyUserResponse.refreshToken;
|
this.refreshToken = IVerifyUserResponse.refreshToken;
|
||||||
this.expiresAt = IVerifyUserResponse.expiresAt;
|
this.expiresAt = IVerifyUserResponse.expiresAt;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export class SendForgetPasswordOtpResponseDto {
|
export class SendForgetPasswordOtpResponseDto {
|
||||||
email!: string;
|
maskedNumber!: string;
|
||||||
|
|
||||||
constructor(email: string) {
|
constructor(maskedNumber: string) {
|
||||||
this.email = email;
|
this.maskedNumber = maskedNumber;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
|
|
||||||
export class SendRegisterOtpResponseDto {
|
export class SendRegisterOtpResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
phoneNumber!: string;
|
maskedNumber!: string;
|
||||||
|
|
||||||
constructor(phoneNumber: string) {
|
constructor(maskedNumber: string) {
|
||||||
this.phoneNumber = phoneNumber;
|
this.maskedNumber = maskedNumber;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/auth/dtos/response/send-register-otp.v2.response.dto.ts
Normal file
10
src/auth/dtos/response/send-register-otp.v2.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SendRegisterOtpV2ResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
maskedNumber!: string;
|
||||||
|
|
||||||
|
constructor(maskedNumber: string) {
|
||||||
|
this.maskedNumber = maskedNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,36 +1,54 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { User } from '~/auth/entities';
|
import { Gender } from '~/customer/enums';
|
||||||
import { Roles } from '~/auth/enums';
|
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||||
|
import { User } from '~/user/entities';
|
||||||
|
|
||||||
export class UserResponseDto {
|
export class UserResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email!: string;
|
countryCode!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
phoneNumber!: string;
|
phoneNumber!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
countryCode!: string;
|
email!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isPasswordSet!: boolean;
|
firstName!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isProfileCompleted!: boolean;
|
lastName!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
roles!: Roles[];
|
dateOfBirth!: Date;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: DocumentMetaResponseDto, nullable: true })
|
||||||
|
profilePicture!: DocumentMetaResponseDto | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isPhoneVerified!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isEmailVerified!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Gender, nullable: true })
|
||||||
|
gender!: Gender | null;
|
||||||
|
|
||||||
|
|
||||||
constructor(user: User) {
|
constructor(user: User) {
|
||||||
this.id = user.id;
|
this.id = user.id;
|
||||||
this.email = user.email;
|
|
||||||
this.phoneNumber = user.phoneNumber;
|
|
||||||
this.countryCode = user.countryCode;
|
this.countryCode = user.countryCode;
|
||||||
this.isPasswordSet = user.isPasswordSet;
|
this.phoneNumber = user.phoneNumber;
|
||||||
this.isProfileCompleted = user.isProfileCompleted;
|
this.dateOfBirth = user.customer?.dateOfBirth;
|
||||||
this.roles = user.roles;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { User } from '~/auth/entities';
|
|
||||||
import { ILoginResponse } from '~/auth/interfaces';
|
import { ILoginResponse } from '~/auth/interfaces';
|
||||||
|
import { User } from '~/user/entities';
|
||||||
|
|
||||||
export class VerifyUserResponseDto {
|
export class VerifyUserResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
import {
|
|
||||||
BaseEntity,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
JoinColumn,
|
|
||||||
OneToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { User } from './user.entity';
|
|
||||||
|
|
||||||
@Entity('user_notification_settings')
|
|
||||||
export class UserNotificationSettings extends BaseEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'is_email_enabled', default: false })
|
|
||||||
isEmailEnabled!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_push_enabled', default: false })
|
|
||||||
isPushEnabled!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_sms_enabled', default: false })
|
|
||||||
isSmsEnabled!: boolean;
|
|
||||||
|
|
||||||
@OneToOne(() => User, (user) => user.notificationSettings, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'user_id' })
|
|
||||||
user!: User;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
}
|
|
||||||
@ -1,4 +1,6 @@
|
|||||||
export enum Roles {
|
export enum Roles {
|
||||||
JUNIOR = 'JUNIOR',
|
JUNIOR = 'JUNIOR',
|
||||||
GUARDIAN = 'GUARDIAN',
|
GUARDIAN = 'GUARDIAN',
|
||||||
|
CHECKER = 'CHECKER',
|
||||||
|
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { Roles } from '../enums';
|
||||||
|
|
||||||
export interface IJwtPayload {
|
export interface IJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
roles: string[];
|
roles: Roles[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { FindOptionsWhere, Repository } from 'typeorm';
|
|
||||||
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
|
|
||||||
import { Customer } from '~/customer/entities';
|
|
||||||
import { User, UserNotificationSettings } from '../entities';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UserRepository {
|
|
||||||
constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) {}
|
|
||||||
|
|
||||||
createUnverifiedUser(data: Partial<User>) {
|
|
||||||
return this.userRepository.save(
|
|
||||||
this.userRepository.create({
|
|
||||||
phoneNumber: data.phoneNumber,
|
|
||||||
countryCode: data.countryCode,
|
|
||||||
roles: data.roles,
|
|
||||||
notificationSettings: UserNotificationSettings.create(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
findOne(where: FindOptionsWhere<User>) {
|
|
||||||
return this.userRepository.findOne({ where });
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) {
|
|
||||||
user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body });
|
|
||||||
return this.userRepository.save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyUserAndCreateCustomer(user: User) {
|
|
||||||
user.customer = Customer.create({ ...user.customer, id: user.id, isGuardian: true });
|
|
||||||
|
|
||||||
return this.userRepository.save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(userId: string, data: Partial<User>) {
|
|
||||||
return this.userRepository.update(userId, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +1,71 @@
|
|||||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { CacheService } from '~/common/modules/cache/services';
|
||||||
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
|
||||||
import { OtpService } from '~/common/modules/otp/services';
|
import { OtpService } from '~/common/modules/otp/services';
|
||||||
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
|
import { UserType } from '~/user/enums';
|
||||||
|
import { DeviceService, UserService, UserTokenService } from '~/user/services';
|
||||||
|
import { User } from '../../user/entities';
|
||||||
import {
|
import {
|
||||||
|
ChangePasswordRequestDto,
|
||||||
CreateUnverifiedUserRequestDto,
|
CreateUnverifiedUserRequestDto,
|
||||||
DisableBiometricRequestDto,
|
|
||||||
EnableBiometricRequestDto,
|
|
||||||
ForgetPasswordRequestDto,
|
ForgetPasswordRequestDto,
|
||||||
|
JuniorLoginRequestDto,
|
||||||
LoginRequestDto,
|
LoginRequestDto,
|
||||||
SendForgetPasswordOtpRequestDto,
|
SendForgetPasswordOtpRequestDto,
|
||||||
SetEmailRequestDto,
|
setJuniorPasswordRequestDto,
|
||||||
|
VerifyForgetPasswordOtpRequestDto,
|
||||||
|
VerifyUserRequestDto,
|
||||||
} from '../dtos/request';
|
} from '../dtos/request';
|
||||||
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
|
import { Roles } from '../enums';
|
||||||
import { User } from '../entities';
|
import { IJwtPayload, ILoginResponse } from '../interfaces';
|
||||||
import { GrantType, Roles } from '../enums';
|
|
||||||
import { ILoginResponse } from '../interfaces';
|
|
||||||
import { removePadding, verifySignature } from '../utils';
|
|
||||||
import { DeviceService } from './device.service';
|
|
||||||
import { UserService } from './user.service';
|
|
||||||
|
|
||||||
const ONE_THOUSAND = 1000;
|
const ONE_THOUSAND = 1000;
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly deviceService: DeviceService,
|
private readonly deviceService: DeviceService,
|
||||||
|
private readonly userTokenService: UserTokenService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
|
||||||
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
|
|
||||||
|
|
||||||
|
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({
|
return this.otpService.generateAndSendOtp({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
recipient: user.phoneNumber,
|
recipient: user.fullPhoneNumber,
|
||||||
scope: OtpScope.VERIFY_PHONE,
|
scope: OtpScope.VERIFY_PHONE,
|
||||||
otpType: OtpType.SMS,
|
otpType: OtpType.SMS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
|
||||||
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
|
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.isPasswordSet) {
|
if (user.isPhoneVerified) {
|
||||||
throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED');
|
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({
|
const isOtpValid = await this.otpService.verifyOtp({
|
||||||
@ -59,174 +76,305 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isOtpValid) {
|
if (!isOtpValid) {
|
||||||
throw new BadRequestException('USERS.INVALID_OTP');
|
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
|
||||||
|
throw new BadRequestException('OTP.INVALID_OTP');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await this.userService.verifyUserAndCreateCustomer(user);
|
await this.userService.verifyUser(user.id, verifyUserDto);
|
||||||
|
|
||||||
const tokens = await this.generateAuthToken(updatedUser);
|
await user.reload();
|
||||||
|
|
||||||
return [tokens, updatedUser];
|
const tokens = await this.generateAuthToken(user);
|
||||||
}
|
this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`);
|
||||||
|
|
||||||
async setEmail(userId: string, { email }: SetEmailRequestDto) {
|
// Register/update device with FCM token and timezone if provided
|
||||||
const user = await this.userService.findUserOrThrow({ id: userId });
|
if (verifyUserDto.fcmToken && verifyUserDto.deviceId) {
|
||||||
|
await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone);
|
||||||
if (user.email) {
|
|
||||||
throw new BadRequestException('USERS.EMAIL_ALREADY_SET');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await this.userService.findUser({ email });
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.userService.setEmail(userId, email);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPasscode(userId: string, passcode: string) {
|
|
||||||
const user = await this.userService.findUserOrThrow({ id: userId });
|
|
||||||
|
|
||||||
if (user.password) {
|
|
||||||
throw new BadRequestException('USERS.PASSCODE_ALREADY_SET');
|
|
||||||
}
|
|
||||||
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
|
|
||||||
const hashedPasscode = bcrypt.hashSync(passcode, salt);
|
|
||||||
|
|
||||||
await this.userService.setPasscode(userId, hashedPasscode, salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
|
|
||||||
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return this.deviceService.createDevice({
|
|
||||||
deviceId,
|
|
||||||
userId,
|
|
||||||
publicKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (device.publicKey) {
|
|
||||||
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) {
|
|
||||||
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!device.publicKey) {
|
|
||||||
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.deviceService.updateDevice(deviceId, { publicKey: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
|
|
||||||
const user = await this.userService.findUserOrThrow({ email });
|
|
||||||
|
|
||||||
if (!user.isProfileCompleted) {
|
|
||||||
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.otpService.generateAndSendOtp({
|
|
||||||
userId: user.id,
|
|
||||||
recipient: user.email,
|
|
||||||
scope: OtpScope.FORGET_PASSWORD,
|
|
||||||
otpType: OtpType.EMAIL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) {
|
|
||||||
const user = await this.userService.findUserOrThrow({ email });
|
|
||||||
if (!user.isProfileCompleted) {
|
|
||||||
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
|
|
||||||
}
|
|
||||||
const isOtpValid = await this.otpService.verifyOtp({
|
|
||||||
userId: user.id,
|
|
||||||
scope: OtpScope.FORGET_PASSWORD,
|
|
||||||
otpType: OtpType.EMAIL,
|
|
||||||
value: otp,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isOtpValid) {
|
|
||||||
throw new BadRequestException('USERS.INVALID_OTP');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validatePassword(password, confirmPassword, user);
|
|
||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync(password, user.salt);
|
|
||||||
|
|
||||||
await this.userService.setPasscode(user.id, hashedPassword, user.salt);
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
|
|
||||||
const user = await this.userService.findUser({ email: loginDto.email });
|
|
||||||
let tokens;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loginDto.grantType === GrantType.PASSWORD) {
|
|
||||||
tokens = await this.loginWithPassword(loginDto, user);
|
|
||||||
} else {
|
|
||||||
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() });
|
|
||||||
|
|
||||||
return [tokens, user];
|
return [tokens, user];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
|
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.fullPhoneNumber,
|
||||||
|
scope: OtpScope.FORGET_PASSWORD,
|
||||||
|
otpType: OtpType.SMS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.SMS,
|
||||||
|
value: otp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOtpValid) {
|
||||||
|
this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`);
|
||||||
|
throw new BadRequestException('OTP.INVALID_OTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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 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.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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
|
||||||
|
this.logger.log('Refreshing token');
|
||||||
|
|
||||||
|
const isBlackListed = await this.cacheService.get(refreshToken);
|
||||||
|
|
||||||
|
if (isBlackListed) {
|
||||||
|
this.logger.error('Refresh token is blacklisted');
|
||||||
|
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
|
||||||
|
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Refreshing token for user with id ${isValid.sub}`);
|
||||||
|
|
||||||
|
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
|
||||||
|
|
||||||
|
const tokens = await this.generateAuthToken(user);
|
||||||
|
|
||||||
|
this.logger.log(`Blacklisting old tokens for user with id ${isValid.sub}`);
|
||||||
|
|
||||||
|
const refreshTokenExpiry = this.jwtService.decode(refreshToken).exp - Date.now() / ONE_THOUSAND;
|
||||||
|
|
||||||
|
await this.cacheService.set(refreshToken, 'BLACKLISTED', refreshTokenExpiry);
|
||||||
|
|
||||||
|
this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`);
|
||||||
|
|
||||||
|
return [tokens, user];
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Invalid refresh token');
|
||||||
|
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(req: Request) {
|
||||||
|
this.logger.log('Logging out');
|
||||||
|
const accessToken = req.headers.authorization?.split(' ')[1] as string;
|
||||||
|
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
|
||||||
|
return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> {
|
||||||
|
const user = await this.userService.findUser({
|
||||||
|
countryCode: loginDto.countryCode,
|
||||||
|
phoneNumber: loginDto.phoneNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
|
this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`);
|
||||||
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await this.generateAuthToken(user);
|
const tokens = await this.generateAuthToken(user);
|
||||||
|
this.logger.log(`Password validated successfully for user`);
|
||||||
|
|
||||||
return tokens;
|
// 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, user: User, deviceId: string): Promise<ILoginResponse> {
|
async juniorLogin(juniorLoginDto: JuniorLoginRequestDto): Promise<[ILoginResponse, User]> {
|
||||||
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
|
const user = await this.userService.findUser({ email: juniorLoginDto.email });
|
||||||
|
|
||||||
if (!device) {
|
if (!user || !user.roles.includes(Roles.JUNIOR)) {
|
||||||
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND');
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!device.publicKey) {
|
this.logger.log(`validating password for user with email ${juniorLoginDto.email}`);
|
||||||
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
|
const isPasswordValid = bcrypt.compareSync(juniorLoginDto.password, user.password);
|
||||||
}
|
|
||||||
|
|
||||||
const cleanToken = removePadding(loginDto.deviceToken);
|
if (!isPasswordValid) {
|
||||||
const isValidToken = await verifySignature(
|
this.logger.error(`Invalid password for user with email ${juniorLoginDto.email}`);
|
||||||
device.publicKey,
|
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
|
||||||
cleanToken,
|
|
||||||
`${user.email} - ${device.deviceId}`,
|
|
||||||
'SHA1',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidToken) {
|
|
||||||
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await this.generateAuthToken(user);
|
const tokens = await this.generateAuthToken(user);
|
||||||
|
this.logger.log(`Password validated successfully for user`);
|
||||||
|
|
||||||
return tokens;
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`);
|
||||||
|
|
||||||
|
// Step 1: Check if device already exists for this user
|
||||||
|
const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check if device exists for any user (different user scenario)
|
||||||
|
const existingDevice = await this.deviceService.findByDeviceId(deviceId);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateAuthToken(user: User) {
|
private async generateAuthToken(user: User) {
|
||||||
|
this.logger.log(`Generating auth token for user with id ${user.id}`);
|
||||||
const [accessToken, refreshToken] = await Promise.all([
|
const [accessToken, refreshToken] = await Promise.all([
|
||||||
this.jwtService.sign(
|
this.jwtService.sign(
|
||||||
{ sub: user.id, roles: user.roles },
|
{ sub: user.id, roles: user.roles },
|
||||||
@ -244,22 +392,7 @@ 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) };
|
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePassword(password: string, confirmPassword: string, user: User) {
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
|
|
||||||
}
|
|
||||||
|
|
||||||
const roles = user.roles;
|
|
||||||
|
|
||||||
if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) {
|
|
||||||
throw new BadRequestException('AUTH.INVALID_PASSCODE');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) {
|
|
||||||
throw new BadRequestException('AUTH.INVALID_PASSWORD');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Device } from '../entities';
|
|
||||||
import { DeviceRepository } from '../repositories';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class DeviceService {
|
|
||||||
constructor(private readonly deviceRepository: DeviceRepository) {}
|
|
||||||
findUserDeviceById(deviceId: string, userId: string) {
|
|
||||||
return this.deviceRepository.findUserDeviceById(deviceId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
createDevice(data: Partial<Device>) {
|
|
||||||
return this.deviceRepository.createDevice(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDevice(deviceId: string, data: Partial<Device>) {
|
|
||||||
return this.deviceRepository.updateDevice(deviceId, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +1 @@
|
|||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './device.service';
|
|
||||||
export * from './user.service';
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
|
||||||
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request';
|
|
||||||
import { CreateUnverifiedUserRequestDto } from '../dtos/request';
|
|
||||||
import { User } from '../entities';
|
|
||||||
import { Roles } from '../enums';
|
|
||||||
import { UserRepository } from '../repositories';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UserService {
|
|
||||||
constructor(private readonly userRepository: UserRepository) {}
|
|
||||||
|
|
||||||
async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) {
|
|
||||||
const user = await this.findUserOrThrow({ id: userId });
|
|
||||||
|
|
||||||
const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body))
|
|
||||||
.notificationSettings;
|
|
||||||
|
|
||||||
return notificationSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
findUser(where: FindOptionsWhere<User>) {
|
|
||||||
return this.userRepository.findOne(where);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUserOrThrow(where: FindOptionsWhere<User>) {
|
|
||||||
const user = await this.findUser(where);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException('USERS.NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOrCreateUser({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
|
|
||||||
const user = await this.userRepository.findOne({ phoneNumber });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return this.userRepository.createUnverifiedUser({ phoneNumber, countryCode, roles: [Roles.GUARDIAN] });
|
|
||||||
}
|
|
||||||
if (user && user.roles.includes(Roles.GUARDIAN) && user.isPasswordSet) {
|
|
||||||
throw new BadRequestException('USERS.PHONE_NUMBER_ALREADY_EXISTS');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user && user.roles.includes(Roles.JUNIOR)) {
|
|
||||||
//TODO add role Guardian to the existing user and send OTP
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEmail(userId: string, email: string) {
|
|
||||||
return this.userRepository.update(userId, { email });
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasscode(userId: string, passcode: string, salt: string) {
|
|
||||||
return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyUserAndCreateCustomer(user: User) {
|
|
||||||
return this.userRepository.verifyUserAndCreateCustomer(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { UserService } from '~/user/services';
|
||||||
import { IJwtPayload } from '../interfaces';
|
import { IJwtPayload } from '../interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
|
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-token') {
|
||||||
constructor(configService: ConfigService) {
|
constructor(configService: ConfigService, private userService: UserService) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
@ -14,7 +15,13 @@ export class AccessTokenStrategy extends PassportStrategy(Strategy, 'access-toke
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: IJwtPayload) {
|
async validate(payload: IJwtPayload) {
|
||||||
|
const user = await this.userService.findUser({ id: payload.sub });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/card/card.module.ts
Normal file
33
src/card/card.module.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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';
|
||||||
|
import { CardRepository } from './repositories';
|
||||||
|
import { AccountRepository } from './repositories/account.repository';
|
||||||
|
import { TransactionRepository } from './repositories/transaction.repository';
|
||||||
|
import { CardService } from './services';
|
||||||
|
import { AccountService } from './services/account.service';
|
||||||
|
import { TransactionService } from './services/transaction.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Card, Account, Transaction]),
|
||||||
|
forwardRef(() => NeoLeapModule),
|
||||||
|
forwardRef(() => CustomerModule), // <-- add forwardRef here
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CardService,
|
||||||
|
CardRepository,
|
||||||
|
TransactionService,
|
||||||
|
TransactionRepository,
|
||||||
|
AccountService,
|
||||||
|
AccountRepository,
|
||||||
|
],
|
||||||
|
exports: [CardService, TransactionService, AccountService],
|
||||||
|
controllers: [CardsController],
|
||||||
|
})
|
||||||
|
export class CardModule {}
|
||||||
86
src/card/controllers/cards.controller.ts
Normal file
86
src/card/controllers/cards.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/card/controllers/index.ts
Normal file
1
src/card/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './cards.controller';
|
||||||
9
src/card/dtos/requests/fund-iban.request.dto.ts
Normal file
9
src/card/dtos/requests/fund-iban.request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/card/dtos/requests/index.ts
Normal file
1
src/card/dtos/requests/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './fund-iban.request.dto';
|
||||||
10
src/card/dtos/responses/account-iban.response.dto.ts
Normal file
10
src/card/dtos/responses/account-iban.response.dto.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AccountIbanResponseDto {
|
||||||
|
@ApiProperty({ example: 'DE89370400440532013000' })
|
||||||
|
iban!: string;
|
||||||
|
|
||||||
|
constructor(iban: string) {
|
||||||
|
this.iban = iban;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/card/dtos/responses/card.response.dto.ts
Normal file
66
src/card/dtos/responses/card.response.dto.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Card } from '~/card/entities';
|
||||||
|
import { CardScheme, CardStatus, CustomerType } from '~/card/enums';
|
||||||
|
import { CardStatusDescriptionMapper } from '~/card/mappers/card-status-description.mapper';
|
||||||
|
import { UserLocale } from '~/core/enums';
|
||||||
|
|
||||||
|
export class CardResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'b34df8c2-5d3e-4b1a-9c2f-7e3b1a2d3f4e',
|
||||||
|
})
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '123456',
|
||||||
|
description: 'The first six digits of the card number.',
|
||||||
|
})
|
||||||
|
firstSixDigits!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '7890', description: 'The last four digits of the card number.' })
|
||||||
|
lastFourDigits!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: CardScheme,
|
||||||
|
description: 'The card scheme (e.g., VISA, MASTERCARD).',
|
||||||
|
})
|
||||||
|
scheme!: CardScheme;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: CardStatus,
|
||||||
|
description: 'The current status of the card (e.g., ACTIVE, PENDING).',
|
||||||
|
})
|
||||||
|
status!: CardStatus;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'The card is active',
|
||||||
|
description: 'A description of the card status.',
|
||||||
|
})
|
||||||
|
statusDescription!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 2000.0,
|
||||||
|
description: 'The credit limit of the card.',
|
||||||
|
})
|
||||||
|
balance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 100.0,
|
||||||
|
nullable: true,
|
||||||
|
description: 'The reserved balance of the card (applicable for child accounts).',
|
||||||
|
})
|
||||||
|
reservedBalance!: number | null;
|
||||||
|
|
||||||
|
constructor(card: Card) {
|
||||||
|
this.id = card.id;
|
||||||
|
this.firstSixDigits = card.firstSixDigits;
|
||||||
|
this.lastFourDigits = card.lastFourDigits;
|
||||||
|
this.scheme = card.scheme;
|
||||||
|
this.status = card.status;
|
||||||
|
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
|
||||||
|
this.balance =
|
||||||
|
card.customerType === CustomerType.CHILD
|
||||||
|
? Math.min(card.limit, card.account.balance)
|
||||||
|
: card.account.balance - card.account.reservedBalance;
|
||||||
|
this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/card/dtos/responses/child-card.response.dto.ts
Normal file
48
src/card/dtos/responses/child-card.response.dto.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Card } from '~/card/entities';
|
||||||
|
import { Gender } from '~/customer/enums';
|
||||||
|
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||||
|
import { CardResponseDto } from './card.response.dto';
|
||||||
|
|
||||||
|
class JuniorInfo {
|
||||||
|
@ApiProperty({ example: 'id' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'FirstName' })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'LastName' })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'test@example.com' })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: Gender, example: Gender.MALE })
|
||||||
|
gender!: Gender;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2000-01-01' })
|
||||||
|
dateOfBirth!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: DocumentMetaResponseDto, nullable: true })
|
||||||
|
profilePicture!: DocumentMetaResponseDto | null;
|
||||||
|
|
||||||
|
constructor(card: Card) {
|
||||||
|
this.id = card.customer?.junior?.id;
|
||||||
|
this.firstName = card.customer?.firstName;
|
||||||
|
this.lastName = card.customer?.lastName;
|
||||||
|
this.email = card.customer?.user?.email;
|
||||||
|
this.gender = card.customer.gender;
|
||||||
|
this.profilePicture = card.customer?.user?.profilePicture
|
||||||
|
? new DocumentMetaResponseDto(card.customer.user.profilePicture)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ChildCardResponseDto extends CardResponseDto {
|
||||||
|
@ApiProperty({ type: JuniorInfo })
|
||||||
|
junior!: JuniorInfo | null;
|
||||||
|
|
||||||
|
constructor(card: Card) {
|
||||||
|
super(card);
|
||||||
|
this.junior = card.customer?.junior ? new JuniorInfo(card) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChildTransferItemDto {
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.0 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
|
|
||||||
17
src/card/dtos/responses/guardian-home.response.dto.ts
Normal file
17
src/card/dtos/responses/guardian-home.response.dto.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { TransactionItemResponseDto } from './transaction-item.response.dto';
|
||||||
|
|
||||||
|
export class GuardianHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 2000.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [TransactionItemResponseDto] })
|
||||||
|
recentTransactions!: TransactionItemResponseDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransactions = recentTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
src/card/dtos/responses/index.ts
Normal file
15
src/card/dtos/responses/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export * from './account-iban.response.dto';
|
||||||
|
export * from './card.response.dto';
|
||||||
|
export * from './child-card.response.dto';
|
||||||
|
export * from './transaction-item.response.dto';
|
||||||
|
export * from './guardian-home.response.dto';
|
||||||
|
export * from './paged-transactions.response.dto';
|
||||||
|
export * from './parent-transfer-item.response.dto';
|
||||||
|
export * from './parent-home.response.dto';
|
||||||
|
export * from './paged-parent-transfers.response.dto';
|
||||||
|
export * from './child-transfer-item.response.dto';
|
||||||
|
export * from './junior-home.response.dto';
|
||||||
|
export * from './paged-child-transfers.response.dto';
|
||||||
|
export * from './spending-history-item.response.dto';
|
||||||
|
export * from './spending-history.response.dto';
|
||||||
|
export * from './transaction-detail.response.dto';
|
||||||
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class JuniorHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 500.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||||
|
recentTransfers!: ChildTransferItemDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransfers = recentTransfers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedChildTransfersResponseDto {
|
||||||
|
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||||
|
items!: ChildTransferItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 20 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: ChildTransferItemDto[],
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.page = page;
|
||||||
|
this.size = size;
|
||||||
|
this.total = total;
|
||||||
|
this.hasMore = page * size < total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedParentTransfersResponseDto {
|
||||||
|
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||||
|
items!: ParentTransferItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 45 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: ParentTransferItemDto[],
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.page = page;
|
||||||
|
this.size = size;
|
||||||
|
this.total = total;
|
||||||
|
this.hasMore = page * size < total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
src/card/dtos/responses/paged-transactions.response.dto.ts
Normal file
33
src/card/dtos/responses/paged-transactions.response.dto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { TransactionItemResponseDto } from './transaction-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedTransactionsResponseDto {
|
||||||
|
@ApiProperty({ type: [TransactionItemResponseDto] })
|
||||||
|
items!: TransactionItemResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 45 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: TransactionItemResponseDto[],
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.page = page;
|
||||||
|
this.size = size;
|
||||||
|
this.total = total;
|
||||||
|
this.hasMore = page * size < total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class ParentHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 2000.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||||
|
recentTransfers!: ParentTransferItemDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransfers = recentTransfers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ParentTransferItemDto {
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.0 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Ahmed Ali' })
|
||||||
|
childName!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transaction } from '~/card/entities/transaction.entity';
|
||||||
|
|
||||||
|
export class SpendingHistoryItemDto {
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.5 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Shopping' })
|
||||||
|
category!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Target Store' })
|
||||||
|
merchantName!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Riyadh' })
|
||||||
|
merchantCity!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '277012*****3456' })
|
||||||
|
cardMasked!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
transactionId!: string;
|
||||||
|
|
||||||
|
constructor(transaction: Transaction) {
|
||||||
|
this.date = transaction.transactionDate;
|
||||||
|
this.amount = transaction.transactionAmount;
|
||||||
|
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
|
||||||
|
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
|
||||||
|
this.merchantName = transaction.merchantName;
|
||||||
|
this.merchantCity = transaction.merchantCity;
|
||||||
|
this.cardMasked = transaction.cardMaskedNumber;
|
||||||
|
this.transactionId = transaction.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapMccToCategory(mcc: string | null): string {
|
||||||
|
if (!mcc) return 'Other';
|
||||||
|
|
||||||
|
const mccCode = mcc;
|
||||||
|
|
||||||
|
// Map MCC codes to categories
|
||||||
|
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
|
||||||
|
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
|
||||||
|
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
|
||||||
|
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
|
||||||
|
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
|
||||||
|
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
|
||||||
|
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
|
||||||
|
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
|
||||||
|
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal file
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { SpendingHistoryItemDto } from './spending-history-item.response.dto';
|
||||||
|
|
||||||
|
export class SpendingHistoryResponseDto {
|
||||||
|
@ApiProperty({ type: [SpendingHistoryItemDto] })
|
||||||
|
transactions!: SpendingHistoryItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 150.75 })
|
||||||
|
totalSpent!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
count!: number;
|
||||||
|
|
||||||
|
constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') {
|
||||||
|
this.transactions = transactions;
|
||||||
|
this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0);
|
||||||
|
this.currency = currency;
|
||||||
|
this.count = transactions.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal file
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transaction } from '~/card/entities/transaction.entity';
|
||||||
|
|
||||||
|
export class TransactionDetailResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.5 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 2.5 })
|
||||||
|
fees!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0.5 })
|
||||||
|
vatOnFees!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Target Store' })
|
||||||
|
merchantName!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Shopping' })
|
||||||
|
category!: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Riyadh' })
|
||||||
|
merchantCity!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '277012*****3456' })
|
||||||
|
cardMasked!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
rrn!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
transactionId!: string;
|
||||||
|
|
||||||
|
constructor(transaction: Transaction) {
|
||||||
|
this.id = transaction.id;
|
||||||
|
this.date = transaction.transactionDate;
|
||||||
|
this.amount = transaction.transactionAmount;
|
||||||
|
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
|
||||||
|
this.fees = transaction.fees;
|
||||||
|
this.vatOnFees = transaction.vatOnFees;
|
||||||
|
this.merchantName = transaction.merchantName;
|
||||||
|
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
|
||||||
|
this.merchantCity = transaction.merchantCity;
|
||||||
|
this.cardMasked = transaction.cardMaskedNumber;
|
||||||
|
this.rrn = transaction.rrn;
|
||||||
|
this.transactionId = transaction.transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapMccToCategory(mcc: string | null): string {
|
||||||
|
if (!mcc) return 'Other';
|
||||||
|
|
||||||
|
const mccCode = mcc;
|
||||||
|
|
||||||
|
// Map MCC codes to categories
|
||||||
|
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
|
||||||
|
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
|
||||||
|
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
|
||||||
|
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
|
||||||
|
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
|
||||||
|
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
|
||||||
|
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
|
||||||
|
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
|
||||||
|
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
24
src/card/dtos/responses/transaction-item.response.dto.ts
Normal file
24
src/card/dtos/responses/transaction-item.response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransactionType } from '~/card/enums';
|
||||||
|
|
||||||
|
export class TransactionItemResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: -50.0 })
|
||||||
|
amountSigned!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ParentTransactionType })
|
||||||
|
type!: ParentTransactionType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' })
|
||||||
|
counterpartyName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
counterpartyAccountMasked!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
childName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
60
src/card/entities/account.entity.ts
Normal file
60
src/card/entities/account.entity.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
import { Card } from './card.entity';
|
||||||
|
import { Transaction } from './transaction.entity';
|
||||||
|
|
||||||
|
@Entity('accounts')
|
||||||
|
export class Account {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 255, nullable: false, unique: true, name: 'account_reference' })
|
||||||
|
@Index({ unique: true })
|
||||||
|
accountReference!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar', { length: 255, nullable: false, name: 'account_number' })
|
||||||
|
accountNumber!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column('varchar', { length: 255, nullable: false, name: 'iban' })
|
||||||
|
iban!: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column('decimal', {
|
||||||
|
precision: 10,
|
||||||
|
scale: 2,
|
||||||
|
default: 0.0,
|
||||||
|
name: 'balance',
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => parseFloat(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
balance!: number;
|
||||||
|
|
||||||
|
@Column('decimal', {
|
||||||
|
precision: 10,
|
||||||
|
scale: 2,
|
||||||
|
default: 0.0,
|
||||||
|
name: 'reserved_balance',
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => parseFloat(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reservedBalance!: number;
|
||||||
|
|
||||||
|
@OneToMany(() => Card, (card) => card.account, { cascade: true })
|
||||||
|
cards!: Card[];
|
||||||
|
|
||||||
|
@OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true })
|
||||||
|
transactions!: Transaction[];
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
89
src/card/entities/card.entity.ts
Normal file
89
src/card/entities/card.entity.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Customer } from '~/customer/entities';
|
||||||
|
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
|
||||||
|
import { Account } from './account.entity';
|
||||||
|
import { Transaction } from './transaction.entity';
|
||||||
|
|
||||||
|
@Entity('cards')
|
||||||
|
export class Card {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'card_reference', nullable: false, type: 'varchar' })
|
||||||
|
cardReference!: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'vpan', nullable: false, type: 'varchar' })
|
||||||
|
vpan!: string;
|
||||||
|
|
||||||
|
@Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' })
|
||||||
|
firstSixDigits!: string;
|
||||||
|
|
||||||
|
@Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' })
|
||||||
|
lastFourDigits!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false })
|
||||||
|
expiry!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false, name: 'customer_type' })
|
||||||
|
customerType!: CustomerType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false, default: CardColors.DEEP_MAGENTA })
|
||||||
|
color!: CardColors;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })
|
||||||
|
status!: CardStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false, default: CardStatusDescription.PENDING_ACTIVATION })
|
||||||
|
statusDescription!: CardStatusDescription;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0.0, name: 'limit' })
|
||||||
|
limit!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false, default: CardScheme.VISA })
|
||||||
|
scheme!: CardScheme;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: false })
|
||||||
|
issuer!: CardIssuers;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'customer_id', nullable: false })
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'account_id', nullable: false })
|
||||||
|
accountId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Customer, (customer) => customer.childCards)
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parentCustomer?: Customer;
|
||||||
|
|
||||||
|
@ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'customer_id' })
|
||||||
|
customer!: Customer;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'account_id' })
|
||||||
|
account!: Account;
|
||||||
|
|
||||||
|
@OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true })
|
||||||
|
transactions!: Transaction[];
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
1
src/card/entities/index.ts
Normal file
1
src/card/entities/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './card.entity';
|
||||||
87
src/card/entities/transaction.entity.ts
Normal file
87
src/card/entities/transaction.entity.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { TransactionScope, TransactionType } from '../enums';
|
||||||
|
import { Account } from './account.entity';
|
||||||
|
import { Card } from './card.entity';
|
||||||
|
|
||||||
|
@Entity('transactions')
|
||||||
|
export class Transaction {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'transaction_scope', type: 'varchar', nullable: false })
|
||||||
|
transactionScope!: TransactionScope;
|
||||||
|
|
||||||
|
@Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL })
|
||||||
|
transactionType!: TransactionType;
|
||||||
|
|
||||||
|
@Column({ name: 'card_reference', nullable: true, type: 'varchar' })
|
||||||
|
cardReference!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'account_reference', nullable: true, type: 'varchar' })
|
||||||
|
accountReference!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'transaction_id', unique: true, nullable: true, type: 'varchar' })
|
||||||
|
transactionId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_masked_number', nullable: true, type: 'varchar' })
|
||||||
|
cardMaskedNumber!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', name: 'transaction_date', nullable: true })
|
||||||
|
transactionDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
|
||||||
|
rrn!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 2,
|
||||||
|
name: 'transaction_amount',
|
||||||
|
transformer: {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => parseFloat(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
transactionAmount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', name: 'transaction_currency' })
|
||||||
|
transactionCurrency!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', name: 'billing_amount', precision: 12, scale: 2 })
|
||||||
|
billingAmount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', name: 'settlement_amount', precision: 12, scale: 2 })
|
||||||
|
settlementAmount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 })
|
||||||
|
fees!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
|
||||||
|
vatOnFees!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'merchant_name', type: 'varchar', nullable: true })
|
||||||
|
merchantName!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'merchant_category_code', type: 'varchar', nullable: true })
|
||||||
|
merchantCategoryCode!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'merchant_city', type: 'varchar', nullable: true })
|
||||||
|
merchantCity!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'card_id', type: 'uuid', nullable: true })
|
||||||
|
cardId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'account_id', type: 'uuid', nullable: true })
|
||||||
|
accountId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'card_id' })
|
||||||
|
card!: Card;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'account_id' })
|
||||||
|
account!: Account;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
13
src/card/enums/card-colors.enum.ts
Normal file
13
src/card/enums/card-colors.enum.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export enum CardColors {
|
||||||
|
RAINBOW_PASTEL = 'RAINBOW_PASTEL',
|
||||||
|
DEEP_MAGENTA = 'DEEP_MAGENTA',
|
||||||
|
GREEN_TEAL = 'GREEN_TEAL',
|
||||||
|
|
||||||
|
BLUE_GREEN = 'BLUE_GREEN',
|
||||||
|
TEAL_NAVY = 'TEAL_NAVY',
|
||||||
|
PURPLE_PINK = 'PURPLE_PINK',
|
||||||
|
|
||||||
|
GOLD_BLUE = 'GOLD_BLUE',
|
||||||
|
OCEAN_BLUE = 'OCEAN_BLUE',
|
||||||
|
BROWN_RUST = 'BROWN_RUST',
|
||||||
|
}
|
||||||
3
src/card/enums/card-issuers.enum.ts
Normal file
3
src/card/enums/card-issuers.enum.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export enum CardIssuers {
|
||||||
|
NEOLEAP = 'NEOLEAP',
|
||||||
|
}
|
||||||
4
src/card/enums/card-scheme.enum.ts
Normal file
4
src/card/enums/card-scheme.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum CardScheme {
|
||||||
|
VISA = 'VISA',
|
||||||
|
MASTERCARD = 'MASTERCARD',
|
||||||
|
}
|
||||||
68
src/card/enums/card-status-description.enum.ts
Normal file
68
src/card/enums/card-status-description.enum.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* import { CardStatus, CardStatusDescription } from '../enums';
|
||||||
|
|
||||||
|
export const CardStatusMapper: Record<string, { description: CardStatusDescription; status: CardStatus }> = {
|
||||||
|
//ACTIVE
|
||||||
|
'00': { description: 'NORMAL', status: CardStatus.ACTIVE },
|
||||||
|
|
||||||
|
//PENDING
|
||||||
|
'02': { description: 'NOT_YET_ISSUED', status: CardStatus.PENDING },
|
||||||
|
'20': { description: 'PENDING_ISSUANCE', status: CardStatus.PENDING },
|
||||||
|
'21': { description: 'CARD_EXTRACTED', status: CardStatus.PENDING },
|
||||||
|
'22': { description: 'EXTRACTION_FAILED', status: CardStatus.PENDING },
|
||||||
|
'23': { description: 'FAILED_PRINTING_BULK', status: CardStatus.PENDING },
|
||||||
|
'24': { description: 'FAILED_PRINTING_INST', status: CardStatus.PENDING },
|
||||||
|
'30': { description: 'PENDING_ACTIVATION', status: CardStatus.PENDING },
|
||||||
|
'27': { description: 'PENDING_PIN', status: CardStatus.PENDING },
|
||||||
|
'16': { description: 'PREPARE_TO_CLOSE', status: CardStatus.PENDING },
|
||||||
|
|
||||||
|
//BLOCKED
|
||||||
|
'01': { description: 'PIN_TRIES_EXCEEDED', status: CardStatus.BLOCKED },
|
||||||
|
'03': { description: 'CARD_EXPIRED', status: CardStatus.BLOCKED },
|
||||||
|
'04': { description: 'LOST', status: CardStatus.BLOCKED },
|
||||||
|
'05': { description: 'STOLEN', status: CardStatus.BLOCKED },
|
||||||
|
'06': { description: 'CUSTOMER_CLOSE', status: CardStatus.BLOCKED },
|
||||||
|
'07': { description: 'BANK_CANCELLED', status: CardStatus.BLOCKED },
|
||||||
|
'08': { description: 'FRAUD', status: CardStatus.BLOCKED },
|
||||||
|
'09': { description: 'DAMAGED', status: CardStatus.BLOCKED },
|
||||||
|
'50': { description: 'SAFE_BLOCK', status: CardStatus.BLOCKED },
|
||||||
|
'51': { description: 'TEMPORARY_BLOCK', status: CardStatus.BLOCKED },
|
||||||
|
'52': { description: 'RISK_BLOCK', status: CardStatus.BLOCKED },
|
||||||
|
'53': { description: 'OVERDRAFT', status: CardStatus.BLOCKED },
|
||||||
|
'54': { description: 'BLOCKED_FOR_FEES', status: CardStatus.BLOCKED },
|
||||||
|
'67': { description: 'CLOSED_CUSTOMER_DEAD', status: CardStatus.BLOCKED },
|
||||||
|
'75': { description: 'RETURN_CARD', status: CardStatus.BLOCKED },
|
||||||
|
|
||||||
|
//Fallback
|
||||||
|
'99': { description: 'UNKNOWN', status: CardStatus.PENDING },
|
||||||
|
};
|
||||||
|
|
||||||
|
*/
|
||||||
|
export enum CardStatusDescription {
|
||||||
|
NORMAL = 'NORMAL',
|
||||||
|
NOT_YET_ISSUED = 'NOT_YET_ISSUED',
|
||||||
|
PENDING_ISSUANCE = 'PENDING_ISSUANCE',
|
||||||
|
CARD_EXTRACTED = 'CARD_EXTRACTED',
|
||||||
|
EXTRACTION_FAILED = 'EXTRACTION_FAILED',
|
||||||
|
FAILED_PRINTING_BULK = 'FAILED_PRINTING_BULK',
|
||||||
|
FAILED_PRINTING_INST = 'FAILED_PRINTING_INST',
|
||||||
|
PENDING_ACTIVATION = 'PENDING_ACTIVATION',
|
||||||
|
PENDING_PIN = 'PENDING_PIN',
|
||||||
|
PREPARE_TO_CLOSE = 'PREPARE_TO_CLOSE',
|
||||||
|
PIN_TRIES_EXCEEDED = 'PIN_TRIES_EXCEEDED',
|
||||||
|
CARD_EXPIRED = 'CARD_EXPIRED',
|
||||||
|
LOST = 'LOST',
|
||||||
|
STOLEN = 'STOLEN',
|
||||||
|
CUSTOMER_CLOSE = 'CUSTOMER_CLOSE',
|
||||||
|
BANK_CANCELLED = 'BANK_CANCELLED',
|
||||||
|
FRAUD = 'FRAUD',
|
||||||
|
DAMAGED = 'DAMAGED',
|
||||||
|
SAFE_BLOCK = 'SAFE_BLOCK',
|
||||||
|
TEMPORARY_BLOCK = 'TEMPORARY_BLOCK',
|
||||||
|
RISK_BLOCK = 'RISK_BLOCK',
|
||||||
|
OVERDRAFT = 'OVERDRAFT',
|
||||||
|
BLOCKED_FOR_FEES = 'BLOCKED_FOR_FEES',
|
||||||
|
CLOSED_CUSTOMER_DEAD = 'CLOSED_CUSTOMER_DEAD',
|
||||||
|
RETURN_CARD = 'RETURN_CARD',
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
6
src/card/enums/card-status.enum.ts
Normal file
6
src/card/enums/card-status.enum.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum CardStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
CANCELED = 'CANCELED',
|
||||||
|
BLOCKED = 'BLOCKED',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
}
|
||||||
4
src/card/enums/customer-type.enum.ts
Normal file
4
src/card/enums/customer-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum CustomerType {
|
||||||
|
PARENT = 'PARENT',
|
||||||
|
CHILD = 'CHILD',
|
||||||
|
}
|
||||||
9
src/card/enums/index.ts
Normal file
9
src/card/enums/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export * from './card-colors.enum';
|
||||||
|
export * from './card-issuers.enum';
|
||||||
|
export * from './card-scheme.enum';
|
||||||
|
export * from './card-status-description.enum';
|
||||||
|
export * from './card-status.enum';
|
||||||
|
export * from './customer-type.enum';
|
||||||
|
export * from './transaction-scope.enum';
|
||||||
|
export * from './transaction-type.enum';
|
||||||
|
export * from './parent-transaction-type.enum';
|
||||||
6
src/card/enums/parent-transaction-type.enum.ts
Normal file
6
src/card/enums/parent-transaction-type.enum.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum ParentTransactionType {
|
||||||
|
PARENT_TRANSFER = 'PARENT_TRANSFER',
|
||||||
|
PARENT_TOPUP = 'PARENT_TOPUP',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
4
src/card/enums/transaction-scope.enum.ts
Normal file
4
src/card/enums/transaction-scope.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum TransactionScope {
|
||||||
|
CARD = 'CARD',
|
||||||
|
ACCOUNT = 'ACCOUNT',
|
||||||
|
}
|
||||||
4
src/card/enums/transaction-type.enum.ts
Normal file
4
src/card/enums/transaction-type.enum.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export enum TransactionType {
|
||||||
|
INTERNAL = 'INTERNAL',
|
||||||
|
EXTERNAL = 'EXTERNAL',
|
||||||
|
}
|
||||||
112
src/card/mappers/card-status-description.mapper.ts
Normal file
112
src/card/mappers/card-status-description.mapper.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { UserLocale } from '~/core/enums';
|
||||||
|
import { CardStatusDescription } from '../enums';
|
||||||
|
|
||||||
|
export const CardStatusDescriptionMapper: Record<
|
||||||
|
CardStatusDescription,
|
||||||
|
{ [key in UserLocale]: { description: string } }
|
||||||
|
> = {
|
||||||
|
[CardStatusDescription.NORMAL]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is active' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة نشطة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.NOT_YET_ISSUED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is not yet issued' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة لم تصدر بعد' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.PENDING_ISSUANCE]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is pending issuance' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإصدار' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.CARD_EXTRACTED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card has been extracted' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'تم استخراج البطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.EXTRACTION_FAILED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card extraction has failed' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'فشل استخراج البطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.FAILED_PRINTING_BULK]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card printing in bulk has failed' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'فشل الطباعة بالجملة للبطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.FAILED_PRINTING_INST]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card printing in institution has failed' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'فشل الطباعة في المؤسسة للبطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.PENDING_ACTIVATION]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is pending activation' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد التفعيل' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.PENDING_PIN]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is pending PIN' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد الانتظار لرقم التعريف الشخصي' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.PREPARE_TO_CLOSE]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is being prepared for closure' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد التحضير للإغلاق' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.PIN_TRIES_EXCEEDED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card PIN tries have been exceeded' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'تم تجاوز محاولات رقم التعريف الشخصي للبطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.CARD_EXPIRED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card has expired' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'انتهت صلاحية البطاقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.LOST]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is lost' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة ضائعة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.STOLEN]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is stolen' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة مسروقة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.CUSTOMER_CLOSE]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is being closed by the customer' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإغلاق من قبل العميل' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.BANK_CANCELLED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card has been cancelled by the bank' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة ألغيت من قبل البنك' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.FRAUD]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'Fraud' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'احتيال' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.DAMAGED]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is damaged' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة تالفة' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.SAFE_BLOCK]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is in a safe block' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة في حظر آمن' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.TEMPORARY_BLOCK]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is in a temporary block' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة في حظر مؤقت' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.RISK_BLOCK]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is in a risk block' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة في حظر المخاطر' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.OVERDRAFT]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is in overdraft' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة في السحب على المكشوف' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.BLOCKED_FOR_FEES]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is blocked for fees' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة محظورة للرسوم' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.CLOSED_CUSTOMER_DEAD]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is closed because the customer is dead' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة مغلقة لأن العميل متوفى' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.RETURN_CARD]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card is being returned' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإرجاع' },
|
||||||
|
},
|
||||||
|
[CardStatusDescription.UNKNOWN]: {
|
||||||
|
[UserLocale.ENGLISH]: { description: 'The card status is unknown' },
|
||||||
|
[UserLocale.ARABIC]: { description: 'حالة البطاقة غير معروفة' },
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user