diff --git a/cmd/main.go b/cmd/main.go index e73f1bb..ad29b18 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,6 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/SamuelTariku/FortuneBet-Backend/internal/infrastructure" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" @@ -55,7 +54,6 @@ import ( httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" - "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/worker" ) // @title FortuneBet API @@ -119,7 +117,7 @@ func main() { branchSvc := branch.NewService(store) companySvc := company.NewService(store) leagueSvc := league.New(store) - ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger) + ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger, notificationSvc) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, logger, domain.MongoDBLogger) resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc) referalRepo := repository.NewReferralRepository(store) @@ -162,15 +160,17 @@ func main() { logger, ) - // Initialize report worker with CSV exporter - csvExporter := infrastructure.CSVExporter{ - ExportPath: cfg.ReportExportPath, // Make sure to add this to your config - } + go httpserver.SetupReportCronJobs(context.Background(), reportSvc) - reportWorker := worker.NewReportWorker( - reportSvc, - csvExporter, - ) + // Initialize report worker with CSV exporter + // csvExporter := infrastructure.CSVExporter{ + // ExportPath: cfg.ReportExportPath, // Make sure to add this to your config + // } + + // reportWorker := worker.NewReportWorker( + // reportSvc, + // csvExporter, + // ) // Start cron jobs for automated reporting @@ -196,7 +196,7 @@ func main() { httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc) httpserver.StartTicketCrons(*ticketSvc) - go httpserver.SetupReportCronJob(reportWorker) + // go httpserver.SetupReportCronJob(reportWorker) // Initialize and start HTTP server app := httpserver.NewApp( @@ -229,6 +229,7 @@ func main() { recommendationSvc, resultSvc, cfg, + domain.MongoDBLogger, ) logger.Info("Starting server", "port", cfg.Port) @@ -236,4 +237,5 @@ func main() { logger.Error("Failed to start server", "error", err) os.Exit(1) } + select {} } diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 15b0598..2332e39 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -78,4 +78,5 @@ DROP TABLE IF EXISTS odds; DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS leagues; DROP TABLE IF EXISTS teams; -DROP TABLE IF EXISTS settings; \ No newline at end of file +DROP TABLE IF EXISTS settings; +-- DELETE FROM wallet_transfer; \ No newline at end of file diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 8cf8b0f..b57d127 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -139,7 +139,7 @@ CREATE TABLE IF NOT EXISTS wallet_transfer ( sender_wallet_id BIGINT, cashier_id BIGINT, verified BOOLEAN DEFAULT false, - reference_number VARCHAR(255), + reference_number VARCHAR(255) NOT NULL, status VARCHAR(255), payment_method VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/db/query/bet.sql b/db/query/bet.sql index 00004db..0553e2d 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -118,4 +118,6 @@ DELETE FROM bets WHERE id = $1; -- name: DeleteBetOutcome :exec DELETE FROM bet_outcomes -WHERE bet_id = $1; \ No newline at end of file +WHERE bet_id = $1; + + diff --git a/db/query/report.sql b/db/query/report.sql new file mode 100644 index 0000000..7689643 --- /dev/null +++ b/db/query/report.sql @@ -0,0 +1,34 @@ +-- name: GetTotalBetsMadeInRange :one +SELECT COUNT(*) AS total_bets +FROM bets +WHERE created_at BETWEEN sqlc.arg('from') AND sqlc.arg('to') + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL +); +-- name: GetTotalCashMadeInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_made +FROM bets +WHERE created_at BETWEEN sqlc.arg('from') AND sqlc.arg('to') + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL +); +-- name: GetTotalCashOutInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_out +FROM bets +WHERE created_at BETWEEN sqlc.arg('from') AND sqlc.arg('to') + AND cashed_out = true + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL +); +-- name: GetTotalCashBacksInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_backs +FROM bets +WHERE created_at BETWEEN sqlc.arg('from') AND sqlc.arg('to') + AND status = 5 + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL +); diff --git a/db/query/ticket.sql b/db/query/ticket.sql index d091f04..7648842 100644 --- a/db/query/ticket.sql +++ b/db/query/ticket.sql @@ -58,4 +58,8 @@ Delete from tickets where created_at < now() - interval '1 day'; -- name: DeleteTicketOutcome :exec Delete from ticket_outcomes -where ticket_id = $1; \ No newline at end of file +where ticket_id = $1; +-- name: GetAllTicketsInRange :one +SELECT COUNT(*) as total_tickets, COALESCE(SUM(amount), 0) as total_amount +FROM tickets +WHERE created_at BETWEEN $1 AND $2; \ No newline at end of file diff --git a/db/query/transfer.sql b/db/query/transfer.sql index ac8e7ed..b4cc137 100644 --- a/db/query/transfer.sql +++ b/db/query/transfer.sql @@ -38,4 +38,13 @@ WHERE id = $2; UPDATE wallet_transfer SET status = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = $2; \ No newline at end of file +WHERE id = $2; + +-- name: GetWalletTransactionsInRange :many +SELECT type, COUNT(*) as count, SUM(amount) as total_amount +FROM wallet_transfer +WHERE created_at BETWEEN $1 AND $2 +GROUP BY type; + + + diff --git a/db/query/virtual_games.sql b/db/query/virtual_games.sql index 102cc78..799259d 100644 --- a/db/query/virtual_games.sql +++ b/db/query/virtual_games.sql @@ -61,3 +61,14 @@ WHERE external_transaction_id = $1; UPDATE virtual_game_transactions SET status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1; + +-- name: GetVirtualGameSummaryInRange :many +SELECT + vg.name AS game_name, + COUNT(vgh.id) AS number_of_bets, + COALESCE(SUM(vgh.amount), 0) AS total_transaction_sum +FROM virtual_game_histories vgh +JOIN virtual_games vg ON vgh.game_id = vg.id +WHERE vgh.transaction_type = 'BET' + AND vgh.created_at BETWEEN $1 AND $2 +GROUP BY vg.name; diff --git a/docker-compose.yml b/docker-compose.yml index bf5801f..39cb050 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: "3.8" + services: postgres: image: postgres:16-alpine @@ -54,6 +56,18 @@ services: networks: - app + redis: + image: redis:7-alpine + ports: + - "6379:6379" + networks: + - app + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + app: build: context: . @@ -64,14 +78,19 @@ services: environment: - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable - MONGO_URI=mongodb://root:secret@mongo:27017 + - REDIS_ADDR=redis:6379 depends_on: migrate: condition: service_completed_successfully mongo: condition: service_healthy + redis: + condition: service_healthy networks: - app command: ["/app/bin/web"] + volumes: + - "C:/Users/User/Desktop:/host-desktop" test: build: diff --git a/docs/docs.go b/docs/docs.go index ca94814..754c307 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -634,6 +634,123 @@ const docTemplate = `{ } } }, + "/api/v1/logs": { + "get": { + "description": "Fetches the 100 most recent application logs from MongoDB", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Retrieve latest application logs", + "responses": { + "200": { + "description": "List of application logs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.LogEntry" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/report-files/download/{filename}": { + "get": { + "description": "Downloads a generated report CSV file from the server", + "produces": [ + "text/csv" + ], + "tags": [ + "Reports" + ], + "summary": "Download a CSV report file", + "parameters": [ + { + "type": "string", + "description": "Name of the report file to download (e.g., report_daily_2025-06-21.csv)", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CSV file will be downloaded", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Missing or invalid filename", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Report file not found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal server error while serving the file", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/report-files/list": { + "get": { + "description": "Returns a list of all generated report CSV files available for download", + "produces": [ + "application/json" + ], + "tags": [ + "Reports" + ], + "summary": "List available report CSV files", + "responses": { + "200": { + "description": "List of CSV report filenames", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "500": { + "description": "Failed to read report directory", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/reports/dashboard": { "get": { "security": [ @@ -5193,6 +5310,36 @@ const docTemplate = `{ } } }, + "domain.LogEntry": { + "type": "object", + "properties": { + "caller": { + "type": "string" + }, + "env": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": true + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + }, + "service": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, "domain.Odd": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 3160c79..0402648 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -626,6 +626,123 @@ } } }, + "/api/v1/logs": { + "get": { + "description": "Fetches the 100 most recent application logs from MongoDB", + "produces": [ + "application/json" + ], + "tags": [ + "Logs" + ], + "summary": "Retrieve latest application logs", + "responses": { + "200": { + "description": "List of application logs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.LogEntry" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/report-files/download/{filename}": { + "get": { + "description": "Downloads a generated report CSV file from the server", + "produces": [ + "text/csv" + ], + "tags": [ + "Reports" + ], + "summary": "Download a CSV report file", + "parameters": [ + { + "type": "string", + "description": "Name of the report file to download (e.g., report_daily_2025-06-21.csv)", + "name": "filename", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CSV file will be downloaded", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Missing or invalid filename", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "404": { + "description": "Report file not found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal server error while serving the file", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/report-files/list": { + "get": { + "description": "Returns a list of all generated report CSV files available for download", + "produces": [ + "application/json" + ], + "tags": [ + "Reports" + ], + "summary": "List available report CSV files", + "responses": { + "200": { + "description": "List of CSV report filenames", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/domain.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "500": { + "description": "Failed to read report directory", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/reports/dashboard": { "get": { "security": [ @@ -5185,6 +5302,36 @@ } } }, + "domain.LogEntry": { + "type": "object", + "properties": { + "caller": { + "type": "string" + }, + "env": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": true + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + }, + "service": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, "domain.Odd": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5911b4a..ffb24c6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -395,6 +395,26 @@ definitions: example: 1 type: integer type: object + domain.LogEntry: + properties: + caller: + type: string + env: + type: string + fields: + additionalProperties: true + type: object + level: + type: string + message: + type: string + service: + type: string + stacktrace: + type: string + timestamp: + type: string + type: object domain.Odd: properties: category: @@ -1991,6 +2011,81 @@ paths: summary: Convert currency tags: - Multi-Currency + /api/v1/logs: + get: + description: Fetches the 100 most recent application logs from MongoDB + produces: + - application/json + responses: + "200": + description: List of application logs + schema: + items: + $ref: '#/definitions/domain.LogEntry' + type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Retrieve latest application logs + tags: + - Logs + /api/v1/report-files/download/{filename}: + get: + description: Downloads a generated report CSV file from the server + parameters: + - description: Name of the report file to download (e.g., report_daily_2025-06-21.csv) + in: path + name: filename + required: true + type: string + produces: + - text/csv + responses: + "200": + description: CSV file will be downloaded + schema: + type: file + "400": + description: Missing or invalid filename + schema: + $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Report file not found + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal server error while serving the file + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Download a CSV report file + tags: + - Reports + /api/v1/report-files/list: + get: + description: Returns a list of all generated report CSV files available for + download + produces: + - application/json + responses: + "200": + description: List of CSV report filenames + schema: + allOf: + - $ref: '#/definitions/domain.Response' + - properties: + data: + items: + type: string + type: array + type: object + "500": + description: Failed to read report directory + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: List available report CSV files + tags: + - Reports /api/v1/reports/dashboard: get: consumes: diff --git a/gen/db/models.go b/gen/db/models.go index 4ff2f82..ab7ecca 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -525,7 +525,7 @@ type WalletTransfer struct { SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` CashierID pgtype.Int8 `json:"cashier_id"` Verified pgtype.Bool `json:"verified"` - ReferenceNumber pgtype.Text `json:"reference_number"` + ReferenceNumber string `json:"reference_number"` Status pgtype.Text `json:"status"` PaymentMethod pgtype.Text `json:"payment_method"` CreatedAt pgtype.Timestamp `json:"created_at"` diff --git a/gen/db/report.sql.go b/gen/db/report.sql.go new file mode 100644 index 0000000..bcaab4d --- /dev/null +++ b/gen/db/report.sql.go @@ -0,0 +1,106 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: report.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetTotalBetsMadeInRange = `-- name: GetTotalBetsMadeInRange :one +SELECT COUNT(*) AS total_bets +FROM bets +WHERE created_at BETWEEN $1 AND $2 + AND ( + company_id = $3 + OR $3 IS NULL +) +` + +type GetTotalBetsMadeInRangeParams struct { + From pgtype.Timestamp `json:"from"` + To pgtype.Timestamp `json:"to"` + CompanyID pgtype.Int8 `json:"company_id"` +} + +func (q *Queries) GetTotalBetsMadeInRange(ctx context.Context, arg GetTotalBetsMadeInRangeParams) (int64, error) { + row := q.db.QueryRow(ctx, GetTotalBetsMadeInRange, arg.From, arg.To, arg.CompanyID) + var total_bets int64 + err := row.Scan(&total_bets) + return total_bets, err +} + +const GetTotalCashBacksInRange = `-- name: GetTotalCashBacksInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_backs +FROM bets +WHERE created_at BETWEEN $1 AND $2 + AND status = 5 + AND ( + company_id = $3 + OR $3 IS NULL +) +` + +type GetTotalCashBacksInRangeParams struct { + From pgtype.Timestamp `json:"from"` + To pgtype.Timestamp `json:"to"` + CompanyID pgtype.Int8 `json:"company_id"` +} + +func (q *Queries) GetTotalCashBacksInRange(ctx context.Context, arg GetTotalCashBacksInRangeParams) (interface{}, error) { + row := q.db.QueryRow(ctx, GetTotalCashBacksInRange, arg.From, arg.To, arg.CompanyID) + var total_cash_backs interface{} + err := row.Scan(&total_cash_backs) + return total_cash_backs, err +} + +const GetTotalCashMadeInRange = `-- name: GetTotalCashMadeInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_made +FROM bets +WHERE created_at BETWEEN $1 AND $2 + AND ( + company_id = $3 + OR $3 IS NULL +) +` + +type GetTotalCashMadeInRangeParams struct { + From pgtype.Timestamp `json:"from"` + To pgtype.Timestamp `json:"to"` + CompanyID pgtype.Int8 `json:"company_id"` +} + +func (q *Queries) GetTotalCashMadeInRange(ctx context.Context, arg GetTotalCashMadeInRangeParams) (interface{}, error) { + row := q.db.QueryRow(ctx, GetTotalCashMadeInRange, arg.From, arg.To, arg.CompanyID) + var total_cash_made interface{} + err := row.Scan(&total_cash_made) + return total_cash_made, err +} + +const GetTotalCashOutInRange = `-- name: GetTotalCashOutInRange :one +SELECT COALESCE(SUM(amount), 0) AS total_cash_out +FROM bets +WHERE created_at BETWEEN $1 AND $2 + AND cashed_out = true + AND ( + company_id = $3 + OR $3 IS NULL +) +` + +type GetTotalCashOutInRangeParams struct { + From pgtype.Timestamp `json:"from"` + To pgtype.Timestamp `json:"to"` + CompanyID pgtype.Int8 `json:"company_id"` +} + +func (q *Queries) GetTotalCashOutInRange(ctx context.Context, arg GetTotalCashOutInRangeParams) (interface{}, error) { + row := q.db.QueryRow(ctx, GetTotalCashOutInRange, arg.From, arg.To, arg.CompanyID) + var total_cash_out interface{} + err := row.Scan(&total_cash_out) + return total_cash_out, err +} diff --git a/gen/db/ticket.sql.go b/gen/db/ticket.sql.go index 4140384..c72c1ea 100644 --- a/gen/db/ticket.sql.go +++ b/gen/db/ticket.sql.go @@ -128,6 +128,29 @@ func (q *Queries) GetAllTickets(ctx context.Context) ([]TicketWithOutcome, error return items, nil } +const GetAllTicketsInRange = `-- name: GetAllTicketsInRange :one +SELECT COUNT(*) as total_tickets, COALESCE(SUM(amount), 0) as total_amount +FROM tickets +WHERE created_at BETWEEN $1 AND $2 +` + +type GetAllTicketsInRangeParams struct { + CreatedAt pgtype.Timestamp `json:"created_at"` + CreatedAt_2 pgtype.Timestamp `json:"created_at_2"` +} + +type GetAllTicketsInRangeRow struct { + TotalTickets int64 `json:"total_tickets"` + TotalAmount interface{} `json:"total_amount"` +} + +func (q *Queries) GetAllTicketsInRange(ctx context.Context, arg GetAllTicketsInRangeParams) (GetAllTicketsInRangeRow, error) { + row := q.db.QueryRow(ctx, GetAllTicketsInRange, arg.CreatedAt, arg.CreatedAt_2) + var i GetAllTicketsInRangeRow + err := row.Scan(&i.TotalTickets, &i.TotalAmount) + return i, err +} + const GetTicketByID = `-- name: GetTicketByID :one SELECT id, amount, total_odds, ip, created_at, updated_at, outcomes FROM ticket_with_outcomes diff --git a/gen/db/transfer.sql.go b/gen/db/transfer.sql.go index 18b6243..5055d84 100644 --- a/gen/db/transfer.sql.go +++ b/gen/db/transfer.sql.go @@ -34,7 +34,7 @@ type CreateTransferParams struct { SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` CashierID pgtype.Int8 `json:"cashier_id"` Verified pgtype.Bool `json:"verified"` - ReferenceNumber pgtype.Text `json:"reference_number"` + ReferenceNumber string `json:"reference_number"` Status pgtype.Text `json:"status"` PaymentMethod pgtype.Text `json:"payment_method"` } @@ -139,7 +139,7 @@ FROM wallet_transfer WHERE reference_number = $1 ` -func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber pgtype.Text) (WalletTransfer, error) { +func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber string) (WalletTransfer, error) { row := q.db.QueryRow(ctx, GetTransferByReference, referenceNumber) var i WalletTransfer err := row.Scan( @@ -199,6 +199,44 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgt return items, nil } +const GetWalletTransactionsInRange = `-- name: GetWalletTransactionsInRange :many +SELECT type, COUNT(*) as count, SUM(amount) as total_amount +FROM wallet_transfer +WHERE created_at BETWEEN $1 AND $2 +GROUP BY type +` + +type GetWalletTransactionsInRangeParams struct { + CreatedAt pgtype.Timestamp `json:"created_at"` + CreatedAt_2 pgtype.Timestamp `json:"created_at_2"` +} + +type GetWalletTransactionsInRangeRow struct { + Type pgtype.Text `json:"type"` + Count int64 `json:"count"` + TotalAmount int64 `json:"total_amount"` +} + +func (q *Queries) GetWalletTransactionsInRange(ctx context.Context, arg GetWalletTransactionsInRangeParams) ([]GetWalletTransactionsInRangeRow, error) { + rows, err := q.db.Query(ctx, GetWalletTransactionsInRange, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWalletTransactionsInRangeRow + for rows.Next() { + var i GetWalletTransactionsInRangeRow + if err := rows.Scan(&i.Type, &i.Count, &i.TotalAmount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const UpdateTransferStatus = `-- name: UpdateTransferStatus :exec UPDATE wallet_transfer SET status = $1, diff --git a/gen/db/virtual_games.sql.go b/gen/db/virtual_games.sql.go index 94cdeca..a65275f 100644 --- a/gen/db/virtual_games.sql.go +++ b/gen/db/virtual_games.sql.go @@ -197,6 +197,49 @@ func (q *Queries) GetVirtualGameSessionByToken(ctx context.Context, sessionToken return i, err } +const GetVirtualGameSummaryInRange = `-- name: GetVirtualGameSummaryInRange :many +SELECT + vg.name AS game_name, + COUNT(vgh.id) AS number_of_bets, + COALESCE(SUM(vgh.amount), 0) AS total_transaction_sum +FROM virtual_game_histories vgh +JOIN virtual_games vg ON vgh.game_id = vg.id +WHERE vgh.transaction_type = 'BET' + AND vgh.created_at BETWEEN $1 AND $2 +GROUP BY vg.name +` + +type GetVirtualGameSummaryInRangeParams struct { + CreatedAt pgtype.Timestamp `json:"created_at"` + CreatedAt_2 pgtype.Timestamp `json:"created_at_2"` +} + +type GetVirtualGameSummaryInRangeRow struct { + GameName string `json:"game_name"` + NumberOfBets int64 `json:"number_of_bets"` + TotalTransactionSum interface{} `json:"total_transaction_sum"` +} + +func (q *Queries) GetVirtualGameSummaryInRange(ctx context.Context, arg GetVirtualGameSummaryInRangeParams) ([]GetVirtualGameSummaryInRangeRow, error) { + rows, err := q.db.Query(ctx, GetVirtualGameSummaryInRange, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetVirtualGameSummaryInRangeRow + for rows.Next() { + var i GetVirtualGameSummaryInRangeRow + if err := rows.Scan(&i.GameName, &i.NumberOfBets, &i.TotalTransactionSum); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetVirtualGameTransactionByExternalID = `-- name: GetVirtualGameTransactionByExternalID :one SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at FROM virtual_game_transactions diff --git a/go.mod b/go.mod index 75d188f..7fe0d0c 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,10 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/golang/mock v1.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.10.0 // indirect go.uber.org/atomic v1.9.0 // indirect ) diff --git a/go.sum b/go.sum index 56862fa..514814e 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= @@ -23,6 +25,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -125,6 +129,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= +github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/resend/resend-go/v2 v2.20.0 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM= github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/internal/config/config.go b/internal/config/config.go index c2af075..b469617 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,6 +91,7 @@ type Config struct { TwilioAccountSid string TwilioAuthToken string TwilioSenderPhoneNumber string + RedisAddr string } func NewConfig() (*Config, error) { @@ -115,6 +116,8 @@ func (c *Config) loadEnv() error { c.ReportExportPath = os.Getenv("REPORT_EXPORT_PATH") + c.RedisAddr = os.Getenv("REDIS_ADDR") + c.CHAPA_TRANSFER_TYPE = os.Getenv("CHAPA_TRANSFER_TYPE") c.CHAPA_PAYMENT_TYPE = os.Getenv("CHAPA_PAYMENT_TYPE") diff --git a/internal/domain/common.go b/internal/domain/common.go index a6a408f..54433ab 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -78,3 +78,6 @@ func CalculateWinnings(amount Currency, totalOdds float32) Currency { return ToCurrency(possibleWin - incomeTax) } + +func PtrFloat64(v float64) *float64 { return &v } +func PtrInt64(v int64) *int64 { return &v } diff --git a/internal/domain/report.go b/internal/domain/report.go index 938633a..a6c5be0 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -10,6 +10,37 @@ const ( Monthly TimeFrame = "monthly" ) +type ReportFrequency string + +const ( + ReportDaily ReportFrequency = "daily" + ReportWeekly ReportFrequency = "weekly" + ReportMonthly ReportFrequency = "monthly" +) + +type ReportRequest struct { + Frequency ReportFrequency + StartDate time.Time + EndDate time.Time +} + +type ReportData struct { + TotalBets int64 + TotalCashIn float64 + TotalCashOut float64 + CashBacks float64 + Withdrawals float64 + Deposits float64 + TotalTickets int64 + VirtualGameStats []VirtualGameStat +} + +type VirtualGameStat struct { + GameName string + NumBets int64 + TotalTransaction float64 +} + type Report struct { ID string TimeFrame TimeFrame @@ -22,6 +53,22 @@ type Report struct { GeneratedAt time.Time } +type LiveMetric struct { + TotalCashSportsbook float64 + TotalCashSportGames float64 + TotalLiveTickets int64 + TotalUnsettledCash float64 + TotalGames int64 +} + +type MetricUpdates struct { + TotalCashSportsbookDelta *float64 + TotalCashSportGamesDelta *float64 + TotalLiveTicketsDelta *int64 + TotalUnsettledCashDelta *float64 + TotalGamesDelta *int64 +} + type DashboardSummary struct { TotalStakes Currency `json:"total_stakes"` TotalBets int64 `json:"total_bets"` diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 6ea6338..a518aec 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -33,28 +33,28 @@ type PaymentDetails struct { // A Transfer is logged for every modification of ALL wallets and wallet types type Transfer struct { - ID int64 - Amount Currency - Verified bool - Type TransferType - PaymentMethod PaymentMethod - ReceiverWalletID ValidInt64 - SenderWalletID ValidInt64 - ReferenceNumber string - Status string - CashierID ValidInt64 - CreatedAt time.Time - UpdatedAt time.Time + ID int64 `json:"id"` + Amount Currency `json:"amount"` + Verified bool `json:"verified"` + Type TransferType `json:"type"` + PaymentMethod PaymentMethod `json:"payment_method"` + ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` + SenderWalletID ValidInt64 `json:"sender_wallet_id"` + ReferenceNumber string `json:"reference_number"` // <-- needed + Status string `json:"status"` + CashierID ValidInt64 `json:"cashier_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateTransfer struct { - Amount Currency - Verified bool - ReferenceNumber string - Status string - ReceiverWalletID ValidInt64 - SenderWalletID ValidInt64 - CashierID ValidInt64 - Type TransferType - PaymentMethod PaymentMethod + Amount Currency `json:"amount"` + Verified bool `json:"verified"` + Type TransferType `json:"type"` + PaymentMethod PaymentMethod `json:"payment_method"` + ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"` + SenderWalletID ValidInt64 `json:"sender_wallet_id"` + ReferenceNumber string `json:"reference_number"` // <-- needed + Status string `json:"status"` + CashierID ValidInt64 `json:"cashier_id"` } diff --git a/internal/repository/report.go b/internal/repository/report.go index f7b2693..ccbad5e 100644 --- a/internal/repository/report.go +++ b/internal/repository/report.go @@ -2,15 +2,26 @@ package repository import ( "context" + "fmt" "time" + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" ) type ReportRepository interface { GenerateReport(timeFrame domain.TimeFrame, start, end time.Time) (*domain.Report, error) SaveReport(report *domain.Report) error FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit int) ([]*domain.Report, error) + + GetTotalCashOutInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) + GetTotalCashMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) + GetTotalCashBacksInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) + GetTotalBetsMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (int64, error) + GetVirtualGameSummaryInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetVirtualGameSummaryInRangeRow, error) + GetAllTicketsInRange(ctx context.Context, from, to time.Time) (dbgen.GetAllTicketsInRangeRow, error) + GetWalletTransactionsInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetWalletTransactionsInRangeRow, error) } type ReportRepo struct { @@ -105,3 +116,105 @@ func (r *ReportRepo) FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit in return reports, nil } + +func (r *ReportRepo) GetTotalBetsMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (int64, error) { + params := dbgen.GetTotalBetsMadeInRangeParams{ + From: ToPgTimestamp(from), + To: ToPgTimestamp(to), + CompanyID: ToPgInt8(companyID), + } + return r.store.queries.GetTotalBetsMadeInRange(ctx, params) +} + +func (r *ReportRepo) GetTotalCashBacksInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) { + params := dbgen.GetTotalCashBacksInRangeParams{ + From: ToPgTimestamp(from), + To: ToPgTimestamp(to), + CompanyID: ToPgInt8(companyID), + } + value, err := r.store.queries.GetTotalCashBacksInRange(ctx, params) + if err != nil { + return 0, err + } + return parseFloat(value) +} + +func (r *ReportRepo) GetTotalCashMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) { + params := dbgen.GetTotalCashMadeInRangeParams{ + From: ToPgTimestamp(from), + To: ToPgTimestamp(to), + CompanyID: ToPgInt8(companyID), + } + value, err := r.store.queries.GetTotalCashMadeInRange(ctx, params) + if err != nil { + return 0, err + } + return parseFloat(value) +} + +func (r *ReportRepo) GetTotalCashOutInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) { + params := dbgen.GetTotalCashOutInRangeParams{ + From: ToPgTimestamp(from), + To: ToPgTimestamp(to), + CompanyID: ToPgInt8(companyID), + } + value, err := r.store.queries.GetTotalCashOutInRange(ctx, params) + if err != nil { + return 0, err + } + return parseFloat(value) +} + +func (r *ReportRepo) GetWalletTransactionsInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetWalletTransactionsInRangeRow, error) { + params := dbgen.GetWalletTransactionsInRangeParams{ + CreatedAt: ToPgTimestamp(from), + CreatedAt_2: ToPgTimestamp(to), + } + return r.store.queries.GetWalletTransactionsInRange(ctx, params) +} + +func (r *ReportRepo) GetAllTicketsInRange(ctx context.Context, from, to time.Time) (dbgen.GetAllTicketsInRangeRow, error) { + params := dbgen.GetAllTicketsInRangeParams{ + CreatedAt: ToPgTimestamp(from), + CreatedAt_2: ToPgTimestamp(to), + } + return r.store.queries.GetAllTicketsInRange(ctx, params) +} + +func (r *ReportRepo) GetVirtualGameSummaryInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetVirtualGameSummaryInRangeRow, error) { + params := dbgen.GetVirtualGameSummaryInRangeParams{ + CreatedAt: ToPgTimestamp(from), + CreatedAt_2: ToPgTimestamp(to), + } + return r.store.queries.GetVirtualGameSummaryInRange(ctx, params) +} + +func ToPgTimestamp(t time.Time) pgtype.Timestamp { + return pgtype.Timestamp{Time: t, Valid: true} +} + +func ToPgInt8(i int64) pgtype.Int8 { + return pgtype.Int8{Int64: i, Valid: true} +} + +func parseFloat(value interface{}) (float64, error) { + switch v := value.(type) { + case float64: + return v, nil + case int64: + return float64(v), nil + case pgtype.Numeric: + if !v.Valid { + return 0, nil + } + f, err := v.Float64Value() + if err != nil { + return 0, fmt.Errorf("failed to convert pgtype.Numeric to float64: %w", err) + } + return f.Float64, nil + case nil: + return 0, nil + default: + return 0, fmt.Errorf("unexpected type %T for value: %+v", v, v) + } +} diff --git a/internal/repository/transfer.go b/internal/repository/transfer.go index afa603a..c432f9d 100644 --- a/internal/repository/transfer.go +++ b/internal/repository/transfer.go @@ -26,7 +26,11 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer { Value: transfer.CashierID.Int64, Valid: transfer.CashierID.Valid, }, - PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), + PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String), + ReferenceNumber: transfer.ReferenceNumber, + Status: transfer.Status.String, + CreatedAt: transfer.CreatedAt.Time, + UpdatedAt: transfer.UpdatedAt.Time, } } @@ -46,7 +50,7 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP Int64: transfer.CashierID.Value, Valid: transfer.CashierID.Valid, }, - ReferenceNumber: pgtype.Text{String: string(transfer.ReferenceNumber), Valid: true}, + ReferenceNumber: string(transfer.ReferenceNumber), PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true}, } @@ -72,6 +76,7 @@ func (s *Store) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error) } return result, nil } + func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) { transfers, err := s.queries.GetTransfersByWallet(ctx, pgtype.Int8{Int64: walletID, Valid: true}) if err != nil { @@ -87,7 +92,7 @@ func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]dom } func (s *Store) GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) { - transfer, err := s.queries.GetTransferByReference(ctx, pgtype.Text{String: reference, Valid: true}) + transfer, err := s.queries.GetTransferByReference(ctx, reference) if err != nil { return domain.Transfer{}, nil } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 273e5d4..07656bc 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -92,10 +92,6 @@ func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount doma Verified: false, } - if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { - return "", fmt.Errorf("failed to save payment: %w", err) - } - // Initialize payment with Chapa response, err := s.chapaClient.InitializePayment(ctx, domain.ChapaDepositRequest{ Amount: amount, @@ -114,8 +110,13 @@ func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount doma return "", fmt.Errorf("failed to initialize payment: %w", err) } + if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil { + return "", fmt.Errorf("failed to save payment: %w", err) + } + return response.CheckoutURL, nil } + func (s *Service) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) { // Parse and validate amount amount, err := strconv.ParseInt(req.Amount, 10, 64) diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index 2e92e19..547bb59 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -2,6 +2,7 @@ package notificationservice import ( "context" + "encoding/json" "errors" "log/slog" "sync" @@ -14,6 +15,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" afro "github.com/amanuelabay/afrosms-go" "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" ) type Service struct { @@ -24,10 +26,15 @@ type Service struct { stopCh chan struct{} config *config.Config logger *slog.Logger + redisClient *redis.Client } func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service { hub := ws.NewNotificationHub() + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr, // e.g., “redis:6379” + }) + svc := &Service{ repo: repo, Hub: hub, @@ -36,11 +43,13 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi notificationCh: make(chan *domain.Notification, 1000), stopCh: make(chan struct{}), config: cfg, + redisClient: rdb, } go hub.Run() go svc.startWorker() go svc.startRetryWorker() + go svc.RunRedisSubscriber(context.Background()) return svc } @@ -287,3 +296,90 @@ func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int // func (s *Service) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error){ // return s.repo.Get(ctx, filter) // } + +func (s *Service) RunRedisSubscriber(ctx context.Context) { + pubsub := s.redisClient.Subscribe(ctx, "live_metrics") + defer pubsub.Close() + + ch := pubsub.Channel() + for msg := range ch { + var payload domain.LiveMetric + if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil { + s.logger.Error("[NotificationSvc.runRedisSubscriber] failed unmarshal metric", "error", err) + continue + } + // Broadcast via WebSocket Hub + s.Hub.Broadcast <- map[string]interface{}{ + "type": "LIVE_METRIC_UPDATE", + "payload": payload, + } + } +} + +func (s *Service) UpdateLiveMetrics(ctx context.Context, updates domain.MetricUpdates) error { + const key = "live_metrics" + + val, err := s.redisClient.Get(ctx, key).Result() + var metric domain.LiveMetric + if err == redis.Nil { + metric = domain.LiveMetric{} + } else if err != nil { + return err + } else { + if err := json.Unmarshal([]byte(val), &metric); err != nil { + return err + } + } + + // Apply increments if provided + if updates.TotalCashSportsbookDelta != nil { + metric.TotalCashSportsbook += *updates.TotalCashSportsbookDelta + } + if updates.TotalCashSportGamesDelta != nil { + metric.TotalCashSportGames += *updates.TotalCashSportGamesDelta + } + if updates.TotalLiveTicketsDelta != nil { + metric.TotalLiveTickets += *updates.TotalLiveTicketsDelta + } + if updates.TotalUnsettledCashDelta != nil { + metric.TotalUnsettledCash += *updates.TotalUnsettledCashDelta + } + if updates.TotalGamesDelta != nil { + metric.TotalGames += *updates.TotalGamesDelta + } + + updatedData, err := json.Marshal(metric) + if err != nil { + return err + } + + if err := s.redisClient.Set(ctx, key, updatedData, 0).Err(); err != nil { + return err + } + + if err := s.redisClient.Publish(ctx, "live_metrics", updatedData).Err(); err != nil { + return err + } + + s.logger.Info("[NotificationSvc.UpdateLiveMetrics] Live metrics updated and broadcasted") + return nil +} + +func (s *Service) GetLiveMetrics(ctx context.Context) (domain.LiveMetric, error) { + const key = "live_metrics" + var metric domain.LiveMetric + + val, err := s.redisClient.Get(ctx, key).Result() + if err == redis.Nil { + // Key does not exist yet, return zero-valued struct + return domain.LiveMetric{}, nil + } else if err != nil { + return domain.LiveMetric{}, err + } + + if err := json.Unmarshal([]byte(val), &metric); err != nil { + return domain.LiveMetric{}, err + } + + return metric, nil +} diff --git a/internal/services/report/service.go b/internal/services/report/service.go index eaa5c60..8a15335 100644 --- a/internal/services/report/service.go +++ b/internal/services/report/service.go @@ -2,9 +2,14 @@ package report import ( "context" + "encoding/csv" "errors" + "fmt" "log/slog" + "os" "sort" + "strconv" + "strings" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -454,34 +459,144 @@ func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportF return performances, nil } -func (s *Service) GenerateReport(timeFrame domain.TimeFrame) (*domain.Report, error) { - now := time.Now() - var start, end time.Time - - switch timeFrame { - case domain.Daily: - start = now.AddDate(0, 0, -1) - end = now - case domain.Weekly: - start = now.AddDate(0, 0, -7) - end = now - case domain.Monthly: - start = now.AddDate(0, -1, 0) - end = now - } - - report, err := s.repo.GenerateReport(timeFrame, start, end) +func (s *Service) GenerateReport(ctx context.Context, period string) error { + data, err := s.fetchReportData(ctx, period) if err != nil { - return nil, err + return fmt.Errorf("fetch data: %w", err) } - if err := s.repo.SaveReport(report); err != nil { - return nil, err + filePath := fmt.Sprintf("/host-desktop/report_%s_%s.csv", period, time.Now().Format("2006-01-02_15-04")) + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + // Summary section + writer.Write([]string{"Period", "Total Bets", "Total Cash Made", "Total Cash Out", "Total Cash Backs", "Total Deposits", "Total Withdrawals", "Total Tickets"}) + writer.Write([]string{ + period, + fmt.Sprintf("%d", data.TotalBets), + fmt.Sprintf("%.2f", data.TotalCashIn), + fmt.Sprintf("%.2f", data.TotalCashOut), + fmt.Sprintf("%.2f", data.CashBacks), + fmt.Sprintf("%.2f", data.Deposits), + fmt.Sprintf("%.2f", data.Withdrawals), + fmt.Sprintf("%d", data.TotalTickets), + }) + + writer.Write([]string{}) // Empty line for spacing + + // Virtual Game Summary section + writer.Write([]string{"Game Name", "Number of Bets", "Total Transaction Sum"}) + for _, row := range data.VirtualGameStats { + writer.Write([]string{ + row.GameName, + fmt.Sprintf("%d", row.NumBets), + fmt.Sprintf("%.2f", row.TotalTransaction), + }) } - return report, nil + return nil } +func (s *Service) fetchReportData(ctx context.Context, period string) (domain.ReportData, error) { + from, to := getTimeRange(period) + companyID := int64(0) + + // Basic metrics + totalBets, _ := s.repo.GetTotalBetsMadeInRange(ctx, from, to, companyID) + cashIn, _ := s.repo.GetTotalCashMadeInRange(ctx, from, to, companyID) + cashOut, _ := s.repo.GetTotalCashOutInRange(ctx, from, to, companyID) + cashBacks, _ := s.repo.GetTotalCashBacksInRange(ctx, from, to, companyID) + + // Wallet Transactions + transactions, _ := s.repo.GetWalletTransactionsInRange(ctx, from, to) + var totalDeposits, totalWithdrawals float64 + for _, tx := range transactions { + switch strings.ToLower(tx.Type.String) { + case "deposit": + totalDeposits += float64(tx.TotalAmount) + case "withdraw": + totalWithdrawals += float64(tx.TotalAmount) + } + } + + // Ticket Count + totalTickets, _ := s.repo.GetAllTicketsInRange(ctx, from, to) + + // Virtual Game Summary + virtualGameStats, _ := s.repo.GetVirtualGameSummaryInRange(ctx, from, to) + + // Convert []dbgen.GetVirtualGameSummaryInRangeRow to []domain.VirtualGameStat + var virtualGameStatsDomain []domain.VirtualGameStat + for _, row := range virtualGameStats { + var totalTransaction float64 + switch v := row.TotalTransactionSum.(type) { + case string: + val, err := strconv.ParseFloat(v, 64) + if err == nil { + totalTransaction = val + } + case float64: + totalTransaction = v + case int: + totalTransaction = float64(v) + default: + totalTransaction = 0 + } + virtualGameStatsDomain = append(virtualGameStatsDomain, domain.VirtualGameStat{ + GameName: row.GameName, + NumBets: row.NumberOfBets, + TotalTransaction: totalTransaction, + }) + } + + return domain.ReportData{ + TotalBets: totalBets, + TotalCashIn: cashIn, + TotalCashOut: cashOut, + CashBacks: cashBacks, + Deposits: totalDeposits, + Withdrawals: totalWithdrawals, + TotalTickets: totalTickets.TotalTickets, + VirtualGameStats: virtualGameStatsDomain, + }, nil +} + +func getTimeRange(period string) (time.Time, time.Time) { + now := time.Now() + switch strings.ToLower(period) { + case "daily": + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.Add(5 * time.Minute) + return start, end + case "weekly": + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + start := now.AddDate(0, 0, -weekday+1) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 0, 7) + return start, end + case "monthly": + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 1, 0) + return start, end + default: + // Default to daily + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.Add(24 * time.Hour) + return start, end + } +} + + + // func (s *Service) GetCompanyPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CompanyPerformance, error) { // // Get company bet activity // companyBets, err := s.betStore.GetCompanyBetActivity(ctx, filter) diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go index 2f36e88..0067e36 100644 --- a/internal/services/ticket/service.go +++ b/internal/services/ticket/service.go @@ -9,6 +9,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "go.uber.org/zap" ) @@ -28,10 +29,11 @@ var ( ) type Service struct { - ticketStore TicketStore - eventSvc event.Service - prematchSvc odds.ServiceImpl - mongoLogger *zap.Logger + ticketStore TicketStore + eventSvc event.Service + prematchSvc odds.ServiceImpl + mongoLogger *zap.Logger + notificationSvc *notificationservice.Service } func NewService( @@ -39,12 +41,14 @@ func NewService( eventSvc event.Service, prematchSvc odds.ServiceImpl, mongoLogger *zap.Logger, + notificationSvc *notificationservice.Service, ) *Service { return &Service{ - ticketStore: ticketStore, - eventSvc: eventSvc, - prematchSvc: prematchSvc, - mongoLogger: mongoLogger, + ticketStore: ticketStore, + eventSvc: eventSvc, + prematchSvc: prematchSvc, + mongoLogger: mongoLogger, + notificationSvc: notificationSvc, } } @@ -176,7 +180,7 @@ func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq, if count > 50 { // return response.WriteJSON(c, fiber.StatusBadRequest, "Ticket Limit reached", nil, nil) return domain.Ticket{}, 0, ErrTicketLimitForSingleUser - } + } var outcomes []domain.CreateTicketOutcome = make([]domain.CreateTicketOutcome, 0, len(req.Outcomes)) var totalOdds float32 = 1 for _, outcomeReq := range req.Outcomes { @@ -222,6 +226,14 @@ func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq, return domain.Ticket{}, rows, err } + updates := domain.MetricUpdates{ + TotalLiveTicketsDelta: domain.PtrInt64(1), + } + + if err := s.notificationSvc.UpdateLiveMetrics(ctx, updates); err != nil { + // handle error + } + return ticket, rows, nil } diff --git a/internal/services/wallet/monitor/service.go b/internal/services/wallet/monitor/service.go index ea96534..4f48115 100644 --- a/internal/services/wallet/monitor/service.go +++ b/internal/services/wallet/monitor/service.go @@ -91,12 +91,11 @@ func (s *Service) checkWalletThresholds() { // Initialize initial deposit if not set s.mu.Lock() - if _, exists := s.initialDeposits[company.ID]; !exists { + initialDeposit, exists := s.initialDeposits[company.ID] + if !exists || wallet.Balance > initialDeposit { s.initialDeposits[company.ID] = wallet.Balance - s.mu.Unlock() - continue + initialDeposit = wallet.Balance // update local variable } - initialDeposit := s.initialDeposits[company.ID] s.mu.Unlock() if initialDeposit == 0 { diff --git a/internal/services/wallet/transfer.go b/internal/services/wallet/transfer.go index a88c0a5..b9e269e 100644 --- a/internal/services/wallet/transfer.go +++ b/internal/services/wallet/transfer.go @@ -119,7 +119,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom DeliveryChannel: domain.DeliveryChannelInApp, Payload: domain.NotificationPayload{ Headline: "Wallet has been deducted", - Message: fmt.Sprintf(`ETB %d has been transferred from your wallet`), + Message: fmt.Sprintf(`%s %d has been transferred from your wallet`,senderWallet.Currency, amount), }, Priority: 2, Metadata: []byte(fmt.Sprintf(`{ @@ -148,7 +148,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom DeliveryChannel: domain.DeliveryChannelInApp, Payload: domain.NotificationPayload{ Headline: "Wallet has been credited", - Message: fmt.Sprintf(`ETB %d has been transferred to your wallet`), + Message: fmt.Sprintf(`%s %d has been transferred to your wallet`,receiverWallet.Currency, amount), }, Priority: 2, Metadata: []byte(fmt.Sprintf(`{ diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 246bbd5..72926d8 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -27,6 +27,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "go.uber.org/zap" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/bytedance/sonic" @@ -63,6 +64,7 @@ type App struct { eventSvc event.Service leagueSvc league.Service resultSvc *result.Service + mongoLoggerSvc *zap.Logger } func NewApp( @@ -91,6 +93,7 @@ func NewApp( recommendationSvc recommendation.RecommendationService, resultSvc *result.Service, cfg *config.Config, + mongoLoggerSvc *zap.Logger, ) *App { app := fiber.New(fiber.Config{ CaseSensitive: true, @@ -135,6 +138,7 @@ func NewApp( recommendationSvc: recommendationSvc, resultSvc: resultSvc, cfg: cfg, + mongoLoggerSvc: mongoLoggerSvc, } s.initAppRoutes() diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index e69af5e..7dfda9d 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -8,37 +8,14 @@ import ( // "time" - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" resultsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" - "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/worker" - "github.com/go-co-op/gocron" "github.com/robfig/cron/v3" ) -func SetupReportCronJob(reportWorker *worker.ReportWorker) { - s := gocron.NewScheduler(time.UTC) - - // Daily at midnight - _, _ = s.Every(1).Day().At("00:00").Do(func() { - _ = reportWorker.GenerateAndExport(domain.Daily) - }) - - // Weekly on Sunday at 00:05 - _, _ = s.Every(1).Week().Sunday().At("00:05").Do(func() { - _ = reportWorker.GenerateAndExport(domain.Weekly) - }) - - // Monthly on 1st at 00:10 - _, _ = s.Every(1).Month(1).At("00:10").Do(func() { - _ = reportWorker.GenerateAndExport(domain.Monthly) - }) - - s.StartAsync() -} - func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.ServiceImpl, resultService *resultsvc.Service) { c := cron.New(cron.WithSeconds()) @@ -128,3 +105,46 @@ func StartTicketCrons(ticketService ticket.Service) { c.Start() log.Println("Cron jobs started for ticket service") } + +func SetupReportCronJobs(ctx context.Context, reportService *report.Service) { + c := cron.New(cron.WithSeconds()) // use WithSeconds for tighter intervals during testing + + schedule := []struct { + spec string + period string + }{ + { + spec: "*/300 * * * * *", // Every 5 minutes (300 seconds) + period: "5min", + }, + { + spec: "0 0 0 * * *", // Daily at midnight + period: "daily", + }, + { + spec: "0 0 1 * * 0", // Weekly: Sunday at 1 AM + period: "weekly", + }, + { + spec: "0 0 2 1 * *", // Monthly: 1st day of month at 2 AM + period: "monthly", + }, + } + + for _, job := range schedule { + period := job.period + if _, err := c.AddFunc(job.spec, func() { + log.Printf("Running %s report at %s", period, time.Now().Format(time.RFC3339)) + if err := reportService.GenerateReport(ctx, period); err != nil { + log.Printf("Error generating %s report: %v", period, err) + } else { + log.Printf("Successfully generated %s report", period) + } + }); err != nil { + log.Fatalf("Failed to schedule %s report cron job: %v", period, err) + } + } + + c.Start() + log.Println("Cron jobs started for report generation service") +} diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index 795a61f..8e282f1 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -1,7 +1,6 @@ package handlers import ( - "log/slog" "strconv" "time" @@ -10,6 +9,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" + "go.uber.org/zap" ) type CreateAdminReq struct { @@ -38,15 +38,24 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error { var req CreateAdminReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("RegisterUser failed", "error", err) + h.mongoLoggerSvc.Error("failed to parse CreateAdmin request", + zap.Int64("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) } + valErrs, ok := h.validator.Validate(c, req) if !ok { + h.mongoLoggerSvc.Error("validation failed for CreateAdmin request", + zap.Int64("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - // Admins can be created without company ids and can be assigned later if req.CompanyID == nil { companyID = domain.ValidInt64{ Value: 0, @@ -55,7 +64,12 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error { } else { _, err := h.companySvc.GetCompanyByID(c.Context(), *req.CompanyID) if err != nil { - h.logger.Error("CreateAdmin company id is invalid", "error", err) + h.mongoLoggerSvc.Error("invalid company ID for CreateAdmin", + zap.Int64("status_code", fiber.StatusInternalServerError), + zap.Int64("company_id", *req.CompanyID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Company ID is invalid", nil, nil) } companyID = domain.ValidInt64{ @@ -74,10 +88,14 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error { CompanyID: companyID, } - h.logger.Info("CreateAdmin", slog.Bool("company id", req.CompanyID == nil)) newUser, err := h.userSvc.CreateUser(c.Context(), user, true) if err != nil { - h.logger.Error("CreateAdmin failed", "error", err) + h.mongoLoggerSvc.Error("failed to create admin user", + zap.Int64("status_code", fiber.StatusInternalServerError), + zap.Any("request", req), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to create admin", nil, nil) } @@ -87,11 +105,23 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error { AdminID: &newUser.ID, }) if err != nil { - h.logger.Error("CreateAdmin failed to update company", "error", err) + h.mongoLoggerSvc.Error("failed to update company with new admin", + zap.Int64("status_code", fiber.StatusInternalServerError), + zap.Int64("company_id", *req.CompanyID), + zap.Int64("admin_id", newUser.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil) } } + h.mongoLoggerSvc.Info("admin created successfully", + zap.Int64("admin_id", newUser.ID), + zap.String("email", newUser.Email), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil) } @@ -125,7 +155,6 @@ type AdminRes struct { // @Failure 500 {object} response.APIResponse // @Router /admin [get] func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { - filter := user.Filter{ Role: string(domain.RoleAdmin), CompanyID: domain.ValidInt64{ @@ -141,27 +170,45 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { Valid: true, }, } + valErrs, ok := h.validator.Validate(c, filter) if !ok { + h.mongoLoggerSvc.Error("invalid filter values in GetAllAdmins request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } + admins, total, err := h.userSvc.GetAllUsers(c.Context(), filter) if err != nil { - h.logger.Error("GetAllAdmins failed", "error", err) + h.mongoLoggerSvc.Error("failed to get admins from user service", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Any("filter", filter), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get Admins", nil, nil) } - var result []AdminRes = make([]AdminRes, len(admins)) + result := make([]AdminRes, len(admins)) for index, admin := range admins { lastLogin, err := h.authSvc.GetLastLogin(c.Context(), admin.ID) if err != nil { if err == authentication.ErrRefreshTokenNotFound { lastLogin = &admin.CreatedAt } else { - h.logger.Error("Failed to get user last login", "userID", admin.ID, "error", err) + h.mongoLoggerSvc.Error("failed to get last login for admin", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("admin_id", admin.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") } } + result[index] = AdminRes{ ID: admin.ID, FirstName: admin.FirstName, @@ -179,6 +226,13 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { } } + h.mongoLoggerSvc.Info("admins retrieved successfully", + zap.Int("status_code", fiber.StatusOK), + zap.Int("count", len(result)), + zap.Int("page", filter.Page.Value+1), + zap.Time("timestamp", time.Now()), + ) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page.Value, int(total)) } @@ -195,41 +249,40 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { // @Failure 500 {object} response.APIResponse // @Router /admin/{id} [get] func (h *Handler) GetAdminByID(c *fiber.Ctx) error { - // branchId := int64(12) //c.Locals("branch_id").(int64) - // filter := user.Filter{ - // Role: string(domain.RoleUser), - // BranchId: user.ValidBranchId{ - // Value: branchId, - // Valid: true, - // }, - // Page: c.QueryInt("page", 1), - // PageSize: c.QueryInt("page_size", 10), - // } - // valErrs, ok := validator.Validate(c, filter) - // if !ok { - // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) - // } - userIDstr := c.Params("id") userID, err := strconv.ParseInt(userIDstr, 10, 64) if err != nil { - h.logger.Error("failed to fetch user using UserID", "error", err) + h.mongoLoggerSvc.Error("invalid admin ID param", + zap.Int("status_code", fiber.StatusBadRequest), + zap.String("param", userIDstr), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid admin ID", nil, nil) } user, err := h.userSvc.GetUserByID(c.Context(), userID) if err != nil { - h.logger.Error("Get User By ID failed", "error", err) + h.mongoLoggerSvc.Error("failed to fetch admin by ID", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("admin_id", userID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get admin", nil, nil) } lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) - if err != nil { - if err != authentication.ErrRefreshTokenNotFound { - h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") - } - + if err != nil && err != authentication.ErrRefreshTokenNotFound { + h.mongoLoggerSvc.Error("failed to get admin last login", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("admin_id", user.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + if err == authentication.ErrRefreshTokenNotFound { lastLogin = &user.CreatedAt } @@ -249,7 +302,13 @@ func (h *Handler) GetAdminByID(c *fiber.Ctx) error { LastLogin: *lastLogin, } - return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) + h.mongoLoggerSvc.Info("admin retrieved successfully", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("admin_id", user.ID), + zap.Time("timestamp", time.Now()), + ) + + return response.WriteJSON(c, fiber.StatusOK, "Admin retrieved successfully", res, nil) } type updateAdminReq struct { @@ -274,21 +333,36 @@ type updateAdminReq struct { func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { var req updateAdminReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("UpdateAdmin failed", "error", err) + h.mongoLoggerSvc.Error("UpdateAdmin failed - invalid request body", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) } valErrs, ok := h.validator.Validate(c, req) - if !ok { + h.mongoLoggerSvc.Error("UpdateAdmin failed - validation errors", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } + AdminIDStr := c.Params("id") AdminID, err := strconv.ParseInt(AdminIDStr, 10, 64) if err != nil { - h.logger.Error("UpdateAdmin failed", "error", err) + h.mongoLoggerSvc.Error("UpdateAdmin failed - invalid Admin ID param", + zap.Int("status_code", fiber.StatusBadRequest), + zap.String("admin_id_param", AdminIDStr), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Admin ID", nil, nil) } + var companyID domain.ValidInt64 if req.CompanyID != nil { companyID = domain.ValidInt64{ @@ -296,6 +370,7 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { Valid: true, } } + err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ UserId: AdminID, FirstName: domain.ValidString{ @@ -311,23 +386,38 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { Valid: true, }, CompanyID: companyID, - }, - ) + }) if err != nil { - h.logger.Error("UpdateAdmin failed", "error", err) + h.mongoLoggerSvc.Error("UpdateAdmin failed - user service error", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("admin_id", AdminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update admin", nil, nil) } + if req.CompanyID != nil { _, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{ ID: *req.CompanyID, AdminID: &AdminID, }) if err != nil { - h.logger.Error("CreateAdmin failed to update company", "error", err) + h.mongoLoggerSvc.Error("UpdateAdmin failed to update company", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("admin_id", AdminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil) } } - return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil) + h.mongoLoggerSvc.Info("UpdateAdmin succeeded", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("admin_id", AdminID), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil) } diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 8c22fdd..4368266 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -2,11 +2,13 @@ package handlers import ( "errors" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" + "go.uber.org/zap" ) // loginCustomerReq represents the request body for the LoginCustomer endpoint. @@ -38,31 +40,56 @@ type loginCustomerRes struct { func (h *Handler) LoginCustomer(c *fiber.Ctx) error { var req loginCustomerReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse LoginCustomer request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse LoginCustomer request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if _, ok := h.validator.Validate(c, req); !ok { + h.mongoLoggerSvc.Error("LoginCustomer validation failed", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("request", req), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid Request") } successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) if err != nil { - h.logger.Info("Login attempt failed", "email", req.Email, "phone", req.PhoneNumber, "error", err) + h.mongoLoggerSvc.Info("Login attempt failed", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials") case errors.Is(err, authentication.ErrUserSuspended): return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked") default: - h.logger.Error("Login failed", "error", err) + h.mongoLoggerSvc.Error("Login failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Internal server error") } } accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) if err != nil { - h.logger.Error("Failed to create access token", "userID", successRes.UserId, "error", err) + h.mongoLoggerSvc.Error("Failed to create access token", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("user_id", successRes.UserId), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token") } @@ -71,6 +98,14 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error { RefreshToken: successRes.RfToken, Role: string(successRes.Role), } + + h.mongoLoggerSvc.Info("Login successful", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("user_id", successRes.UserId), + zap.String("role", string(successRes.Role)), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) } @@ -101,34 +136,65 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error { var req refreshToken if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse RefreshToken request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse RefreshToken request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if valErrs, ok := h.validator.Validate(c, req); !ok { + h.mongoLoggerSvc.Error("RefreshToken validation failed", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } refreshToken, err := h.authSvc.RefreshToken(c.Context(), req.RefreshToken) if err != nil { - h.logger.Info("Refresh token attempt failed", "refreshToken", req.RefreshToken, "error", err) + h.mongoLoggerSvc.Info("Refresh token attempt failed", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("refresh_token", req.RefreshToken), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) switch { case errors.Is(err, authentication.ErrExpiredToken): return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired") case errors.Is(err, authentication.ErrRefreshTokenNotFound): return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found") default: - h.logger.Error("Refresh token failed", "error", err) + h.mongoLoggerSvc.Error("Refresh token failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Internal server error") } } user, err := h.userSvc.GetUserByID(c.Context(), refreshToken.UserID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get user by ID during refresh", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("user_id", refreshToken.UserID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user information") + } - // Assuming the refreshed token includes userID and role info; adjust if needed accessToken, err := jwtutil.CreateJwt(user.ID, user.Role, user.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) if err != nil { - h.logger.Error("Failed to create new access token", "error", err) + h.mongoLoggerSvc.Error("Failed to create new access token", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("user_id", user.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token") } @@ -137,6 +203,14 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error { RefreshToken: req.RefreshToken, Role: string(user.Role), } + + h.mongoLoggerSvc.Info("Refresh token successful", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("user_id", user.ID), + zap.String("role", string(user.Role)), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Refresh successful", res, nil) } @@ -157,30 +231,52 @@ type logoutReq struct { // @Failure 500 {object} response.APIResponse // @Router /auth/logout [post] func (h *Handler) LogOutCustomer(c *fiber.Ctx) error { - var req logoutReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse LogOutCustomer request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse LogOutCustomer request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } if valErrs, ok := h.validator.Validate(c, req); !ok { + h.mongoLoggerSvc.Error("LogOutCustomer validation failed", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } err := h.authSvc.Logout(c.Context(), req.RefreshToken) if err != nil { - h.logger.Info("Logout attempt failed", "refreshToken", req.RefreshToken, "error", err) + h.mongoLoggerSvc.Info("Logout attempt failed", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("refresh_token", req.RefreshToken), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) switch { case errors.Is(err, authentication.ErrExpiredToken): return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired") case errors.Is(err, authentication.ErrRefreshTokenNotFound): return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found") default: - h.logger.Error("Logout failed", "error", err) + h.mongoLoggerSvc.Error("Logout failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Internal server error") } } + h.mongoLoggerSvc.Info("Logout successful", + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil) } diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 1925280..335d07f 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -9,6 +9,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" + "go.uber.org/zap" ) // CreateBet godoc @@ -23,34 +24,52 @@ import ( // @Failure 500 {object} response.APIResponse // @Router /bet [post] func (h *Handler) CreateBet(c *fiber.Ctx) error { - // Get user_id from middleware userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) var req domain.CreateBetReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse CreateBet request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse CreateBet request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } valErrs, ok := h.validator.Validate(c, req) if !ok { + h.mongoLoggerSvc.Error("CreateBet validation failed", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role) - if err != nil { - h.logger.Error("PlaceBet failed", "error", err) + h.mongoLoggerSvc.Error("PlaceBet failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + switch err { case bet.ErrEventHasBeenRemoved, bet.ErrEventHasNotEnded, bet.ErrRawOddInvalid, wallet.ErrBalanceInsufficient: return fiber.NewError(fiber.StatusBadRequest, err.Error()) } + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") } - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + h.mongoLoggerSvc.Info("Bet created successfully", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("user_id", userID), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } // RandomBet godoc @@ -65,20 +84,25 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { // @Failure 500 {object} response.APIResponse // @Router /random/bet [post] func (h *Handler) RandomBet(c *fiber.Ctx) error { - - // Get user_id from middleware userID := c.Locals("user_id").(int64) - // role := c.Locals("role").(domain.Role) leagueIDQuery, err := strconv.Atoi(c.Query("league_id")) if err != nil { - h.logger.Error("invalid league id", "error", err) + h.mongoLoggerSvc.Error("invalid league id", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "invalid league id", nil, nil) } sportIDQuery, err := strconv.Atoi(c.Query("sport_id")) if err != nil { - h.logger.Error("invalid sport id", "error", err) + h.mongoLoggerSvc.Error("invalid sport id", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "invalid sport id", nil, nil) } @@ -98,7 +122,11 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { if firstStartTimeQuery != "" { firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) if err != nil { - h.logger.Error("invalid start_time format", "error", err) + h.mongoLoggerSvc.Error("invalid start_time format", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) } firstStartTime = domain.ValidTime{ @@ -106,11 +134,16 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { Valid: true, } } + var lastStartTime domain.ValidTime if lastStartTimeQuery != "" { lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery) if err != nil { - h.logger.Error("invalid start_time format", "error", err) + h.mongoLoggerSvc.Error("invalid start_time format", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) } lastStartTime = domain.ValidTime{ @@ -121,21 +154,33 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { var req domain.RandomBetReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse RandomBet request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse RandomBet request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } valErrs, ok := h.validator.Validate(c, req) if !ok { + h.mongoLoggerSvc.Error("RandomBet validation failed", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Any("validation_errors", valErrs), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } var res domain.CreateBetRes for i := 0; i < int(req.NumberOfBets); i++ { res, err = h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) - if err != nil { - h.logger.Error("Random Bet failed", "error", err) + h.mongoLoggerSvc.Error("Random Bet failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) switch err { case bet.ErrNoEventsAvailable: return fiber.NewError(fiber.StatusBadRequest, "No events found") @@ -143,8 +188,14 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } } - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + h.mongoLoggerSvc.Info("Random bet(s) created successfully", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("user_id", userID), + zap.Time("timestamp", time.Now()), + ) + + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } // GetAllBet godoc @@ -158,18 +209,20 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { // @Failure 500 {object} response.APIResponse // @Router /bet [get] func (h *Handler) GetAllBet(c *fiber.Ctx) error { - - // role := c.Locals("role").(domain.Role) companyID := c.Locals("company_id").(domain.ValidInt64) branchID := c.Locals("branch_id").(domain.ValidInt64) var isShopBet domain.ValidBool isShopBetQuery := c.Query("is_shop") - if isShopBetQuery != "" { isShopBetParse, err := strconv.ParseBool(isShopBetQuery) if err != nil { + h.mongoLoggerSvc.Error("Failed to parse is_shop_bet", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Failed to parse is_shop_bet") } isShopBet = domain.ValidBool{ @@ -177,13 +230,18 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { Valid: true, } } + bets, err := h.betSvc.GetAllBets(c.Context(), domain.BetFilter{ BranchID: branchID, CompanyID: companyID, IsShopBet: isShopBet, }) if err != nil { - h.logger.Error("Failed to get bets", "error", err) + h.mongoLoggerSvc.Error("Failed to get bets", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bets") } @@ -192,6 +250,11 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { res[i] = domain.ConvertBet(bet) } + h.mongoLoggerSvc.Info("All bets retrieved successfully", + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "All bets retrieved successfully", res, nil) } @@ -210,21 +273,35 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { betID := c.Params("id") id, err := strconv.ParseInt(betID, 10, 64) if err != nil { - h.logger.Error("Invalid bet ID", "betID", betID, "error", err) + h.mongoLoggerSvc.Error("Invalid bet ID", + zap.String("betID", betID), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID") } bet, err := h.betSvc.GetBetByID(c.Context(), id) if err != nil { - // TODO: handle all the errors types - h.logger.Error("Failed to get bet by ID", "betID", id, "error", err) + h.mongoLoggerSvc.Error("Failed to get bet by ID", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusNotFound), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve bet") } res := domain.ConvertBet(bet) - return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) + h.mongoLoggerSvc.Info("Bet retrieved successfully", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) } // GetBetByCashoutID godoc @@ -240,23 +317,27 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { // @Router /bet/cashout/{id} [get] func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error { cashoutID := c.Params("id") - // id, err := strconv.ParseInt(cashoutID, 10, 64) - - // if err != nil { - // logger.Error("Invalid cashout ID", "cashoutID", cashoutID, "error", err) - // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashout ID", err, nil) - // } bet, err := h.betSvc.GetBetByCashoutID(c.Context(), cashoutID) if err != nil { - h.logger.Error("Failed to get bet by ID", "cashoutID", cashoutID, "error", err) + h.mongoLoggerSvc.Error("Failed to get bet by cashout ID", + zap.String("cashoutID", cashoutID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil) } res := domain.ConvertBet(bet) - return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) + h.mongoLoggerSvc.Info("Bet retrieved successfully by cashout ID", + zap.String("cashoutID", cashoutID), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) } type UpdateCashOutReq struct { @@ -283,13 +364,23 @@ func (h *Handler) UpdateCashOut(c *fiber.Ctx) error { betID := c.Params("id") id, err := strconv.ParseInt(betID, 10, 64) if err != nil { - h.logger.Error("Invalid bet ID", "betID", betID, "error", err) + h.mongoLoggerSvc.Error("Invalid bet ID", + zap.String("betID", betID), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID") } var req UpdateCashOutReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("Failed to parse UpdateCashOut request", "error", err) + h.mongoLoggerSvc.Error("Failed to parse UpdateCashOut request", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request body", err, nil) } @@ -299,10 +390,21 @@ func (h *Handler) UpdateCashOut(c *fiber.Ctx) error { err = h.betSvc.UpdateCashOut(c.Context(), id, req.CashedOut) if err != nil { - h.logger.Error("Failed to update cash out bet", "betID", id, "error", err) + h.mongoLoggerSvc.Error("Failed to update cash out bet", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update cash out bet") } + h.mongoLoggerSvc.Info("Bet updated successfully", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Bet updated successfully", nil, nil) } @@ -321,15 +423,31 @@ func (h *Handler) DeleteBet(c *fiber.Ctx) error { betID := c.Params("id") id, err := strconv.ParseInt(betID, 10, 64) if err != nil { - h.logger.Error("Invalid bet ID", "betID", betID, "error", err) + h.mongoLoggerSvc.Error("Invalid bet ID", + zap.String("betID", betID), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID") } err = h.betSvc.DeleteBet(c.Context(), id) if err != nil { - h.logger.Error("Failed to delete bet by ID", "betID", id, "error", err) + h.mongoLoggerSvc.Error("Failed to delete bet by ID", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete bet") } + h.mongoLoggerSvc.Info("Bet removed successfully", + zap.Int64("betID", id), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "Bet removed successfully", nil, nil) } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index a79c8a4..ad2fe3e 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -26,6 +26,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + "go.uber.org/zap" ) type Handler struct { @@ -54,6 +55,7 @@ type Handler struct { jwtConfig jwtutil.JwtConfig validator *customvalidator.CustomValidator Cfg *config.Config + mongoLoggerSvc *zap.Logger } func New( @@ -82,6 +84,7 @@ func New( leagueSvc league.Service, resultSvc result.Service, cfg *config.Config, + mongoLoggerSvc *zap.Logger, ) *Handler { return &Handler{ currSvc: currSvc, @@ -109,5 +112,6 @@ func New( resultSvc: resultSvc, jwtConfig: jwtConfig, Cfg: cfg, + mongoLoggerSvc: mongoLoggerSvc, } } diff --git a/internal/web_server/handlers/mongoLogger.go b/internal/web_server/handlers/mongoLogger.go index f31d780..2d97756 100644 --- a/internal/web_server/handlers/mongoLogger.go +++ b/internal/web_server/handlers/mongoLogger.go @@ -10,9 +10,17 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// GetLogsHandler godoc +// @Summary Retrieve latest application logs +// @Description Fetches the 100 most recent application logs from MongoDB +// @Tags Logs +// @Produce json +// @Success 200 {array} domain.LogEntry "List of application logs" +// @Failure 500 {object} domain.ErrorResponse "Internal server error" +// @Router /api/v1/logs [get] func GetLogsHandler(appCtx context.Context) fiber.Handler { return func(c *fiber.Ctx) error { - client, err := mongo.Connect(appCtx, options.Client().ApplyURI("mongodb://root:secret@localhost:27017/?authSource=admin")) + client, err := mongo.Connect(appCtx, options.Client().ApplyURI("mongodb://root:secret@mongo:27017/?authSource=admin")) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "MongoDB connection failed: "+err.Error()) } diff --git a/internal/web_server/handlers/report.go b/internal/web_server/handlers/report.go index c597763..ed396a2 100644 --- a/internal/web_server/handlers/report.go +++ b/internal/web_server/handlers/report.go @@ -3,7 +3,9 @@ package handlers import ( "context" "fmt" + "os" "strconv" + "strings" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -121,3 +123,81 @@ func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) { return filter, err } + +// DownloadReportFile godoc +// @Summary Download a CSV report file +// @Description Downloads a generated report CSV file from the server +// @Tags Reports +// @Param filename path string true "Name of the report file to download (e.g., report_daily_2025-06-21.csv)" +// @Produce text/csv +// @Success 200 {file} file "CSV file will be downloaded" +// @Failure 400 {object} domain.ErrorResponse "Missing or invalid filename" +// @Failure 404 {object} domain.ErrorResponse "Report file not found" +// @Failure 500 {object} domain.ErrorResponse "Internal server error while serving the file" +// @Router /api/v1/report-files/download/{filename} [get] +func (h *Handler) DownloadReportFile(c *fiber.Ctx) error { + filename := c.Params("filename") + if filename == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Missing filename parameter", + Error: "filename is required", + }) + } + + filePath := fmt.Sprintf("/host-desktop/%s", filename) + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Report file not found", + Error: "no such file", + }) + } + + // Set download headers and return file + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + if err := c.SendFile(filePath); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to serve file", + Error: err.Error(), + }) + } + + return nil +} + +// ListReportFiles godoc +// @Summary List available report CSV files +// @Description Returns a list of all generated report CSV files available for download +// @Tags Reports +// @Produce json +// @Success 200 {object} domain.Response{data=[]string} "List of CSV report filenames" +// @Failure 500 {object} domain.ErrorResponse "Failed to read report directory" +// @Router /api/v1/report-files/list [get] +func (h *Handler) ListReportFiles(c *fiber.Ctx) error { + reportDir := "/host-desktop" + + files, err := os.ReadDir(reportDir) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to read report directory", + Error: err.Error(), + }) + } + + var reportFiles []string + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".csv") { + reportFiles = append(reportFiles, file.Name()) + } + } + + return c.Status(fiber.StatusOK).JSON(domain.Response{ + StatusCode: 200, + Message: "Report files retrieved successfully", + Data: reportFiles, + Success: true, + }) +} diff --git a/internal/web_server/handlers/transfer_handler.go b/internal/web_server/handlers/transfer_handler.go index a2a5a56..783022f 100644 --- a/internal/web_server/handlers/transfer_handler.go +++ b/internal/web_server/handlers/transfer_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "strconv" "time" @@ -134,7 +135,9 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error { // Get sender ID from the cashier userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) - companyID := c.Locals("company_id").(int64) + companyID := c.Locals("company_id").(domain.ValidInt64) + + fmt.Printf("\n\nCompant ID: %v\n\n", companyID.Value) var senderID int64 @@ -143,9 +146,13 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error { h.logger.Error("Unauthorized access", "userID", userID, "role", role) return response.WriteJSON(c, fiber.StatusUnauthorized, "Unauthorized access", nil, nil) } else if role == domain.RoleBranchManager || role == domain.RoleAdmin || role == domain.RoleSuperAdmin { - company, err := h.companySvc.GetCompanyByID(c.Context(), companyID) + company, err := h.companySvc.GetCompanyByID(c.Context(), companyID.Value) if err != nil { - return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching company", err, nil) + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to fetch company", + Error: err.Error(), + }) + // return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching company", err, nil) } senderID = company.WalletID h.logger.Error("Will", "userID", userID, "role", role) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 97e7a40..6531989 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -45,6 +45,7 @@ func (a *App) initAppRoutes() { a.leagueSvc, *a.resultSvc, a.cfg, + a.mongoLoggerSvc, ) group := a.fiber.Group("/api/v1") @@ -214,6 +215,8 @@ func (a *App) initAppRoutes() { //Report Routes group.Get("/reports/dashboard", h.GetDashboardReport) + group.Get("/report-files/download/:filename", a.authMiddleware, a.SuperAdminOnly, h.DownloadReportFile) + group.Get("/report-files/list", a.authMiddleware, a.SuperAdminOnly, h.ListReportFiles) //Wallet Monitor Service // group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error { diff --git a/internal/web_server/worker/report.go b/internal/web_server/worker/report.go deleted file mode 100644 index ab6fc6c..0000000 --- a/internal/web_server/worker/report.go +++ /dev/null @@ -1,29 +0,0 @@ -// worker/report_worker.go -package worker - -import ( - "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/SamuelTariku/FortuneBet-Backend/internal/infrastructure" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/report" -) - -type ReportWorker struct { - reportService *report.Service - exporter infrastructure.CSVExporter -} - -func NewReportWorker(service *report.Service, exporter infrastructure.CSVExporter) *ReportWorker { - return &ReportWorker{ - reportService: service, - exporter: exporter, - } -} - -func (w *ReportWorker) GenerateAndExport(timeFrame domain.TimeFrame) error { - report, err := w.reportService.GenerateReport(timeFrame) - if err != nil { - return err - } - - return w.exporter.Export(report) -}