CSV reports + live metrics + redis service

This commit is contained in:
Yared Yemane 2025-06-21 21:48:11 +03:00
parent 5cd5d2f143
commit 12855f3690
42 changed files with 1719 additions and 234 deletions

View File

@ -18,7 +18,6 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/infrastructure"
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
"github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger"
@ -55,7 +54,6 @@ import (
httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/worker"
) )
// @title FortuneBet API // @title FortuneBet API
@ -119,7 +117,7 @@ func main() {
branchSvc := branch.NewService(store) branchSvc := branch.NewService(store)
companySvc := company.NewService(store) companySvc := company.NewService(store)
leagueSvc := league.New(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) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, logger, domain.MongoDBLogger)
resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc) resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc)
referalRepo := repository.NewReferralRepository(store) referalRepo := repository.NewReferralRepository(store)
@ -162,15 +160,17 @@ func main() {
logger, logger,
) )
// Initialize report worker with CSV exporter go httpserver.SetupReportCronJobs(context.Background(), reportSvc)
csvExporter := infrastructure.CSVExporter{
ExportPath: cfg.ReportExportPath, // Make sure to add this to your config
}
reportWorker := worker.NewReportWorker( // Initialize report worker with CSV exporter
reportSvc, // csvExporter := infrastructure.CSVExporter{
csvExporter, // ExportPath: cfg.ReportExportPath, // Make sure to add this to your config
) // }
// reportWorker := worker.NewReportWorker(
// reportSvc,
// csvExporter,
// )
// Start cron jobs for automated reporting // Start cron jobs for automated reporting
@ -196,7 +196,7 @@ func main() {
httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc) httpserver.StartDataFetchingCrons(eventSvc, *oddsSvc, resultSvc)
httpserver.StartTicketCrons(*ticketSvc) httpserver.StartTicketCrons(*ticketSvc)
go httpserver.SetupReportCronJob(reportWorker) // go httpserver.SetupReportCronJob(reportWorker)
// Initialize and start HTTP server // Initialize and start HTTP server
app := httpserver.NewApp( app := httpserver.NewApp(
@ -229,6 +229,7 @@ func main() {
recommendationSvc, recommendationSvc,
resultSvc, resultSvc,
cfg, cfg,
domain.MongoDBLogger,
) )
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)
@ -236,4 +237,5 @@ func main() {
logger.Error("Failed to start server", "error", err) logger.Error("Failed to start server", "error", err)
os.Exit(1) os.Exit(1)
} }
select {}
} }

View File

@ -78,4 +78,5 @@ DROP TABLE IF EXISTS odds;
DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS leagues; DROP TABLE IF EXISTS leagues;
DROP TABLE IF EXISTS teams; DROP TABLE IF EXISTS teams;
DROP TABLE IF EXISTS settings; DROP TABLE IF EXISTS settings;
-- DELETE FROM wallet_transfer;

View File

@ -139,7 +139,7 @@ CREATE TABLE IF NOT EXISTS wallet_transfer (
sender_wallet_id BIGINT, sender_wallet_id BIGINT,
cashier_id BIGINT, cashier_id BIGINT,
verified BOOLEAN DEFAULT false, verified BOOLEAN DEFAULT false,
reference_number VARCHAR(255), reference_number VARCHAR(255) NOT NULL,
status VARCHAR(255), status VARCHAR(255),
payment_method VARCHAR(255), payment_method VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -118,4 +118,6 @@ DELETE FROM bets
WHERE id = $1; WHERE id = $1;
-- name: DeleteBetOutcome :exec -- name: DeleteBetOutcome :exec
DELETE FROM bet_outcomes DELETE FROM bet_outcomes
WHERE bet_id = $1; WHERE bet_id = $1;

34
db/query/report.sql Normal file
View File

@ -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
);

View File

@ -58,4 +58,8 @@ Delete from tickets
where created_at < now() - interval '1 day'; where created_at < now() - interval '1 day';
-- name: DeleteTicketOutcome :exec -- name: DeleteTicketOutcome :exec
Delete from ticket_outcomes Delete from ticket_outcomes
where ticket_id = $1; 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;

View File

@ -38,4 +38,13 @@ WHERE id = $2;
UPDATE wallet_transfer UPDATE wallet_transfer
SET status = $1, SET status = $1,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $2; 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;

View File

@ -61,3 +61,14 @@ WHERE external_transaction_id = $1;
UPDATE virtual_game_transactions UPDATE virtual_game_transactions
SET status = $2, updated_at = CURRENT_TIMESTAMP SET status = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1; 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;

View File

@ -1,3 +1,5 @@
version: "3.8"
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@ -54,6 +56,18 @@ services:
networks: networks:
- app - app
redis:
image: redis:7-alpine
ports:
- "6379:6379"
networks:
- app
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
app: app:
build: build:
context: . context: .
@ -64,14 +78,19 @@ services:
environment: environment:
- DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable
- MONGO_URI=mongodb://root:secret@mongo:27017 - MONGO_URI=mongodb://root:secret@mongo:27017
- REDIS_ADDR=redis:6379
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
mongo: mongo:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
networks: networks:
- app - app
command: ["/app/bin/web"] command: ["/app/bin/web"]
volumes:
- "C:/Users/User/Desktop:/host-desktop"
test: test:
build: build:

