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

View File

@ -79,3 +79,4 @@ DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS leagues;
DROP TABLE IF EXISTS teams;
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,
cashier_id BIGINT,
verified BOOLEAN DEFAULT false,
reference_number VARCHAR(255),
reference_number VARCHAR(255) NOT NULL,
status VARCHAR(255),
payment_method VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -119,3 +119,5 @@ WHERE id = $1;
-- name: DeleteBetOutcome :exec
DELETE FROM bet_outcomes
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

@ -59,3 +59,7 @@ where created_at < now() - interval '1 day';
-- name: DeleteTicketOutcome :exec
Delete from ticket_outcomes
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

@ -39,3 +39,12 @@ UPDATE wallet_transfer
SET status = $1,
updated_at = CURRENT_TIMESTAMP
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
SET status = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: GetVirtualGameSummaryInRange :many
SELECT
vg.name AS game_name,
COUNT(vgh.id) AS number_of_bets,
COALESCE(SUM(vgh.amount), 0) AS total_transaction_sum
FROM virtual_game_histories vgh
JOIN virtual_games vg ON vgh.game_id = vg.id
WHERE vgh.transaction_type = 'BET'
AND vgh.created_at BETWEEN $1 AND $2
GROUP BY vg.name;

View File

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

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": {
"get": {
"security": [
@ -5193,6 +5310,36 @@ const docTemplate = `{
}
}
},
"domain.LogEntry": {
"type": "object",
"properties": {
"caller": {
"type": "string"
},
"env": {
"type": "string"
},
"fields": {
"type": "object",
"additionalProperties": true
},
"level": {
"type": "string"
},
"message": {
"type": "string"
},
"service": {
"type": "string"
},
"stacktrace": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"domain.Odd": {
"type": "object",
"properties": {

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": {
"get": {
"security": [
@ -5185,6 +5302,36 @@
}
}
},
"domain.LogEntry": {
"type": "object",
"properties": {
"caller": {
"type": "string"
},
"env": {
"type": "string"
},
"fields": {
"type": "object",
"additionalProperties": true
},
"level": {
"type": "string"
},
"message": {
"type": "string"
},
"service": {
"type": "string"
},
"stacktrace": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"domain.Odd": {
"type": "object",
"properties": {

View File

@ -395,6 +395,26 @@ definitions:
example: 1
type: integer
type: object
domain.LogEntry:
properties:
caller:
type: string
env:
type: string
fields:
additionalProperties: true
type: object
level:
type: string
message:
type: string
service:
type: string
stacktrace:
type: string
timestamp:
type: string
type: object
domain.Odd:
properties:
category:
@ -1991,6 +2011,81 @@ paths:
summary: Convert currency
tags:
- Multi-Currency
/api/v1/logs:
get:
description: Fetches the 100 most recent application logs from MongoDB
produces:
- application/json
responses:
"200":
description: List of application logs
schema:
items:
$ref: '#/definitions/domain.LogEntry'
type: array
"500":
description: Internal server error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Retrieve latest application logs
tags:
- Logs
/api/v1/report-files/download/{filename}:
get:
description: Downloads a generated report CSV file from the server
parameters:
- description: Name of the report file to download (e.g., report_daily_2025-06-21.csv)
in: path
name: filename
required: true
type: string
produces:
- text/csv
responses:
"200":
description: CSV file will be downloaded
schema:
type: file
"400":
description: Missing or invalid filename
schema:
$ref: '#/definitions/domain.ErrorResponse'
"404":
description: Report file not found
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal server error while serving the file
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Download a CSV report file
tags:
- Reports
/api/v1/report-files/list:
get:
description: Returns a list of all generated report CSV files available for
download
produces:
- application/json
responses:
"200":
description: List of CSV report filenames
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
items:
type: string
type: array
type: object
"500":
description: Failed to read report directory
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: List available report CSV files
tags:
- Reports
/api/v1/reports/dashboard:
get:
consumes:

View File

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

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
}
const GetAllTicketsInRange = `-- name: GetAllTicketsInRange :one
SELECT COUNT(*) as total_tickets, COALESCE(SUM(amount), 0) as total_amount
FROM tickets
WHERE created_at BETWEEN $1 AND $2
`
type GetAllTicketsInRangeParams struct {
CreatedAt pgtype.Timestamp `json:"created_at"`
CreatedAt_2 pgtype.Timestamp `json:"created_at_2"`
}
type GetAllTicketsInRangeRow struct {
TotalTickets int64 `json:"total_tickets"`
TotalAmount interface{} `json:"total_amount"`
}
func (q *Queries) GetAllTicketsInRange(ctx context.Context, arg GetAllTicketsInRangeParams) (GetAllTicketsInRangeRow, error) {
row := q.db.QueryRow(ctx, GetAllTicketsInRange, arg.CreatedAt, arg.CreatedAt_2)
var i GetAllTicketsInRangeRow
err := row.Scan(&i.TotalTickets, &i.TotalAmount)
return i, err
}
const GetTicketByID = `-- name: GetTicketByID :one
SELECT id, amount, total_odds, ip, created_at, updated_at, outcomes
FROM ticket_with_outcomes

View File

@ -34,7 +34,7 @@ type CreateTransferParams struct {
SenderWalletID pgtype.Int8 `json:"sender_wallet_id"`
CashierID pgtype.Int8 `json:"cashier_id"`
Verified pgtype.Bool `json:"verified"`
ReferenceNumber pgtype.Text `json:"reference_number"`
ReferenceNumber string `json:"reference_number"`
Status pgtype.Text `json:"status"`
PaymentMethod pgtype.Text `json:"payment_method"`
}
@ -139,7 +139,7 @@ FROM wallet_transfer
WHERE reference_number = $1
`
func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber pgtype.Text) (WalletTransfer, error) {
func (q *Queries) GetTransferByReference(ctx context.Context, referenceNumber string) (WalletTransfer, error) {
row := q.db.QueryRow(ctx, GetTransferByReference, referenceNumber)
var i WalletTransfer
err := row.Scan(
@ -199,6 +199,44 @@ func (q *Queries) GetTransfersByWallet(ctx context.Context, receiverWalletID pgt
return items, nil
}
const GetWalletTransactionsInRange = `-- name: GetWalletTransactionsInRange :many
SELECT type, COUNT(*) as count, SUM(amount) as total_amount
FROM wallet_transfer
WHERE created_at BETWEEN $1 AND $2
GROUP BY type
`
type GetWalletTransactionsInRangeParams struct {
CreatedAt pgtype.Timestamp `json:"created_at"`
CreatedAt_2 pgtype.Timestamp `json:"created_at_2"`
}
type GetWalletTransactionsInRangeRow struct {
Type pgtype.Text `json:"type"`
Count int64 `json:"count"`
TotalAmount int64 `json:"total_amount"`
}
func (q *Queries) GetWalletTransactionsInRange(ctx context.Context, arg GetWalletTransactionsInRangeParams) ([]GetWalletTransactionsInRangeRow, error) {
rows, err := q.db.Query(ctx, GetWalletTransactionsInRange, arg.CreatedAt, arg.CreatedAt_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWalletTransactionsInRangeRow
for rows.Next() {
var i GetWalletTransactionsInRangeRow
if err := rows.Scan(&i.Type, &i.Count, &i.TotalAmount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateTransferStatus = `-- name: UpdateTransferStatus :exec
UPDATE wallet_transfer
SET status = $1,

View File

@ -197,6 +197,49 @@ func (q *Queries) GetVirtualGameSessionByToken(ctx context.Context, sessionToken
return i, err
}
const GetVirtualGameSummaryInRange = `-- name: GetVirtualGameSummaryInRange :many
SELECT
vg.name AS game_name,
COUNT(vgh.id) AS number_of_bets,
COALESCE(SUM(vgh.amount), 0) AS total_transaction_sum
FROM virtual_game_histories vgh
JOIN virtual_games vg ON vgh.game_id = vg.id
WHERE vgh.transaction_type = 'BET'
AND vgh.created_at BETWEEN $1 AND $2
GROUP BY vg.name
`
type GetVirtualGameSummaryInRangeParams struct {
CreatedAt pgtype.Timestamp `json:"created_at"`
CreatedAt_2 pgtype.Timestamp `json:"created_at_2"`
}
type GetVirtualGameSummaryInRangeRow struct {
GameName string `json:"game_name"`
NumberOfBets int64 `json:"number_of_bets"`
TotalTransactionSum interface{} `json:"total_transaction_sum"`
}
func (q *Queries) GetVirtualGameSummaryInRange(ctx context.Context, arg GetVirtualGameSummaryInRangeParams) ([]GetVirtualGameSummaryInRangeRow, error) {
rows, err := q.db.Query(ctx, GetVirtualGameSummaryInRange, arg.CreatedAt, arg.CreatedAt_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetVirtualGameSummaryInRangeRow
for rows.Next() {
var i GetVirtualGameSummaryInRangeRow
if err := rows.Scan(&i.GameName, &i.NumberOfBets, &i.TotalTransactionSum); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetVirtualGameTransactionByExternalID = `-- name: GetVirtualGameTransactionByExternalID :one
SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at
FROM virtual_game_transactions

3
go.mod
View File

@ -83,7 +83,10 @@ require (
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/redis/go-redis/v9 v9.10.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
)

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.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
@ -23,6 +25,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@ -125,6 +129,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/resend/resend-go/v2 v2.20.0 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM=
github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

View File

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

View File

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

View File

@ -10,6 +10,37 @@ const (
Monthly TimeFrame = "monthly"
)
type ReportFrequency string
const (
ReportDaily ReportFrequency = "daily"
ReportWeekly ReportFrequency = "weekly"
ReportMonthly ReportFrequency = "monthly"
)
type ReportRequest struct {
Frequency ReportFrequency
StartDate time.Time
EndDate time.Time
}
type ReportData struct {
TotalBets int64
TotalCashIn float64
TotalCashOut float64
CashBacks float64
Withdrawals float64
Deposits float64
TotalTickets int64
VirtualGameStats []VirtualGameStat
}
type VirtualGameStat struct {
GameName string
NumBets int64
TotalTransaction float64
}
type Report struct {
ID string
TimeFrame TimeFrame
@ -22,6 +53,22 @@ type Report struct {
GeneratedAt time.Time
}
type LiveMetric struct {
TotalCashSportsbook float64
TotalCashSportGames float64
TotalLiveTickets int64
TotalUnsettledCash float64
TotalGames int64
}
type MetricUpdates struct {
TotalCashSportsbookDelta *float64
TotalCashSportGamesDelta *float64
TotalLiveTicketsDelta *int64
TotalUnsettledCashDelta *float64
TotalGamesDelta *int64
}
type DashboardSummary struct {
TotalStakes Currency `json:"total_stakes"`
TotalBets int64 `json:"total_bets"`

View File

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

View File

@ -2,15 +2,26 @@ package repository
import (
"context"
"fmt"
"time"
dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/jackc/pgx/v5/pgtype"
)
type ReportRepository interface {
GenerateReport(timeFrame domain.TimeFrame, start, end time.Time) (*domain.Report, error)
SaveReport(report *domain.Report) error
FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit int) ([]*domain.Report, error)
GetTotalCashOutInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error)
GetTotalCashMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error)
GetTotalCashBacksInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error)
GetTotalBetsMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (int64, error)
GetVirtualGameSummaryInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetVirtualGameSummaryInRangeRow, error)
GetAllTicketsInRange(ctx context.Context, from, to time.Time) (dbgen.GetAllTicketsInRangeRow, error)
GetWalletTransactionsInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetWalletTransactionsInRangeRow, error)
}
type ReportRepo struct {
@ -105,3 +116,105 @@ func (r *ReportRepo) FindReportsByTimeFrame(timeFrame domain.TimeFrame, limit in
return reports, nil
}
func (r *ReportRepo) GetTotalBetsMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (int64, error) {
params := dbgen.GetTotalBetsMadeInRangeParams{
From: ToPgTimestamp(from),
To: ToPgTimestamp(to),
CompanyID: ToPgInt8(companyID),
}
return r.store.queries.GetTotalBetsMadeInRange(ctx, params)
}
func (r *ReportRepo) GetTotalCashBacksInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) {
params := dbgen.GetTotalCashBacksInRangeParams{
From: ToPgTimestamp(from),
To: ToPgTimestamp(to),
CompanyID: ToPgInt8(companyID),
}
value, err := r.store.queries.GetTotalCashBacksInRange(ctx, params)
if err != nil {
return 0, err
}
return parseFloat(value)
}
func (r *ReportRepo) GetTotalCashMadeInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) {
params := dbgen.GetTotalCashMadeInRangeParams{
From: ToPgTimestamp(from),
To: ToPgTimestamp(to),
CompanyID: ToPgInt8(companyID),
}
value, err := r.store.queries.GetTotalCashMadeInRange(ctx, params)
if err != nil {
return 0, err
}
return parseFloat(value)
}
func (r *ReportRepo) GetTotalCashOutInRange(ctx context.Context, from, to time.Time, companyID int64) (float64, error) {
params := dbgen.GetTotalCashOutInRangeParams{
From: ToPgTimestamp(from),
To: ToPgTimestamp(to),
CompanyID: ToPgInt8(companyID),
}
value, err := r.store.queries.GetTotalCashOutInRange(ctx, params)
if err != nil {
return 0, err
}
return parseFloat(value)
}
func (r *ReportRepo) GetWalletTransactionsInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetWalletTransactionsInRangeRow, error) {
params := dbgen.GetWalletTransactionsInRangeParams{
CreatedAt: ToPgTimestamp(from),
CreatedAt_2: ToPgTimestamp(to),
}
return r.store.queries.GetWalletTransactionsInRange(ctx, params)
}
func (r *ReportRepo) GetAllTicketsInRange(ctx context.Context, from, to time.Time) (dbgen.GetAllTicketsInRangeRow, error) {
params := dbgen.GetAllTicketsInRangeParams{
CreatedAt: ToPgTimestamp(from),
CreatedAt_2: ToPgTimestamp(to),
}
return r.store.queries.GetAllTicketsInRange(ctx, params)
}
func (r *ReportRepo) GetVirtualGameSummaryInRange(ctx context.Context, from, to time.Time) ([]dbgen.GetVirtualGameSummaryInRangeRow, error) {
params := dbgen.GetVirtualGameSummaryInRangeParams{
CreatedAt: ToPgTimestamp(from),
CreatedAt_2: ToPgTimestamp(to),
}
return r.store.queries.GetVirtualGameSummaryInRange(ctx, params)
}
func ToPgTimestamp(t time.Time) pgtype.Timestamp {
return pgtype.Timestamp{Time: t, Valid: true}
}
func ToPgInt8(i int64) pgtype.Int8 {
return pgtype.Int8{Int64: i, Valid: true}
}
func parseFloat(value interface{}) (float64, error) {
switch v := value.(type) {
case float64:
return v, nil
case int64:
return float64(v), nil
case pgtype.Numeric:
if !v.Valid {
return 0, nil
}
f, err := v.Float64Value()
if err != nil {
return 0, fmt.Errorf("failed to convert pgtype.Numeric to float64: %w", err)
}
return f.Float64, nil
case nil:
return 0, nil
default:
return 0, fmt.Errorf("unexpected type %T for value: %+v", v, v)
}
}

View File

@ -27,6 +27,10 @@ func convertDBTransfer(transfer dbgen.WalletTransfer) domain.Transfer {
Valid: transfer.CashierID.Valid,
},
PaymentMethod: domain.PaymentMethod(transfer.PaymentMethod.String),
ReferenceNumber: transfer.ReferenceNumber,
Status: transfer.Status.String,
CreatedAt: transfer.CreatedAt.Time,
UpdatedAt: transfer.UpdatedAt.Time,
}
}
@ -46,7 +50,7 @@ func convertCreateTransfer(transfer domain.CreateTransfer) dbgen.CreateTransferP
Int64: transfer.CashierID.Value,
Valid: transfer.CashierID.Valid,
},
ReferenceNumber: pgtype.Text{String: string(transfer.ReferenceNumber), Valid: true},
ReferenceNumber: string(transfer.ReferenceNumber),
PaymentMethod: pgtype.Text{String: string(transfer.PaymentMethod), Valid: true},
}
@ -72,6 +76,7 @@ func (s *Store) GetAllTransfers(ctx context.Context) ([]domain.Transfer, error)
}
return result, nil
}
func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]domain.Transfer, error) {
transfers, err := s.queries.GetTransfersByWallet(ctx, pgtype.Int8{Int64: walletID, Valid: true})
if err != nil {
@ -87,7 +92,7 @@ func (s *Store) GetTransfersByWallet(ctx context.Context, walletID int64) ([]dom
}
func (s *Store) GetTransferByReference(ctx context.Context, reference string) (domain.Transfer, error) {
transfer, err := s.queries.GetTransferByReference(ctx, pgtype.Text{String: reference, Valid: true})
transfer, err := s.queries.GetTransferByReference(ctx, reference)
if err != nil {
return domain.Transfer{}, nil
}

View File

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

View File

@ -2,6 +2,7 @@ package notificationservice
import (
"context"
"encoding/json"
"errors"
"log/slog"
"sync"
@ -14,6 +15,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws"
afro "github.com/amanuelabay/afrosms-go"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
type Service struct {
@ -24,10 +26,15 @@ type Service struct {
stopCh chan struct{}
config *config.Config
logger *slog.Logger
redisClient *redis.Client
}
func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service {
hub := ws.NewNotificationHub()
rdb := redis.NewClient(&redis.Options{
Addr: cfg.RedisAddr, // e.g., “redis:6379”
})
svc := &Service{
repo: repo,
Hub: hub,
@ -36,11 +43,13 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi
notificationCh: make(chan *domain.Notification, 1000),
stopCh: make(chan struct{}),
config: cfg,
redisClient: rdb,
}
go hub.Run()
go svc.startWorker()
go svc.startRetryWorker()
go svc.RunRedisSubscriber(context.Background())
return svc
}
@ -287,3 +296,90 @@ func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int
// func (s *Service) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error){
// return s.repo.Get(ctx, filter)
// }
func (s *Service) RunRedisSubscriber(ctx context.Context) {
pubsub := s.redisClient.Subscribe(ctx, "live_metrics")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
var payload domain.LiveMetric
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
s.logger.Error("[NotificationSvc.runRedisSubscriber] failed unmarshal metric", "error", err)
continue
}
// Broadcast via WebSocket Hub
s.Hub.Broadcast <- map[string]interface{}{
"type": "LIVE_METRIC_UPDATE",
"payload": payload,
}
}
}
func (s *Service) UpdateLiveMetrics(ctx context.Context, updates domain.MetricUpdates) error {
const key = "live_metrics"
val, err := s.redisClient.Get(ctx, key).Result()
var metric domain.LiveMetric
if err == redis.Nil {
metric = domain.LiveMetric{}
} else if err != nil {
return err
} else {
if err := json.Unmarshal([]byte(val), &metric); err != nil {
return err
}
}
// Apply increments if provided
if updates.TotalCashSportsbookDelta != nil {
metric.TotalCashSportsbook += *updates.TotalCashSportsbookDelta
}
if updates.TotalCashSportGamesDelta != nil {
metric.TotalCashSportGames += *updates.TotalCashSportGamesDelta
}
if updates.TotalLiveTicketsDelta != nil {
metric.TotalLiveTickets += *updates.TotalLiveTicketsDelta
}
if updates.TotalUnsettledCashDelta != nil {
metric.TotalUnsettledCash += *updates.TotalUnsettledCashDelta
}
if updates.TotalGamesDelta != nil {
metric.TotalGames += *updates.TotalGamesDelta
}
updatedData, err := json.Marshal(metric)
if err != nil {
return err
}
if err := s.redisClient.Set(ctx, key, updatedData, 0).Err(); err != nil {
return err
}
if err := s.redisClient.Publish(ctx, "live_metrics", updatedData).Err(); err != nil {
return err
}
s.logger.Info("[NotificationSvc.UpdateLiveMetrics] Live metrics updated and broadcasted")
return nil
}
func (s *Service) GetLiveMetrics(ctx context.Context) (domain.LiveMetric, error) {
const key = "live_metrics"
var metric domain.LiveMetric
val, err := s.redisClient.Get(ctx, key).Result()
if err == redis.Nil {
// Key does not exist yet, return zero-valued struct
return domain.LiveMetric{}, nil
} else if err != nil {
return domain.LiveMetric{}, err
}
if err := json.Unmarshal([]byte(val), &metric); err != nil {
return domain.LiveMetric{}, err
}
return metric, nil
}

View File

@ -2,9 +2,14 @@ package report
import (
"context"
"encoding/csv"
"errors"
"fmt"
"log/slog"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -454,34 +459,144 @@ func (s *Service) GetSportPerformance(ctx context.Context, filter domain.ReportF
return performances, nil
}
func (s *Service) GenerateReport(timeFrame domain.TimeFrame) (*domain.Report, error) {
now := time.Now()
var start, end time.Time
switch timeFrame {
case domain.Daily:
start = now.AddDate(0, 0, -1)
end = now
case domain.Weekly:
start = now.AddDate(0, 0, -7)
end = now
case domain.Monthly:
start = now.AddDate(0, -1, 0)
end = now
}
report, err := s.repo.GenerateReport(timeFrame, start, end)
func (s *Service) GenerateReport(ctx context.Context, period string) error {
data, err := s.fetchReportData(ctx, period)
if err != nil {
return nil, err
return fmt.Errorf("fetch data: %w", err)
}
if err := s.repo.SaveReport(report); err != nil {
return nil, err
filePath := fmt.Sprintf("/host-desktop/report_%s_%s.csv", period, time.Now().Format("2006-01-02_15-04"))
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Summary section
writer.Write([]string{"Period", "Total Bets", "Total Cash Made", "Total Cash Out", "Total Cash Backs", "Total Deposits", "Total Withdrawals", "Total Tickets"})
writer.Write([]string{
period,
fmt.Sprintf("%d", data.TotalBets),
fmt.Sprintf("%.2f", data.TotalCashIn),
fmt.Sprintf("%.2f", data.TotalCashOut),
fmt.Sprintf("%.2f", data.CashBacks),
fmt.Sprintf("%.2f", data.Deposits),
fmt.Sprintf("%.2f", data.Withdrawals),
fmt.Sprintf("%d", data.TotalTickets),
})
writer.Write([]string{}) // Empty line for spacing
// Virtual Game Summary section
writer.Write([]string{"Game Name", "Number of Bets", "Total Transaction Sum"})
for _, row := range data.VirtualGameStats {
writer.Write([]string{
row.GameName,
fmt.Sprintf("%d", row.NumBets),
fmt.Sprintf("%.2f", row.TotalTransaction),
})
}
return report, nil
return nil
}
func (s *Service) fetchReportData(ctx context.Context, period string) (domain.ReportData, error) {
from, to := getTimeRange(period)
companyID := int64(0)
// Basic metrics
totalBets, _ := s.repo.GetTotalBetsMadeInRange(ctx, from, to, companyID)
cashIn, _ := s.repo.GetTotalCashMadeInRange(ctx, from, to, companyID)
cashOut, _ := s.repo.GetTotalCashOutInRange(ctx, from, to, companyID)
cashBacks, _ := s.repo.GetTotalCashBacksInRange(ctx, from, to, companyID)
// Wallet Transactions
transactions, _ := s.repo.GetWalletTransactionsInRange(ctx, from, to)
var totalDeposits, totalWithdrawals float64
for _, tx := range transactions {
switch strings.ToLower(tx.Type.String) {
case "deposit":
totalDeposits += float64(tx.TotalAmount)
case "withdraw":
totalWithdrawals += float64(tx.TotalAmount)
}
}
// Ticket Count
totalTickets, _ := s.repo.GetAllTicketsInRange(ctx, from, to)
// Virtual Game Summary
virtualGameStats, _ := s.repo.GetVirtualGameSummaryInRange(ctx, from, to)
// Convert []dbgen.GetVirtualGameSummaryInRangeRow to []domain.VirtualGameStat
var virtualGameStatsDomain []domain.VirtualGameStat
for _, row := range virtualGameStats {
var totalTransaction float64
switch v := row.TotalTransactionSum.(type) {
case string:
val, err := strconv.ParseFloat(v, 64)
if err == nil {
totalTransaction = val
}
case float64:
totalTransaction = v
case int:
totalTransaction = float64(v)
default:
totalTransaction = 0
}
virtualGameStatsDomain = append(virtualGameStatsDomain, domain.VirtualGameStat{
GameName: row.GameName,
NumBets: row.NumberOfBets,
TotalTransaction: totalTransaction,
})
}
return domain.ReportData{
TotalBets: totalBets,
TotalCashIn: cashIn,
TotalCashOut: cashOut,
CashBacks: cashBacks,
Deposits: totalDeposits,
Withdrawals: totalWithdrawals,
TotalTickets: totalTickets.TotalTickets,
VirtualGameStats: virtualGameStatsDomain,
}, nil
}
func getTimeRange(period string) (time.Time, time.Time) {
now := time.Now()
switch strings.ToLower(period) {
case "daily":
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.Add(5 * time.Minute)
return start, end
case "weekly":
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start := now.AddDate(0, 0, -weekday+1)
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 7)
return start, end
case "monthly":
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 1, 0)
return start, end
default:
// Default to daily
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start, end
}
}
// func (s *Service) GetCompanyPerformance(ctx context.Context, filter domain.ReportFilter) ([]domain.CompanyPerformance, error) {
// // Get company bet activity
// companyBets, err := s.betStore.GetCompanyBetActivity(ctx, filter)

View File

@ -9,6 +9,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"go.uber.org/zap"
)
@ -32,6 +33,7 @@ type Service struct {
eventSvc event.Service
prematchSvc odds.ServiceImpl
mongoLogger *zap.Logger
notificationSvc *notificationservice.Service
}
func NewService(
@ -39,12 +41,14 @@ func NewService(
eventSvc event.Service,
prematchSvc odds.ServiceImpl,
mongoLogger *zap.Logger,
notificationSvc *notificationservice.Service,
) *Service {
return &Service{
ticketStore: ticketStore,
eventSvc: eventSvc,
prematchSvc: prematchSvc,
mongoLogger: mongoLogger,
notificationSvc: notificationSvc,
}
}
@ -222,6 +226,14 @@ func (s *Service) CreateTicket(ctx context.Context, req domain.CreateTicketReq,
return domain.Ticket{}, rows, err
}
updates := domain.MetricUpdates{
TotalLiveTicketsDelta: domain.PtrInt64(1),
}
if err := s.notificationSvc.UpdateLiveMetrics(ctx, updates); err != nil {
// handle error
}
return ticket, rows, nil
}

View File

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

View File

@ -119,7 +119,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: "Wallet has been deducted",
Message: fmt.Sprintf(`ETB %d has been transferred from your wallet`),
Message: fmt.Sprintf(`%s %d has been transferred from your wallet`,senderWallet.Currency, amount),
},
Priority: 2,
Metadata: []byte(fmt.Sprintf(`{
@ -148,7 +148,7 @@ func (s *Service) SendTransferNotification(ctx context.Context, senderWallet dom
DeliveryChannel: domain.DeliveryChannelInApp,
Payload: domain.NotificationPayload{
Headline: "Wallet has been credited",
Message: fmt.Sprintf(`ETB %d has been transferred to your wallet`),
Message: fmt.Sprintf(`%s %d has been transferred to your wallet`,receiverWallet.Currency, amount),
},
Priority: 2,
Metadata: []byte(fmt.Sprintf(`{

View File

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

View File

@ -8,37 +8,14 @@ import (
// "time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/report"
resultsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/worker"
"github.com/go-co-op/gocron"
"github.com/robfig/cron/v3"
)
func SetupReportCronJob(reportWorker *worker.ReportWorker) {
s := gocron.NewScheduler(time.UTC)
// Daily at midnight
_, _ = s.Every(1).Day().At("00:00").Do(func() {
_ = reportWorker.GenerateAndExport(domain.Daily)
})
// Weekly on Sunday at 00:05
_, _ = s.Every(1).Week().Sunday().At("00:05").Do(func() {
_ = reportWorker.GenerateAndExport(domain.Weekly)
})
// Monthly on 1st at 00:10
_, _ = s.Every(1).Month(1).At("00:10").Do(func() {
_ = reportWorker.GenerateAndExport(domain.Monthly)
})
s.StartAsync()
}
func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.ServiceImpl, resultService *resultsvc.Service) {
c := cron.New(cron.WithSeconds())
@ -128,3 +105,46 @@ func StartTicketCrons(ticketService ticket.Service) {
c.Start()
log.Println("Cron jobs started for ticket service")
}
func SetupReportCronJobs(ctx context.Context, reportService *report.Service) {
c := cron.New(cron.WithSeconds()) // use WithSeconds for tighter intervals during testing
schedule := []struct {
spec string
period string
}{
{
spec: "*/300 * * * * *", // Every 5 minutes (300 seconds)
period: "5min",
},
{
spec: "0 0 0 * * *", // Daily at midnight
period: "daily",
},
{
spec: "0 0 1 * * 0", // Weekly: Sunday at 1 AM
period: "weekly",
},
{
spec: "0 0 2 1 * *", // Monthly: 1st day of month at 2 AM
period: "monthly",
},
}
for _, job := range schedule {
period := job.period
if _, err := c.AddFunc(job.spec, func() {
log.Printf("Running %s report at %s", period, time.Now().Format(time.RFC3339))
if err := reportService.GenerateReport(ctx, period); err != nil {
log.Printf("Error generating %s report: %v", period, err)
} else {
log.Printf("Successfully generated %s report", period)
}
}); err != nil {
log.Fatalf("Failed to schedule %s report cron job: %v", period, err)
}
}
c.Start()
log.Println("Cron jobs started for report generation service")
}

View File

@ -1,7 +1,6 @@
package handlers
import (
"log/slog"
"strconv"
"time"
@ -10,6 +9,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
type CreateAdminReq struct {
@ -38,15 +38,24 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
var req CreateAdminReq
if err := c.BodyParser(&req); err != nil {
h.logger.Error("RegisterUser failed", "error", err)
h.mongoLoggerSvc.Error("failed to parse CreateAdmin request",
zap.Int64("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil)
}
valErrs, ok := h.validator.Validate(c, req)
if !ok {
h.mongoLoggerSvc.Error("validation failed for CreateAdmin request",
zap.Int64("status_code", fiber.StatusBadRequest),
zap.Any("validation_errors", valErrs),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
}
// Admins can be created without company ids and can be assigned later
if req.CompanyID == nil {
companyID = domain.ValidInt64{
Value: 0,
@ -55,7 +64,12 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
} else {
_, err := h.companySvc.GetCompanyByID(c.Context(), *req.CompanyID)
if err != nil {
h.logger.Error("CreateAdmin company id is invalid", "error", err)
h.mongoLoggerSvc.Error("invalid company ID for CreateAdmin",
zap.Int64("status_code", fiber.StatusInternalServerError),
zap.Int64("company_id", *req.CompanyID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Company ID is invalid", nil, nil)
}
companyID = domain.ValidInt64{
@ -74,10 +88,14 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
CompanyID: companyID,
}
h.logger.Info("CreateAdmin", slog.Bool("company id", req.CompanyID == nil))
newUser, err := h.userSvc.CreateUser(c.Context(), user, true)
if err != nil {
h.logger.Error("CreateAdmin failed", "error", err)
h.mongoLoggerSvc.Error("failed to create admin user",
zap.Int64("status_code", fiber.StatusInternalServerError),
zap.Any("request", req),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to create admin", nil, nil)
}
@ -87,11 +105,23 @@ func (h *Handler) CreateAdmin(c *fiber.Ctx) error {
AdminID: &newUser.ID,
})
if err != nil {
h.logger.Error("CreateAdmin failed to update company", "error", err)
h.mongoLoggerSvc.Error("failed to update company with new admin",
zap.Int64("status_code", fiber.StatusInternalServerError),
zap.Int64("company_id", *req.CompanyID),
zap.Int64("admin_id", newUser.ID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil)
}
}
h.mongoLoggerSvc.Info("admin created successfully",
zap.Int64("admin_id", newUser.ID),
zap.String("email", newUser.Email),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Admin created successfully", nil, nil)
}
@ -125,7 +155,6 @@ type AdminRes struct {
// @Failure 500 {object} response.APIResponse
// @Router /admin [get]
func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
filter := user.Filter{
Role: string(domain.RoleAdmin),
CompanyID: domain.ValidInt64{
@ -141,27 +170,45 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
Valid: true,
},
}
valErrs, ok := h.validator.Validate(c, filter)
if !ok {
h.mongoLoggerSvc.Error("invalid filter values in GetAllAdmins request",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Any("validation_errors", valErrs),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
}
admins, total, err := h.userSvc.GetAllUsers(c.Context(), filter)
if err != nil {
h.logger.Error("GetAllAdmins failed", "error", err)
h.mongoLoggerSvc.Error("failed to get admins from user service",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Any("filter", filter),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get Admins", nil, nil)
}
var result []AdminRes = make([]AdminRes, len(admins))
result := make([]AdminRes, len(admins))
for index, admin := range admins {
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), admin.ID)
if err != nil {
if err == authentication.ErrRefreshTokenNotFound {
lastLogin = &admin.CreatedAt
} else {
h.logger.Error("Failed to get user last login", "userID", admin.ID, "error", err)
h.mongoLoggerSvc.Error("failed to get last login for admin",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("admin_id", admin.ID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login")
}
}
result[index] = AdminRes{
ID: admin.ID,
FirstName: admin.FirstName,
@ -179,6 +226,13 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
}
}
h.mongoLoggerSvc.Info("admins retrieved successfully",
zap.Int("status_code", fiber.StatusOK),
zap.Int("count", len(result)),
zap.Int("page", filter.Page.Value+1),
zap.Time("timestamp", time.Now()),
)
return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page.Value, int(total))
}
@ -195,41 +249,40 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error {
// @Failure 500 {object} response.APIResponse
// @Router /admin/{id} [get]
func (h *Handler) GetAdminByID(c *fiber.Ctx) error {
// branchId := int64(12) //c.Locals("branch_id").(int64)
// filter := user.Filter{
// Role: string(domain.RoleUser),
// BranchId: user.ValidBranchId{
// Value: branchId,
// Valid: true,
// },
// Page: c.QueryInt("page", 1),
// PageSize: c.QueryInt("page_size", 10),
// }
// valErrs, ok := validator.Validate(c, filter)
// if !ok {
// return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
// }
userIDstr := c.Params("id")
userID, err := strconv.ParseInt(userIDstr, 10, 64)
if err != nil {
h.logger.Error("failed to fetch user using UserID", "error", err)
h.mongoLoggerSvc.Error("invalid admin ID param",
zap.Int("status_code", fiber.StatusBadRequest),
zap.String("param", userIDstr),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid admin ID", nil, nil)
}
user, err := h.userSvc.GetUserByID(c.Context(), userID)
if err != nil {
h.logger.Error("Get User By ID failed", "error", err)
h.mongoLoggerSvc.Error("failed to fetch admin by ID",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("admin_id", userID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get admin", nil, nil)
}
lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID)
if err != nil {
if err != authentication.ErrRefreshTokenNotFound {
h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err)
if err != nil && err != authentication.ErrRefreshTokenNotFound {
h.mongoLoggerSvc.Error("failed to get admin last login",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("admin_id", user.ID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login")
}
if err == authentication.ErrRefreshTokenNotFound {
lastLogin = &user.CreatedAt
}
@ -249,7 +302,13 @@ func (h *Handler) GetAdminByID(c *fiber.Ctx) error {
LastLogin: *lastLogin,
}
return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil)
h.mongoLoggerSvc.Info("admin retrieved successfully",
zap.Int("status_code", fiber.StatusOK),
zap.Int64("admin_id", user.ID),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Admin retrieved successfully", res, nil)
}
type updateAdminReq struct {
@ -274,21 +333,36 @@ type updateAdminReq struct {
func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
var req updateAdminReq
if err := c.BodyParser(&req); err != nil {
h.logger.Error("UpdateAdmin failed", "error", err)
h.mongoLoggerSvc.Error("UpdateAdmin failed - invalid request body",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil)
}
valErrs, ok := h.validator.Validate(c, req)
if !ok {
h.mongoLoggerSvc.Error("UpdateAdmin failed - validation errors",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Any("validation_errors", valErrs),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
}
AdminIDStr := c.Params("id")
AdminID, err := strconv.ParseInt(AdminIDStr, 10, 64)
if err != nil {
h.logger.Error("UpdateAdmin failed", "error", err)
h.mongoLoggerSvc.Error("UpdateAdmin failed - invalid Admin ID param",
zap.Int("status_code", fiber.StatusBadRequest),
zap.String("admin_id_param", AdminIDStr),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Admin ID", nil, nil)
}
var companyID domain.ValidInt64
if req.CompanyID != nil {
companyID = domain.ValidInt64{
@ -296,6 +370,7 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
Valid: true,
}
}
err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{
UserId: AdminID,
FirstName: domain.ValidString{
@ -311,23 +386,38 @@ func (h *Handler) UpdateAdmin(c *fiber.Ctx) error {
Valid: true,
},
CompanyID: companyID,
},
)
})
if err != nil {
h.logger.Error("UpdateAdmin failed", "error", err)
h.mongoLoggerSvc.Error("UpdateAdmin failed - user service error",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("admin_id", AdminID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update admin", nil, nil)
}
if req.CompanyID != nil {
_, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{
ID: *req.CompanyID,
AdminID: &AdminID,
})
if err != nil {
h.logger.Error("CreateAdmin failed to update company", "error", err)
h.mongoLoggerSvc.Error("UpdateAdmin failed to update company",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("admin_id", AdminID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil)
}
}
return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil)
h.mongoLoggerSvc.Info("UpdateAdmin succeeded",
zap.Int("status_code", fiber.StatusOK),
zap.Int64("admin_id", AdminID),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil)
}

View File

@ -2,11 +2,13 @@ package handlers
import (
"errors"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// loginCustomerReq represents the request body for the LoginCustomer endpoint.
@ -38,31 +40,56 @@ type loginCustomerRes struct {
func (h *Handler) LoginCustomer(c *fiber.Ctx) error {
var req loginCustomerReq
if err := c.BodyParser(&req); err != nil {
h.logger.Error("Failed to parse LoginCustomer request", "error", err)
h.mongoLoggerSvc.Error("Failed to parse LoginCustomer request",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if _, ok := h.validator.Validate(c, req); !ok {
h.mongoLoggerSvc.Error("LoginCustomer validation failed",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Any("request", req),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid Request")
}
successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password)
if err != nil {
h.logger.Info("Login attempt failed", "email", req.Email, "phone", req.PhoneNumber, "error", err)
h.mongoLoggerSvc.Info("Login attempt failed",
zap.Int("status_code", fiber.StatusUnauthorized),
zap.String("email", req.Email),
zap.String("phone", req.PhoneNumber),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
switch {
case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials")
case errors.Is(err, authentication.ErrUserSuspended):
return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked")
default:
h.logger.Error("Login failed", "error", err)
h.mongoLoggerSvc.Error("Login failed",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
}
accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry)
if err != nil {
h.logger.Error("Failed to create access token", "userID", successRes.UserId, "error", err)
h.mongoLoggerSvc.Error("Failed to create access token",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("user_id", successRes.UserId),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token")
}
@ -71,6 +98,14 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error {
RefreshToken: successRes.RfToken,
Role: string(successRes.Role),
}
h.mongoLoggerSvc.Info("Login successful",
zap.Int("status_code", fiber.StatusOK),
zap.Int64("user_id", successRes.UserId),
zap.String("role", string(successRes.Role)),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil)
}
@ -101,34 +136,65 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error {
var req refreshToken
if err := c.BodyParser(&req); err != nil {
h.logger.Error("Failed to parse RefreshToken request", "error", err)
h.mongoLoggerSvc.Error("Failed to parse RefreshToken request",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
h.mongoLoggerSvc.Error("RefreshToken validation failed",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Any("validation_errors", valErrs),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
}
refreshToken, err := h.authSvc.RefreshToken(c.Context(), req.RefreshToken)
if err != nil {
h.logger.Info("Refresh token attempt failed", "refreshToken", req.RefreshToken, "error", err)
h.mongoLoggerSvc.Info("Refresh token attempt failed",
zap.Int("status_code", fiber.StatusUnauthorized),
zap.String("refresh_token", req.RefreshToken),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
switch {
case errors.Is(err, authentication.ErrExpiredToken):
return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired")
case errors.Is(err, authentication.ErrRefreshTokenNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found")
default:
h.logger.Error("Refresh token failed", "error", err)
h.mongoLoggerSvc.Error("Refresh token failed",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
}
user, err := h.userSvc.GetUserByID(c.Context(), refreshToken.UserID)
if err != nil {
h.mongoLoggerSvc.Error("Failed to get user by ID during refresh",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("user_id", refreshToken.UserID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user information")
}
// Assuming the refreshed token includes userID and role info; adjust if needed
accessToken, err := jwtutil.CreateJwt(user.ID, user.Role, user.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry)
if err != nil {
h.logger.Error("Failed to create new access token", "error", err)
h.mongoLoggerSvc.Error("Failed to create new access token",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Int64("user_id", user.ID),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token")
}
@ -137,6 +203,14 @@ func (h *Handler) RefreshToken(c *fiber.Ctx) error {
RefreshToken: req.RefreshToken,
Role: string(user.Role),
}
h.mongoLoggerSvc.Info("Refresh token successful",
zap.Int("status_code", fiber.StatusOK),
zap.Int64("user_id", user.ID),
zap.String("role", string(user.Role)),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Refresh successful", res, nil)
}
@ -157,30 +231,52 @@ type logoutReq struct {
// @Failure 500 {object} response.APIResponse
// @Router /auth/logout [post]
func (h *Handler) LogOutCustomer(c *fiber.Ctx) error {
var req logoutReq
if err := c.BodyParser(&req); err != nil {
h.logger.Error("Failed to parse LogOutCustomer request", "error", err)
h.mongoLoggerSvc.Error("Failed to parse LogOutCustomer request",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
h.mongoLoggerSvc.Error("LogOutCustomer validation failed",
zap.Int("status_code", fiber.StatusBadRequest),
zap.Any("validation_errors", valErrs),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil)
}
err := h.authSvc.Logout(c.Context(), req.RefreshToken)
if err != nil {
h.logger.Info("Logout attempt failed", "refreshToken", req.RefreshToken, "error", err)
h.mongoLoggerSvc.Info("Logout attempt failed",
zap.Int("status_code", fiber.StatusUnauthorized),
zap.String("refresh_token", req.RefreshToken),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
switch {
case errors.Is(err, authentication.ErrExpiredToken):
return fiber.NewError(fiber.StatusUnauthorized, "The refresh token has expired")
case errors.Is(err, authentication.ErrRefreshTokenNotFound):
return fiber.NewError(fiber.StatusUnauthorized, "Refresh token not found")
default:
h.logger.Error("Logout failed", "error", err)
h.mongoLoggerSvc.Error("Logout failed",
zap.Int("status_code", fiber.StatusInternalServerError),
zap.Error(err),
zap.Time("timestamp", time.Now()),
)
return fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
}
}
h.mongoLoggerSvc.Info("Logout successful",
zap.Int("status_code", fiber.StatusOK),
zap.Time("timestamp", time.Now()),
)
return response.WriteJSON(c, fiber.StatusOK, "Logout successful", nil, nil)
}

View File

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

View File

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

View File

@ -10,9 +10,17 @@ import (
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetLogsHandler godoc
// @Summary Retrieve latest application logs
// @Description Fetches the 100 most recent application logs from MongoDB
// @Tags Logs
// @Produce json
// @Success 200 {array} domain.LogEntry "List of application logs"
// @Failure 500 {object} domain.ErrorResponse "Internal server error"
// @Router /api/v1/logs [get]
func GetLogsHandler(appCtx context.Context) fiber.Handler {
return func(c *fiber.Ctx) error {
client, err := mongo.Connect(appCtx, options.Client().ApplyURI("mongodb://root:secret@localhost:27017/?authSource=admin"))
client, err := mongo.Connect(appCtx, options.Client().ApplyURI("mongodb://root:secret@mongo:27017/?authSource=admin"))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "MongoDB connection failed: "+err.Error())
}

View File

@ -3,7 +3,9 @@ package handlers
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
@ -121,3 +123,81 @@ func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) {
return filter, err
}
// DownloadReportFile godoc
// @Summary Download a CSV report file
// @Description Downloads a generated report CSV file from the server
// @Tags Reports
// @Param filename path string true "Name of the report file to download (e.g., report_daily_2025-06-21.csv)"
// @Produce text/csv
// @Success 200 {file} file "CSV file will be downloaded"
// @Failure 400 {object} domain.ErrorResponse "Missing or invalid filename"
// @Failure 404 {object} domain.ErrorResponse "Report file not found"
// @Failure 500 {object} domain.ErrorResponse "Internal server error while serving the file"
// @Router /api/v1/report-files/download/{filename} [get]
func (h *Handler) DownloadReportFile(c *fiber.Ctx) error {
filename := c.Params("filename")
if filename == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Missing filename parameter",
Error: "filename is required",
})
}
filePath := fmt.Sprintf("/host-desktop/%s", filename)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Report file not found",
Error: "no such file",
})
}
// Set download headers and return file
c.Set("Content-Type", "text/csv")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if err := c.SendFile(filePath); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to serve file",
Error: err.Error(),
})
}
return nil
}
// ListReportFiles godoc
// @Summary List available report CSV files
// @Description Returns a list of all generated report CSV files available for download
// @Tags Reports
// @Produce json
// @Success 200 {object} domain.Response{data=[]string} "List of CSV report filenames"
// @Failure 500 {object} domain.ErrorResponse "Failed to read report directory"
// @Router /api/v1/report-files/list [get]
func (h *Handler) ListReportFiles(c *fiber.Ctx) error {
reportDir := "/host-desktop"
files, err := os.ReadDir(reportDir)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read report directory",
Error: err.Error(),
})
}
var reportFiles []string
for _, file := range files {
if !file.IsDir() && strings.HasSuffix(file.Name(), ".csv") {
reportFiles = append(reportFiles, file.Name())
}
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
StatusCode: 200,
Message: "Report files retrieved successfully",
Data: reportFiles,
Success: true,
})
}

View File

@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"strconv"
"time"
@ -134,7 +135,9 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error {
// Get sender ID from the cashier
userID := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
companyID := c.Locals("company_id").(int64)
companyID := c.Locals("company_id").(domain.ValidInt64)
fmt.Printf("\n\nCompant ID: %v\n\n", companyID.Value)
var senderID int64
@ -143,9 +146,13 @@ func (h *Handler) TransferToWallet(c *fiber.Ctx) error {
h.logger.Error("Unauthorized access", "userID", userID, "role", role)
return response.WriteJSON(c, fiber.StatusUnauthorized, "Unauthorized access", nil, nil)
} else if role == domain.RoleBranchManager || role == domain.RoleAdmin || role == domain.RoleSuperAdmin {
company, err := h.companySvc.GetCompanyByID(c.Context(), companyID)
company, err := h.companySvc.GetCompanyByID(c.Context(), companyID.Value)
if err != nil {
return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching company", err, nil)
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to fetch company",
Error: err.Error(),
})
// return response.WriteJSON(c, fiber.StatusInternalServerError, "Error fetching company", err, nil)
}
senderID = company.WalletID
h.logger.Error("Will", "userID", userID, "role", role)

View File

@ -45,6 +45,7 @@ func (a *App) initAppRoutes() {
a.leagueSvc,
*a.resultSvc,
a.cfg,
a.mongoLoggerSvc,
)
group := a.fiber.Group("/api/v1")
@ -214,6 +215,8 @@ func (a *App) initAppRoutes() {
//Report Routes
group.Get("/reports/dashboard", h.GetDashboardReport)
group.Get("/report-files/download/:filename", a.authMiddleware, a.SuperAdminOnly, h.DownloadReportFile)
group.Get("/report-files/list", a.authMiddleware, a.SuperAdminOnly, h.ListReportFiles)
//Wallet Monitor Service
// group.Get("/debug/wallet-monitor/status", func(c *fiber.Ctx) error {

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