View File

@ -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": { "/api/v1/reports/dashboard": {
"get": { "get": {
"security": [ "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": { "domain.Odd": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -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": { "/api/v1/reports/dashboard": {
"get": { "get": {
"security": [ "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": { "domain.Odd": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -395,6 +395,26 @@ definitions:
example: 1 example: 1
type: integer type: integer
type: object 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: domain.Odd:
properties: properties:
category: category:
@ -1991,6 +2011,81 @@ paths:
summary: Convert currency summary: Convert currency
tags: tags:
- Multi-Currency - 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: /api/v1/reports/dashboard:
get: get:
consumes: consumes:

View File

@ -525,7 +525,7 @@ type WalletTransfer struct {
SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` SenderWalletID pgtype.Int8 `json:"sender_wallet_id"`
CashierID pgtype.Int8 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"` Verified pgtype.Bool `json:"verified"`
ReferenceNumber pgtype.Text `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"` PaymentMethod pgtype.Text `json:"payment_method"`
CreatedAt pgtype.Timestamp `json:"created_at"` CreatedAt pgtype.Timestamp `json:"created_at"`

106
gen/db/report.sql.go Normal file
View File

@ -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
}

View File

@ -128,6 +128,29 @@ func (q *Queries) GetAllTickets(ctx context.Context) ([]TicketWithOutcome, error
return items, nil 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 const GetTicketByID = `-- name: GetTicketByID :one
SELECT id, amount, total_odds, ip, created_at, updated_at, outcomes SELECT id, amount, total_odds, ip, created_at, updated_at, outcomes
FROM ticket_with_outcomes FROM ticket_with_outcomes

View File

@ -34,7 +34,7 @@ type CreateTransferParams struct {
SenderWalletID pgtype.Int8 `json:"sender_wallet_id"` SenderWalletID pgtype.Int8 `json:"sender_wallet_id"`
CashierID pgtype.Int8 `json:"cashier_id"` CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"` Verified pgtype.Bool `json:"verified"`
ReferenceNumber pgtype.Text `json:"reference_number"` ReferenceNumber string `json:"reference_number"`
Status pgtype.Text `json:"status"` Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"` PaymentMethod pgtype.Text `json:"payment_method"`
} }
@ -139,7 +139,7 @@ FROM wallet_transfer
WHERE reference_number = $1 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) row := q.db.QueryRow(ctx, GetTransferByReference, referenceNumber)
var i WalletTransfer var i WalletTransfer
err := row.Scan( err := row.Scan(
@ -199,6 +199,44 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgt
return items, nil 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 const UpdateTransferStatus = `-- name: UpdateTransferStatus :exec
UPDATE wallet_transfer UPDATE wallet_transfer
SET status = $1, SET status = $1,

View File

@ -197,6 +197,49 @@ func (q *Queries) GetVirtualGameSessionByToken(ctx context.Context, sessionToken
return i, err 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 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 SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at
FROM virtual_game_transactions FROM virtual_game_transactions

3
go.mod
View File

@ -83,7 +83,10 @@ require (
) )
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/golang/mock v1.6.0 // indirect
github.com/pkg/errors v0.9.1 // 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 go.uber.org/atomic v1.9.0 // indirect
) )

6
go.sum
View File

@ -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.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 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM=
github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 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= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

View File

@ -91,6 +91,7 @@ type Config struct {
TwilioAccountSid string TwilioAccountSid string
TwilioAuthToken string TwilioAuthToken string
TwilioSenderPhoneNumber string TwilioSenderPhoneNumber string
RedisAddr string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -115,6 +116,8 @@ func (c *Config) loadEnv() error {
c.ReportExportPath = os.Getenv("REPORT_EXPORT_PATH") c.ReportExportPath = os.Getenv("REPORT_EXPORT_PATH")
c.RedisAddr = os.Getenv("REDIS_ADDR")
c.CHAPA_TRANSFER_TYPE = os.Getenv("CHAPA_TRANSFER_TYPE") c.CHAPA_TRANSFER_TYPE = os.Getenv("CHAPA_TRANSFER_TYPE")
c.CHAPA_PAYMENT_TYPE = os.Getenv("CHAPA_PAYMENT_TYPE") c.CHAPA_PAYMENT_TYPE = os.Getenv("CHAPA_PAYMENT_TYPE")

View File

@ -78,3 +78,6 @@ func CalculateWinnings(amount Currency, totalOdds float32) Currency {
return ToCurrency(possibleWin - incomeTax) return ToCurrency(possibleWin - incomeTax)
} }
func PtrFloat64(v float64) *float64 { return &v }
func PtrInt64(v int64) *int64 { return &v }

View File

@ -10,6 +10,37 @@ const (
Monthly TimeFrame = "monthly" 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 { type Report struct {
ID string ID string
TimeFrame TimeFrame TimeFrame TimeFrame
@ -22,6 +53,22 @@ type Report struct {
GeneratedAt time.Time 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 { type DashboardSummary struct {
TotalStakes Currency `json:"total_stakes"` TotalStakes Currency `json:"total_stakes"`
TotalBets int64 `json:"total_bets"` TotalBets int64 `json:"total_bets"`

View File

@ -33,28 +33,28 @@ type PaymentDetails struct {
// A Transfer is logged for every modification of ALL wallets and wallet types // A Transfer is logged for every modification of ALL wallets and wallet types
type Transfer struct { type Transfer struct {
ID int64 ID int64 `json:"id"`
Amount Currency Amount Currency `json:"amount"`
Verified bool Verified bool `json:"verified"`
Type TransferType Type TransferType `json:"type"`
PaymentMethod PaymentMethod PaymentMethod PaymentMethod `json:"payment_method"`
ReceiverWalletID ValidInt64 ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"`
SenderWalletID ValidInt64 SenderWalletID ValidInt64 `json:"sender_wallet_id"`
ReferenceNumber string ReferenceNumber string `json:"reference_number"` // <-- needed
Status string Status string `json:"status"`
CashierID ValidInt64 CashierID ValidInt64 `json:"cashier_id"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updated_at"`
} }
type CreateTransfer struct { type CreateTransfer struct {
Amount Currency Amount Currency `json:"amount"`
Verified bool Verified bool `json:"verified"`
ReferenceNumber string Type TransferType `json:"type"`
Status string PaymentMethod PaymentMethod `json:"payment_method"`
ReceiverWalletID ValidInt64 ReceiverWalletID ValidInt64 `json:"receiver_wallet_id"`
SenderWalletID ValidInt64 SenderWalletID ValidInt64 `json:"sender_wallet_id"`
CashierID ValidInt64 ReferenceNumber string `json:"reference_number"` // <-- needed
Type TransferType Status string `json:"status"`
PaymentMethod PaymentMethod CashierID ValidInt64 `json:"cashier_id"`
} }

View File

@ -2,15 +2,26 @@ package repository
import ( import (
"context" "context"
"fmt"
"time" "time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
) )
type ReportRepository interface { type ReportRepository interface {
GenerateReport(timeFrame domain.TimeFrame, start, end time.Time) (*domain.Report, error) GenerateReport(timeFrame domain.TimeFrame, start, end time.Time) (*domain.Report, error)
SaveReport(report *domain.Report) error SaveReport(report *domain.Report) error
FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit int) ([]*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 { type ReportRepo struct {
@ -105,3 +116,105 @@ func (r *ReportRepo) FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit in
return reports, nil 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)
}
}

View File

@ -26,7 +26,11 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer {
Value: transfer.CashierID.Int64, Value: transfer.CashierID.Int64,
Valid: transfer.CashierID.Valid, 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, Int64: transfer.CashierID.Value,
Valid: transfer.CashierID.Valid, 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}, 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 return result, nil
} }
func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) { func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) {
transfers, err := s.queries.GetTransfersByWallet(ctx, pgtype.Int8{Int64: walletID, Valid: true}) transfers, err := s.queries.GetTransfersByWallet(ctx, pgtype.Int8{Int64: walletID, Valid: true})
if err != nil { 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) { 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 { if err != nil {
return domain.Transfer{}, nil return domain.Transfer{}, nil
} }

View File

@ -92,10 +92,6 @@ func (s *Service) InitiateDeposit(ctx context.Context, userID int64, amount doma
Verified: false, Verified: false,
} }
if _, err := s.transferStore.CreateTransfer(ctx, transfer); err != nil {
return "", fmt.Errorf("failed to save payment: %w", err)
}
// Initialize payment with Chapa // Initialize payment with Chapa
response, err := s.chapaClient.InitializePayment(ctx, domain.ChapaDepositRequest{ response, err := s.chapaClient.InitializePayment(ctx, domain.ChapaDepositRequest{
Amount: amount, 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) 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 return response.CheckoutURL, nil
} }
func (s *Service) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) { func (s *Service) InitiateWithdrawal(ctx context.Context, userID int64, req domain.ChapaWithdrawalRequest) (*domain.Transfer, error) {
// Parse and validate amount // Parse and validate amount
amount, err := strconv.ParseInt(req.Amount, 10, 64) amount, err := strconv.ParseInt(req.Amount, 10, 64)

View File

@ -2,6 +2,7 @@ package notificationservice
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"sync" "sync"
@ -14,6 +15,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws"
afro "github.com/amanuelabay/afrosms-go" afro "github.com/amanuelabay/afrosms-go"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
) )
type Service struct { type Service struct {
@ -24,10 +26,15 @@ type Service struct {
stopCh chan struct{} stopCh chan struct{}
config *config.Config config *config.Config
logger *slog.Logger logger *slog.Logger
redisClient *redis.Client
} }
func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service { func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service {
hub := ws.NewNotificationHub() hub := ws.NewNotificationHub()
rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr, // e.g., “redis:6379”
})
svc := &Service{ svc := &Service{
repo: repo, repo: repo,
Hub: hub, Hub: hub,
@ -36,11 +43,13 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi
notificationCh: make(chan *domain.Notification, 1000), notificationCh: make(chan *domain.Notification, 1000),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
config: cfg, config: cfg,
redisClient: rdb,
} }
go hub.Run() go hub.Run()
go svc.startWorker() go svc.startWorker()
go svc.startRetryWorker() go svc.startRetryWorker()
go svc.RunRedisSubscriber(context.Background())
return svc 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){ // func (s *Service) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error){
// return s.repo.Get(ctx, filter) // 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
}

View File

@ -2,9 +2,14 @@ package report
import ( import (
"context" "context"
"encoding/csv"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os"
"sort" "sort"
"strconv"
"strings"
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -454,34 +459,144 @@ func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportF
return performances, nil return performances, nil
} }
func (s *Service) GenerateReport(timeFrame domain.TimeFrame) (*domain.Report, error) { func (s *Service) GenerateReport(ctx context.Context, period string) error {
now := time.Now() data, err := s.fetchReportData(ctx, period)
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)
if err != nil { if err != nil {
return nil, err return fmt.Errorf("fetch data: %w", err)
} }
if err := s.repo.SaveReport(report); err != nil { filePath := fmt.Sprintf("/host-desktop/report_%s_%s.csv", period, time.Now().Format("2006-01-02_15-04"))
return nil, err 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) { // func (s *Service) GetCompanyPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CompanyPerformance, error) {
// // Get company bet activity // // Get company bet activity
// companyBets, err := s.betStore.GetCompanyBetActivity(ctx, filter) // companyBets, err := s.betStore.GetCompanyBetActivity(ctx, filter)

View File

@ -9,6 +9,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -28,10 +29,11 @@ var (
) )
type Service struct { type Service struct {
ticketStore TicketStore ticketStore TicketStore
eventSvc event.Service eventSvc event.Service
prematchSvc odds.ServiceImpl prematchSvc odds.ServiceImpl
mongoLogger *zap.Logger mongoLogger *zap.Logger
notificationSvc *notificationservice.Service
} }
func NewService( func NewService(
@ -39,12 +41,14 @@ func NewService(
eventSvc event.Service, eventSvc event.Service,
prematchSvc odds.ServiceImpl, prematchSvc odds.ServiceImpl,
mongoLogger *zap.Logger, mongoLogger *zap.Logger,
notificationSvc *notificationservice.Service,
) *Service { ) *Service {
return &Service{ return &Service{
ticketStore: ticketStore, ticketStore: ticketStore,
eventSvc: eventSvc, eventSvc: eventSvc,
prematchSvc: prematchSvc, prematchSvc: prematchSvc,
mongoLogger: mongoLogger, mongoLogger: mongoLogger,
notificationSvc: notificationSvc,
} }
} }
@ -176,7 +180,7 @@ func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq,
if count > 50 { if count > 50 {
// return response.WriteJSON(c, fiber.StatusBadRequest, "Ticket Limit reached", nil, nil) // return response.WriteJSON(c, fiber.StatusBadRequest, "Ticket Limit reached", nil, nil)
return domain.Ticket{}, 0, ErrTicketLimitForSingleUser return domain.Ticket{}, 0, ErrTicketLimitForSingleUser
} }
var outcomes []domain.CreateTicketOutcome = make([]domain.CreateTicketOutcome, 0, len(req.Outcomes)) var outcomes []domain.CreateTicketOutcome = make([]domain.CreateTicketOutcome, 0, len(req.Outcomes))
var totalOdds float32 = 1 var totalOdds float32 = 1
for _, outcomeReq := range req.Outcomes { for _, outcomeReq := range req.Outcomes {
@ -222,6 +226,14 @@ func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq,
return domain.Ticket{}, rows, err 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 return ticket, rows, nil
} }

View File

@ -91,12 +91,11 @@ func (s *Service) checkWalletThresholds() {
// Initialize initial deposit if not set // Initialize initial deposit if not set
s.mu.Lock() 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.initialDeposits[company.ID] = wallet.Balance
s.mu.Unlock() initialDeposit = wallet.Balance // update local variable
continue
} }
initialDeposit := s.initialDeposits[company.ID]
s.mu.Unlock() s.mu.Unlock()
if initialDeposit == 0 { if initialDeposit == 0 {

View File

@ -119,7 +119,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom
DeliveryChannel: domain.DeliveryChannelInApp, DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{ Payload: domain.NotificationPayload{
Headline: "Wallet has been deducted", 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, Priority: 2,
Metadata: []byte(fmt.Sprintf(`{ Metadata: []byte(fmt.Sprintf(`{
@ -148,7 +148,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom
DeliveryChannel: domain.DeliveryChannelInApp, DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{ Payload: domain.NotificationPayload{
Headline: "Wallet has been credited", 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, Priority: 2,
Metadata: []byte(fmt.Sprintf(`{ Metadata: []byte(fmt.Sprintf(`{

View File

@ -27,6 +27,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
"go.uber.org/zap"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
@ -63,6 +64,7 @@ type App struct {
eventSvc event.Service eventSvc event.Service
leagueSvc league.Service leagueSvc league.Service
resultSvc *result.Service resultSvc *result.Service
mongoLoggerSvc *zap.Logger
} }
func NewApp( func NewApp(
@ -91,6 +93,7 @@ func NewApp(
recommendationSvc recommendation.RecommendationService, recommendationSvc recommendation.RecommendationService,
resultSvc *result.Service, resultSvc *result.Service,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -135,6 +138,7 @@ func NewApp(
recommendationSvc: recommendationSvc, recommendationSvc: recommendationSvc,
resultSvc: resultSvc, resultSvc: resultSvc,
cfg: cfg, cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc,
} }
s.initAppRoutes() s.initAppRoutes()

View File

@ -8,37 +8,14 @@ import (
// "time" // "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" 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" resultsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "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" "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) { func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.ServiceImpl, resultService *resultsvc.Service) {
c := cron.New(cron.WithSeconds()) c := cron.New(cron.WithSeconds())
@ -128,3 +105,46 @@ func StartTicketCrons(ticketService ticket.Service) {
c.Start() c.Start()
log.Println("Cron jobs started for ticket service") 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")
}

View File

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"log/slog"
"strconv" "strconv"
"time" "time"
@ -10,6 +9,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap"
) )
type CreateAdminReq struct { type CreateAdminReq struct {
@ -38,15 +38,24 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
var req CreateAdminReq var req CreateAdminReq
if err := c.BodyParser(&req); err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil)
} }
valErrs, ok := h.validator.Validate(c, req) valErrs, ok := h.validator.Validate(c, req)
if !ok { 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) 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 { if req.CompanyID == nil {
companyID = domain.ValidInt64{ companyID = domain.ValidInt64{
Value: 0, Value: 0,
@ -55,7 +64,12 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
} else { } else {
_, err := h.companySvc.GetCompanyByID(c.Context(), *req.CompanyID) _, err := h.companySvc.GetCompanyByID(c.Context(), *req.CompanyID)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusInternalServerError, "Company ID is invalid", nil, nil)
} }
companyID = domain.ValidInt64{ companyID = domain.ValidInt64{
@ -74,10 +88,14 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
CompanyID: companyID, CompanyID: companyID,
} }
h.logger.Info("CreateAdmin", slog.Bool("company id", req.CompanyID == nil))
newUser, err := h.userSvc.CreateUser(c.Context(), user, true) newUser, err := h.userSvc.CreateUser(c.Context(), user, true)
if err != nil { 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) 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, AdminID: &newUser.ID,
}) })
if err != nil { 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) 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) return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil)
} }
@ -125,7 +155,6 @@ type AdminRes struct {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /admin [get] // @Router /admin [get]
func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
filter := user.Filter{ filter := user.Filter{
Role: string(domain.RoleAdmin), Role: string(domain.RoleAdmin),
CompanyID: domain.ValidInt64{ CompanyID: domain.ValidInt64{
@ -141,27 +170,45 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
Valid: true, Valid: true,
}, },
} }
valErrs, ok := h.validator.Validate(c, filter) valErrs, ok := h.validator.Validate(c, filter)
if !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
admins, total, err := h.userSvc.GetAllUsers(c.Context(), filter) admins, total, err := h.userSvc.GetAllUsers(c.Context(), filter)
if err != nil { 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) 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 { for index, admin := range admins {
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), admin.ID) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), admin.ID)
if err != nil { if err != nil {
if err == authentication.ErrRefreshTokenNotFound { if err == authentication.ErrRefreshTokenNotFound {
lastLogin = &admin.CreatedAt lastLogin = &admin.CreatedAt
} else { } 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") return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login")
} }
} }
result[index] = AdminRes{ result[index] = AdminRes{
ID: admin.ID, ID: admin.ID,
FirstName: admin.FirstName, 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)) 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 // @Failure 500 {object} response.APIResponse
// @Router /admin/{id} [get] // @Router /admin/{id} [get]
func (h *Handler) GetAdminByID(c *fiber.Ctx) error { 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") userIDstr := c.Params("id")
userID, err := strconv.ParseInt(userIDstr, 10, 64) userID, err := strconv.ParseInt(userIDstr, 10, 64)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid admin ID", nil, nil)
} }
user, err := h.userSvc.GetUserByID(c.Context(), userID) user, err := h.userSvc.GetUserByID(c.Context(), userID)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get admin", nil, nil)
} }
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil { if err != nil && err != authentication.ErrRefreshTokenNotFound {
if err != authentication.ErrRefreshTokenNotFound { h.mongoLoggerSvc.Error("failed to get admin last login",
h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) zap.Int("status_code", fiber.StatusInternalServerError),
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") 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 lastLogin = &user.CreatedAt
} }
@ -249,7 +302,13 @@ func (h *Handler) GetAdminByID(c *fiber.Ctx) error {
LastLogin: *lastLogin, 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 { type updateAdminReq struct {
@ -274,21 +333,36 @@ type updateAdminReq struct {
func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
var req updateAdminReq var req updateAdminReq
if err := c.BodyParser(&req); err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil)
} }
valErrs, ok := h.validator.Validate(c, req) valErrs, ok := h.validator.Validate(c, req)
if !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
AdminIDStr := c.Params("id") AdminIDStr := c.Params("id")
AdminID, err := strconv.ParseInt(AdminIDStr, 10, 64) AdminID, err := strconv.ParseInt(AdminIDStr, 10, 64)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Admin ID", nil, nil)
} }
var companyID domain.ValidInt64 var companyID domain.ValidInt64
if req.CompanyID != nil { if req.CompanyID != nil {
companyID = domain.ValidInt64{ companyID = domain.ValidInt64{
@ -296,6 +370,7 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
Valid: true, Valid: true,
} }
} }
err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{
UserId: AdminID, UserId: AdminID,
FirstName: domain.ValidString{ FirstName: domain.ValidString{
@ -311,23 +386,38 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
Valid: true, Valid: true,
}, },
CompanyID: companyID, CompanyID: companyID,
}, })
)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update admin", nil, nil)
} }
if req.CompanyID != nil { if req.CompanyID != nil {
_, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{ _, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{
ID: *req.CompanyID, ID: *req.CompanyID,
AdminID: &AdminID, AdminID: &AdminID,
}) })
if err != nil { 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.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)
} }

View File

@ -2,11 +2,13 @@ package handlers
import ( import (
"errors" "errors"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap"
) )
// loginCustomerReq represents the request body for the LoginCustomer endpoint. // loginCustomerReq represents the request body for the LoginCustomer endpoint.
@ -38,31 +40,56 @@ type loginCustomerRes struct {
func (h *Handler) LoginCustomer(c *fiber.Ctx) error { func (h *Handler) LoginCustomer(c *fiber.Ctx) error {
var req loginCustomerReq var req loginCustomerReq
if err := c.BodyParser(&req); err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
if _, ok := h.validator.Validate(c, req); !ok { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid Request")
} }
successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password)
if err != nil { 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 { switch {
case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials") return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials")
case errors.Is(err, authentication.ErrUserSuspended): case errors.Is(err, authentication.ErrUserSuspended):
return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked") return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked")
default: 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") return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
} }
} }
accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry)
if err != nil { 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") 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, RefreshToken: successRes.RfToken,
Role: string(successRes.Role), 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) 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 var req refreshToken
if err := c.BodyParser(&req); err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
if valErrs, ok := h.validator.Validate(c, req); !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
refreshToken, err := h.authSvc.RefreshToken(c.Context(), req.RefreshToken) refreshToken, err := h.authSvc.RefreshToken(c.Context(), req.RefreshToken)
if err != nil { 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 { switch {
case errors.Is(err, authentication.ErrExpiredToken): case errors.Is(err, authentication.ErrExpiredToken):
return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired") return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired")
case errors.Is(err, authentication.ErrRefreshTokenNotFound): case errors.Is(err, authentication.ErrRefreshTokenNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found") return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found")
default: 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") return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
} }
} }
user, err := h.userSvc.GetUserByID(c.Context(), refreshToken.UserID) 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) accessToken, err := jwtutil.CreateJwt(user.ID, user.Role, user.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry)
if err != nil { 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") 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, RefreshToken: req.RefreshToken,
Role: string(user.Role), 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) return response.WriteJSON(c, fiber.StatusOK, "Refresh successful", res, nil)
} }
@ -157,30 +231,52 @@ type logoutReq struct {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (h *Handler) LogOutCustomer(c *fiber.Ctx) error { func (h *Handler) LogOutCustomer(c *fiber.Ctx) error {
var req logoutReq var req logoutReq
if err := c.BodyParser(&req); err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
if valErrs, ok := h.validator.Validate(c, req); !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
err := h.authSvc.Logout(c.Context(), req.RefreshToken) err := h.authSvc.Logout(c.Context(), req.RefreshToken)
if err != nil { 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 { switch {
case errors.Is(err, authentication.ErrExpiredToken): case errors.Is(err, authentication.ErrExpiredToken):
return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired") return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired")
case errors.Is(err, authentication.ErrRefreshTokenNotFound): case errors.Is(err, authentication.ErrRefreshTokenNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found") return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found")
default: 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") 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) return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil)
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap"
) )
// CreateBet godoc // CreateBet godoc
@ -23,34 +24,52 @@ import (
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /bet [post] // @Router /bet [post]
func (h *Handler) CreateBet(c *fiber.Ctx) error { func (h *Handler) CreateBet(c *fiber.Ctx) error {
// Get user_id from middleware
userID := c.Locals("user_id").(int64) userID := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) role := c.Locals("role").(domain.Role)
var req domain.CreateBetReq var req domain.CreateBetReq
if err := c.BodyParser(&req); err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
valErrs, ok := h.validator.Validate(c, req) valErrs, ok := h.validator.Validate(c, req)
if !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role) res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role)
if err != nil { 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 { switch err {
case bet.ErrEventHasBeenRemoved, bet.ErrEventHasNotEnded, bet.ErrRawOddInvalid, wallet.ErrBalanceInsufficient: case bet.ErrEventHasBeenRemoved, bet.ErrEventHasNotEnded, bet.ErrRawOddInvalid, wallet.ErrBalanceInsufficient:
return fiber.NewError(fiber.StatusBadRequest, err.Error()) return fiber.NewError(fiber.StatusBadRequest, err.Error())
} }
return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") 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 // RandomBet godoc
@ -65,20 +84,25 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /random/bet [post] // @Router /random/bet [post]
func (h *Handler) RandomBet(c *fiber.Ctx) error { func (h *Handler) RandomBet(c *fiber.Ctx) error {
// Get user_id from middleware
userID := c.Locals("user_id").(int64) userID := c.Locals("user_id").(int64)
// role := c.Locals("role").(domain.Role)
leagueIDQuery, err := strconv.Atoi(c.Query("league_id")) leagueIDQuery, err := strconv.Atoi(c.Query("league_id"))
if err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "invalid league id", nil, nil)
} }
sportIDQuery, err := strconv.Atoi(c.Query("sport_id")) sportIDQuery, err := strconv.Atoi(c.Query("sport_id"))
if err != nil { 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) 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 != "" { if firstStartTimeQuery != "" {
firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil)
} }
firstStartTime = domain.ValidTime{ firstStartTime = domain.ValidTime{
@ -106,11 +134,16 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error {
Valid: true, Valid: true,
} }
} }
var lastStartTime domain.ValidTime var lastStartTime domain.ValidTime
if lastStartTimeQuery != "" { if lastStartTimeQuery != "" {
lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery) lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil)
} }
lastStartTime = domain.ValidTime{ lastStartTime = domain.ValidTime{
@ -121,21 +154,33 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error {
var req domain.RandomBetReq var req domain.RandomBetReq
if err := c.BodyParser(&req); err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
valErrs, ok := h.validator.Validate(c, req) valErrs, ok := h.validator.Validate(c, req)
if !ok { 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) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
} }
var res domain.CreateBetRes var res domain.CreateBetRes
for i := 0; i < int(req.NumberOfBets); i++ { for i := 0; i < int(req.NumberOfBets); i++ {
res, err = h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) res, err = h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime)
if err != nil { 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 { switch err {
case bet.ErrNoEventsAvailable: case bet.ErrNoEventsAvailable:
return fiber.NewError(fiber.StatusBadRequest, "No events found") 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 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 // GetAllBet godoc
@ -158,18 +209,20 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error {
// @Failure 500 {object} response.APIResponse // @Failure 500 {object} response.APIResponse
// @Router /bet [get] // @Router /bet [get]
func (h *Handler) GetAllBet(c *fiber.Ctx) error { func (h *Handler) GetAllBet(c *fiber.Ctx) error {
// role := c.Locals("role").(domain.Role)
companyID := c.Locals("company_id").(domain.ValidInt64) companyID := c.Locals("company_id").(domain.ValidInt64)
branchID := c.Locals("branch_id").(domain.ValidInt64) branchID := c.Locals("branch_id").(domain.ValidInt64)
var isShopBet domain.ValidBool var isShopBet domain.ValidBool
isShopBetQuery := c.Query("is_shop") isShopBetQuery := c.Query("is_shop")
if isShopBetQuery != "" { if isShopBetQuery != "" {
isShopBetParse, err := strconv.ParseBool(isShopBetQuery) isShopBetParse, err := strconv.ParseBool(isShopBetQuery)
if err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Failed to parse is_shop_bet")
} }
isShopBet = domain.ValidBool{ isShopBet = domain.ValidBool{
@ -177,13 +230,18 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error {
Valid: true, Valid: true,
} }
} }
bets, err := h.betSvc.GetAllBets(c.Context(), domain.BetFilter{ bets, err := h.betSvc.GetAllBets(c.Context(), domain.BetFilter{
BranchID: branchID, BranchID: branchID,
CompanyID: companyID, CompanyID: companyID,
IsShopBet: isShopBet, IsShopBet: isShopBet,
}) })
if err != nil { 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") 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) 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) 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") betID := c.Params("id")
id, err := strconv.ParseInt(betID, 10, 64) id, err := strconv.ParseInt(betID, 10, 64)
if err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID")
} }
bet, err := h.betSvc.GetBetByID(c.Context(), id) bet, err := h.betSvc.GetBetByID(c.Context(), id)
if err != nil { if err != nil {
// TODO: handle all the errors types h.mongoLoggerSvc.Error("Failed to get bet by ID",
h.logger.Error("Failed to get bet by ID", "betID", id, "error", err) 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") return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve bet")
} }
res := domain.ConvertBet(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 // GetBetByCashoutID godoc
@ -240,23 +317,27 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error {
// @Router /bet/cashout/{id} [get] // @Router /bet/cashout/{id} [get]
func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error { func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error {
cashoutID := c.Params("id") 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) bet, err := h.betSvc.GetBetByCashoutID(c.Context(), cashoutID)
if err != nil { 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) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil)
} }
res := domain.ConvertBet(bet) 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 { type UpdateCashOutReq struct {
@ -283,13 +364,23 @@ func (h *Handler) UpdateCashOut(c *fiber.Ctx) error {
betID := c.Params("id") betID := c.Params("id")
id, err := strconv.ParseInt(betID, 10, 64) id, err := strconv.ParseInt(betID, 10, 64)
if err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID")
} }
var req UpdateCashOutReq var req UpdateCashOutReq
if err := c.BodyParser(&req); err != nil { 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) 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) err = h.betSvc.UpdateCashOut(c.Context(), id, req.CashedOut)
if err != nil { 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") 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) 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") betID := c.Params("id")
id, err := strconv.ParseInt(betID, 10, 64) id, err := strconv.ParseInt(betID, 10, 64)
if err != nil { 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") return fiber.NewError(fiber.StatusBadRequest, "Invalid bet ID")
} }
err = h.betSvc.DeleteBet(c.Context(), id) err = h.betSvc.DeleteBet(c.Context(), id)
if err != nil { 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") 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) return response.WriteJSON(c, fiber.StatusOK, "Bet removed successfully", nil, nil)
} }

View File

@ -26,6 +26,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
"go.uber.org/zap"
) )
type Handler struct { type Handler struct {
@ -54,6 +55,7 @@ type Handler struct {
jwtConfig jwtutil.JwtConfig jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
Cfg *config.Config Cfg *config.Config
mongoLoggerSvc *zap.Logger
} }
func New( func New(
@ -82,6 +84,7 @@ func New(
leagueSvc league.Service, leagueSvc league.Service,
resultSvc result.Service, resultSvc result.Service,
cfg *config.Config, cfg *config.Config,
mongoLoggerSvc *zap.Logger,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
currSvc: currSvc, currSvc: currSvc,
@ -109,5 +112,6 @@ func New(
resultSvc: resultSvc, resultSvc: resultSvc,
jwtConfig: jwtConfig, jwtConfig: jwtConfig,
Cfg: cfg, Cfg: cfg,
mongoLoggerSvc: mongoLoggerSvc,
} }
} }

View File

@ -10,9 +10,17 @@ import (
"go.mongodb.org/mongo-driver/mongo/options" "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 { func GetLogsHandler(appCtx context.Context) fiber.Handler {
return func(c *fiber.Ctx) error { 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 { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "MongoDB connection failed: "+err.Error()) return fiber.NewError(fiber.StatusInternalServerError, "MongoDB connection failed: "+err.Error())
} }

View File

@ -3,7 +3,9 @@ package handlers
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -121,3 +123,81 @@ func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) {
return filter, err 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,
})
}

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"strconv" "strconv"
"time" "time"
@ -134,7 +135,9 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error {
// Get sender ID from the cashier // Get sender ID from the cashier
userID := c.Locals("user_id").(int64) userID := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role) 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 var senderID int64
@ -143,9 +146,13 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error {
h.logger.Error("Unauthorized access", "userID", userID, "role", role) h.logger.Error("Unauthorized access", "userID", userID, "role", role)
return response.WriteJSON(c, fiber.StatusUnauthorized, "Unauthorized access", nil, nil) return response.WriteJSON(c, fiber.StatusUnauthorized, "Unauthorized access", nil, nil)
} else if role == domain.RoleBranchManager || role == domain.RoleAdmin || role == domain.RoleSuperAdmin { } 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 { 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 senderID = company.WalletID
h.logger.Error("Will", "userID", userID, "role", role) h.logger.Error("Will", "userID", userID, "role", role)

View File

@ -45,6 +45,7 @@ func (a *App) initAppRoutes() {
a.leagueSvc, a.leagueSvc,
*a.resultSvc, *a.resultSvc,
a.cfg, a.cfg,
a.mongoLoggerSvc,
) )
group := a.fiber.Group("/api/v1") group := a.fiber.Group("/api/v1")
@ -214,6 +215,8 @@ func (a *App) initAppRoutes() {
//Report Routes //Report Routes
group.Get("/reports/dashboard", h.GetDashboardReport) 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 //Wallet Monitor Service
// group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error { // group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error {

View File

@ -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)
}