diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a4fd5a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Builder stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -ldflags="-s -w" -o ./bin/web ./cmd/main.go + +# Runner stage +FROM alpine:3.21 AS runner +WORKDIR /app +COPY .env . +COPY --from=builder /app/bin/web /app/bin/web +RUN apk add --no-cache ca-certificates +EXPOSE ${PORT} +CMD ["/app/bin/web"] \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index a474956..90f5dc7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -77,15 +77,14 @@ func main() { userSvc := user.NewService(store, store, mockSms, mockEmail) eventSvc := event.New(cfg.Bet365Token, store) - oddsSvc := odds.New(cfg.Bet365Token, store) - resultSvc := result.NewService(store, cfg, logger) + oddsSvc := odds.New(store, cfg, logger) ticketSvc := ticket.NewService(store) - betSvc := bet.NewService(store) walletSvc := wallet.NewService(store, store) transactionSvc := transaction.NewService(store) branchSvc := branch.NewService(store) companySvc := company.NewService(store) - + betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger) + resultSvc := result.NewService(store, cfg, logger, *betSvc) notificationRepo := repository.NewNotificationRepository(store) referalRepo := repository.NewReferralRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store) @@ -108,6 +107,7 @@ func main() { ) httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc) + httpserver.StartTicketCrons(*ticketSvc) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 3cf6d3e..64f3c2d 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -114,7 +114,6 @@ CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS customer_wallets ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL, - company_id BIGINT NOT NULL, regular_wallet_id BIGINT NOT NULL, static_wallet_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -234,12 +233,17 @@ CREATE TABLE companies ( wallet_id BIGINT NOT NULL ); -- Views -CREATE VIEW companies_with_wallets AS +CREATE VIEW companies_details AS SELECT companies.*, wallets.balance, - wallets.is_active + wallets.is_active, + users.first_name AS admin_first_name, + users.last_name AS admin_last_name, + users.phone_number AS admin_phone_number FROM companies - JOIN wallets ON wallets.id = companies.wallet_id; + JOIN wallets ON wallets.id = companies.wallet_id + JOIN users ON users.id = companies.admin_id; +; CREATE VIEW branch_details AS SELECT branches.*, CONCAT(users.first_name, ' ', users.last_name) AS manager_name, @@ -290,11 +294,11 @@ ALTER TABLE branch_operations ADD CONSTRAINT fk_branch_operations_operations FOREIGN KEY (operation_id) REFERENCES supported_operations(id) ON DELETE CASCADE, ADD CONSTRAINT fk_branch_operations_branches FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE; ALTER TABLE branch_cashiers -ADD CONSTRAINT fk_branch_cashiers_users FOREIGN KEY (user_id) REFERENCES users(id), - ADD CONSTRAINT fk_branch_cashiers_branches FOREIGN KEY (branch_id) REFERENCES branches(id); +ADD CONSTRAINT fk_branch_cashiers_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_branch_cashiers_branches FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE; ALTER TABLE companies ADD CONSTRAINT fk_companies_admin FOREIGN KEY (admin_id) REFERENCES users(id), - ADD CONSTRAINT fk_companies_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id); + ADD CONSTRAINT fk_companies_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE; ----------------------------------------------seed data------------------------------------------------------------- -------------------------------------- DO NOT USE IN PRODUCTION------------------------------------------------- CREATE EXTENSION IF NOT EXISTS pgcrypto; @@ -340,15 +344,43 @@ INSERT INTO users ( suspended_at, suspended ) +VALUES ( + 'Test', + 'Admin', + 'test.admin@gmail.com', + '0988554466', + crypt('password123', gen_salt('bf'))::bytea, + 'admin', + TRUE, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL, + FALSE + ); +INSERT INTO users ( + first_name, + last_name, + email, + phone_number, + password, + role, + email_verified, + phone_verified, + created_at, + updated_at, + suspended_at, + suspended + ) VALUES ( 'Samuel', 'Tariku', 'cybersamt@gmail.com', - NULL, + '0911111111', crypt('password@123', gen_salt('bf'))::bytea, 'super_admin', TRUE, - FALSE, + TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, @@ -372,11 +404,11 @@ VALUES ( 'Kirubel', 'Kibru', 'kirubeljkl679 @gmail.com', - NULL, + '0911554486', crypt('password@123', gen_salt('bf'))::bytea, 'super_admin', TRUE, - FALSE, + TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, @@ -384,8 +416,7 @@ VALUES ( ); INSERT INTO supported_operations (name, description) VALUES ('SportBook', 'Sportbook operations'), - ('Virtual', 'Virtual operations'), - ('GameZone', 'GameZone operations'); + ('Virtual', 'Virtual operations'); INSERT INTO wallets ( balance, is_withdraw, @@ -405,4 +436,54 @@ VALUES ( TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ); +INSERT INTO companies ( + name, + admin_id, + wallet_id + ) +values ( + 'Test Company', + 2, + 1 + ); +INSERT INTO wallets ( + balance, + is_withdraw, + is_bettable, + is_transferable, + user_id, + is_active, + created_at, + updated_at + ) +VALUES ( + 10000, + TRUE, + TRUE, + TRUE, + 2, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ); +INSERT INTO branches ( + name, + location, + wallet_id, + branch_manager_id, + company_id, + is_self_owned, + created_at, + updated_at + ) +values ( + 'Test Branch', + 'Addis Ababa', + 2, + 2, + 1, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/db/query/bet.sql b/db/query/bet.sql index 42db5a7..61a3d02 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -62,21 +62,25 @@ WHERE branch_id = $1; SELECT * FROM bet_outcomes WHERE event_id = $1; - +-- name: GetBetOutcomeByBetID :many +SELECT * +FROM bet_outcomes +WHERE bet_id = $1; -- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1; --- name: UpdateBetOutcomeStatus :exec +-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 -WHERE id = $2; +WHERE id = $2 +RETURNING *; -- name: UpdateStatus :exec UPDATE bets -SET status = $2, +SET status = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = $1; +WHERE id = $2; -- name: DeleteBet :exec DELETE FROM bets WHERE id = $1; diff --git a/db/query/branch.sql b/db/query/branch.sql index 422a612..bb01b26 100644 --- a/db/query/branch.sql +++ b/db/query/branch.sql @@ -55,15 +55,6 @@ SELECT branches.* FROM branch_cashiers JOIN branches ON branch_cashiers.branch_id = branches.id WHERE branch_cashiers.user_id = $1; --- name: GetCashiersByBranch :many -SELECT users.* -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -WHERE branch_cashiers.branch_id = $1; --- name: GetAllCashiers :many -SELECT users.* -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id; -- name: UpdateBranch :one UPDATE branches SET name = COALESCE(sqlc.narg(name), name), diff --git a/db/query/cashier.sql b/db/query/cashier.sql new file mode 100644 index 0000000..dcb8dfb --- /dev/null +++ b/db/query/cashier.sql @@ -0,0 +1,15 @@ +-- name: GetCashiersByBranch :many +SELECT users.* +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +WHERE branch_cashiers.branch_id = $1; +-- name: GetAllCashiers :many +SELECT users.*, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id; +-- name: GetCashierByID :one +SELECT users.*, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = $1; \ No newline at end of file diff --git a/db/query/company.sql b/db/query/company.sql index 35d37c1..3315132 100644 --- a/db/query/company.sql +++ b/db/query/company.sql @@ -8,14 +8,14 @@ VALUES ($1, $2, $3) RETURNING *; -- name: GetAllCompanies :many SELECT * -FROM companies_with_wallets; +FROM companies_details; -- name: GetCompanyByID :one SELECT * -FROM companies_with_wallets +FROM companies_details WHERE id = $1; -- name: SearchCompanyByName :many SELECT * -FROM companies_with_wallets +FROM companies_details WHERE name ILIKE '%' || $1 || '%'; -- name: UpdateCompany :one UPDATE companies diff --git a/db/query/events.sql b/db/query/events.sql index 4109c44..5fb5e46 100644 --- a/db/query/events.sql +++ b/db/query/events.sql @@ -158,9 +158,7 @@ SELECT id, status, fetched_at FROM events -WHERE is_live = false - AND status = 'upcoming' - AND start_time < now() +WHERE start_time < now() ORDER BY start_time ASC; -- name: GetTotalEvents :one SELECT COUNT(*) @@ -168,12 +166,20 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $1 - OR $1 IS NULL + league_id = sqlc.narg('league_id') + OR sqlc.narg('league_id') IS NULL ) AND ( - sport_id = $2 - OR $2 IS NULL + sport_id = sqlc.narg('sport_id') + OR sqlc.narg('sport_id') IS NULL + ) + AND ( + start_time < sqlc.narg('last_start_time') + OR sqlc.narg('last_start_time') IS NULL + ) + AND ( + start_time > sqlc.narg('first_start_time') + OR sqlc.narg('first_start_time') IS NULL ); -- name: GetPaginatedUpcomingEvents :many SELECT id, @@ -196,15 +202,23 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $3 - OR $3 IS NULL + league_id = sqlc.narg('league_id') + OR sqlc.narg('league_id') IS NULL ) AND ( - sport_id = $4 - OR $4 IS NULL + sport_id = sqlc.narg('sport_id') + OR sqlc.narg('sport_id') IS NULL + ) + AND ( + start_time < sqlc.narg('last_start_time') + OR sqlc.narg('last_start_time') IS NULL + ) + AND ( + start_time > sqlc.narg('first_start_time') + OR sqlc.narg('first_start_time') IS NULL ) ORDER BY start_time ASC -LIMIT $1 OFFSET $2; +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetUpcomingByID :one SELECT id, sport_id, diff --git a/db/query/notification.sql b/db/query/notification.sql index 22bae8d..8a1c51f 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -1,21 +1,71 @@ -- name: CreateNotification :one INSERT INTO notifications ( - id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, timestamp, metadata -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 -) RETURNING *; - + id, + recipient_id, + type, + level, + error_severity, + reciever, + is_read, + delivery_status, + delivery_channel, + payload, + priority, + timestamp, + metadata + ) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13 + ) +RETURNING *; -- name: GetNotification :one -SELECT * FROM notifications WHERE id = $1 LIMIT 1; - +SELECT * +FROM notifications +WHERE id = $1 +LIMIT 1; +-- name: GetAllNotifications :many +SELECT * +FROM notifications +ORDER BY timestamp DESC +LIMIT $1 OFFSET $2; -- name: ListNotifications :many -SELECT * FROM notifications WHERE recipient_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3; - +SELECT * +FROM notifications +WHERE recipient_id = $1 +ORDER BY timestamp DESC +LIMIT $2 OFFSET $3; +-- name: CountUnreadNotifications :one +SELECT count(id) +FROM notifications +WHERE recipient_id = $1 + AND is_read = false; -- name: UpdateNotificationStatus :one -UPDATE notifications SET delivery_status = $2, is_read = $3, metadata = $4 WHERE id = $1 RETURNING *; - +UPDATE notifications +SET delivery_status = $2, + is_read = $3, + metadata = $4 +WHERE id = $1 +RETURNING *; -- name: ListFailedNotifications :many -SELECT * FROM notifications WHERE delivery_status = 'failed' AND timestamp < NOW() - INTERVAL '1 hour' ORDER BY timestamp ASC LIMIT $1; - +SELECT * +FROM notifications +WHERE delivery_status = 'failed' + AND timestamp < NOW() - INTERVAL '1 hour' +ORDER BY timestamp ASC +LIMIT $1; -- name: ListRecipientIDsByReceiver :many -SELECT recipient_id FROM notifications WHERE reciever = $1; +SELECT recipient_id +FROM notifications +WHERE reciever = $1; \ No newline at end of file diff --git a/db/query/odds.sql b/db/query/odds.sql index 908a445..9de17b3 100644 --- a/db/query/odds.sql +++ b/db/query/odds.sql @@ -94,23 +94,17 @@ WHERE market_id = $1 AND fi = $2 AND is_active = true AND source = 'b365api'; - -- name: GetPrematchOddsByUpcomingID :many -SELECT o.event_id, - o.fi, - o.market_type, - o.market_name, - o.market_category, - o.market_id, - o.name, - o.handicap, - o.odds_value, - o.section, - o.category, - o.raw_odds, - o.fetched_at, - o.source, - o.is_active +SELECT o.* +FROM odds o + JOIN events e ON o.fi = e.id +WHERE e.id = $1 + AND e.is_live = false + AND e.status = 'upcoming' + AND o.is_active = true + AND o.source = 'b365api'; +-- name: GetPaginatedPrematchOddsByUpcomingID :many +SELECT o.* FROM odds o JOIN events e ON o.fi = e.id WHERE e.id = $1 @@ -118,4 +112,4 @@ WHERE e.id = $1 AND e.status = 'upcoming' AND o.is_active = true AND o.source = 'b365api' -LIMIT $2 OFFSET $3; \ No newline at end of file +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index c5799e8..91ddccb 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -66,7 +66,7 @@ wHERE ( company_id = $2 OR $2 IS NULL ) -LIMIT $3 OFFSET $4; +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetTotalUsers :one SELECT COUNT(*) FROM users @@ -93,23 +93,30 @@ SELECT id, suspended_at, company_id FROM users -WHERE first_name ILIKE '%' || $1 || '%' - OR last_name ILIKE '%' || $1 || '%' - OR phone_number LIKE '%' || $1 || '%'; +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR phone_number LIKE '%' || $1 || '%' + ) + AND ( + role = sqlc.narg('role') + OR sqlc.narg('role') IS NULL + ) + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL + ); -- name: UpdateUser :exec UPDATE users SET first_name = $1, last_name = $2, - email = $3, - phone_number = $4, - role = $5, - updated_at = $6 -WHERE id = $7; + suspended = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $4; -- name: UpdateUserCompany :exec UPDATE users SET company_id = $1 WHERE id = $2; - -- name: SuspendUser :exec UPDATE users SET suspended = $1, diff --git a/db/query/wallet.sql b/db/query/wallet.sql index 0f4d27d..e825653 100644 --- a/db/query/wallet.sql +++ b/db/query/wallet.sql @@ -10,11 +10,10 @@ RETURNING *; -- name: CreateCustomerWallet :one INSERT INTO customer_wallets ( customer_id, - company_id, regular_wallet_id, static_wallet_id ) -VALUES ($1, $2, $3, $4) +VALUES ($1, $2, $3) RETURNING *; -- name: GetAllWallets :many SELECT * @@ -30,7 +29,6 @@ WHERE user_id = $1; -- name: GetCustomerWallet :one SELECT cw.id, cw.customer_id, - cw.company_id, rw.id AS regular_id, rw.balance AS regular_balance, sw.id AS static_id, @@ -41,8 +39,7 @@ SELECT cw.id, FROM customer_wallets cw JOIN wallets rw ON cw.regular_wallet_id = rw.id JOIN wallets sw ON cw.static_wallet_id = sw.id -WHERE cw.customer_id = $1 - AND cw.company_id = $2; +WHERE cw.customer_id = $1; -- name: GetAllBranchWallets :many SELECT wallets.id, wallets.balance, diff --git a/compose.db.yaml b/docker-compose.yml similarity index 53% rename from compose.db.yaml rename to docker-compose.yml index 72ab8a7..e1f077d 100644 --- a/compose.db.yaml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: interval: 5s timeout: 3s retries: 5 + volumes: + - postgres_data:/var/lib/postgresql/data + migrate: image: migrate/migrate volumes: @@ -32,6 +35,37 @@ services: networks: - app + app: + build: + context: . + dockerfile: Dockerfile + target: runner + ports: + - ${PORT}:8080 + environment: + - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable + depends_on: + migrate: + condition: service_completed_successfully + networks: + - app + command: ["/app/bin/web"] + + + test: + build: + context: . + dockerfile: Dockerfile + target: builder + volumes: + - .:/app + command: ["tail", "-f", "/dev/null"] + networks: + - app + networks: app: driver: bridge + +volumes: + postgres_data: diff --git a/docs/docs.go b/docs/docs.go index 47695ae..5836188 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -129,6 +129,106 @@ const docTemplate = `{ } } }, + "/admin/{id}": { + "get": { + "description": "Get a single admin by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get admin by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "description": "Update Admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update Admin", + "parameters": [ + { + "description": "Update Admin", + "name": "admin", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateAdminReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/alea-games/launch": { "get": { "security": [ @@ -687,7 +787,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -724,7 +824,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateBetReq" + "$ref": "#/definitions/domain.CreateBetReq" } } ], @@ -732,7 +832,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -776,7 +876,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -820,7 +920,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -1169,7 +1269,54 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/branch/{id}/cashier": { + "get": { + "description": "Gets branch cashiers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "branch" + ], + "summary": "Gets branch cashiers", + "parameters": [ + { + "type": "integer", + "description": "Branch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.GetCashierRes" } } }, @@ -1324,6 +1471,56 @@ const docTemplate = `{ } } }, + "/cashier/{id}": { + "get": { + "description": "Get a single cashier by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cashier" + ], + "summary": "Get cashier by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/cashiers": { "get": { "description": "Get all cashiers", @@ -1456,7 +1653,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateCashierReq" } } ], @@ -1907,6 +2104,54 @@ const docTemplate = `{ } }, "/managers/{id}": { + "get": { + "description": "Get a single manager by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manager" + ], + "summary": "Get manager by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ManagersRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, "put": { "description": "Update Managers", "consumes": [ @@ -1916,7 +2161,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Managers" + "manager" ], "summary": "Update Managers", "parameters": [ @@ -1926,7 +2171,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateManagerReq" } } ], @@ -2041,6 +2286,18 @@ const docTemplate = `{ "description": "Sport ID Filter", "name": "sport_id", "in": "query" + }, + { + "type": "string", + "description": "Start Time", + "name": "first_start_time", + "in": "query" + }, + { + "type": "string", + "description": "End Time", + "name": "last_start_time", + "in": "query" } ], "responses": { @@ -2298,6 +2555,52 @@ const docTemplate = `{ } } }, + "/random/bet": { + "post": { + "description": "Generate a random bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Generate a random bet", + "parameters": [ + { + "description": "Create Random bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RandomBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/referral/settings": { "get": { "security": [ @@ -3093,6 +3396,50 @@ const docTemplate = `{ } } }, + "/user/delete/{id}": { + "delete": { + "description": "Delete a user by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/profile": { "get": { "security": [ @@ -3389,7 +3736,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.APIResponse" + "$ref": "#/definitions/handlers.UserProfileRes" } }, "400": { @@ -3413,6 +3760,52 @@ const docTemplate = `{ } } }, + "/user/suspend": { + "post": { + "description": "Suspend or unsuspend a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Suspend or unsuspend a user", + "parameters": [ + { + "description": "Suspend or unsuspend a user", + "name": "updateUserSuspend", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/wallet": { "get": { "security": [ @@ -3881,6 +4274,65 @@ const docTemplate = `{ } } }, + "domain.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "cashed_id": { + "type": "string", + "example": "21234" + }, + "cashed_out": { + "type": "boolean", + "example": false + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.BetOutcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, "domain.ChapaSupportedBank": { "type": "object", "properties": { @@ -3948,6 +4400,58 @@ const docTemplate = `{ } } }, + "domain.CreateBetOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 1 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateBetOutcomeReq" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + } + } + }, "domain.CreateTransferResponse": { "type": "object", "properties": { @@ -4076,9 +4580,11 @@ const docTemplate = `{ 1, 2, 3, - 4 + 4, + 5 ], "x-enum-comments": { + "OUTCOME_STATUS_ERROR": "Half Win and Half Given Back", "OUTCOME_STATUS_HALF": "Half Win and Half Given Back", "OUTCOME_STATUS_VOID": "Give Back" }, @@ -4087,7 +4593,8 @@ const docTemplate = `{ "OUTCOME_STATUS_WIN", "OUTCOME_STATUS_LOSS", "OUTCOME_STATUS_VOID", - "OUTCOME_STATUS_HALF" + "OUTCOME_STATUS_HALF", + "OUTCOME_STATUS_ERROR" ] }, "domain.PaymentOption": { @@ -4133,6 +4640,23 @@ const docTemplate = `{ } } }, + "domain.RandomBetReq": { + "type": "object", + "required": [ + "branch_id", + "number_of_bets" + ], + "properties": { + "branch_id": { + "type": "integer", + "example": 1 + }, + "number_of_bets": { + "type": "integer", + "example": 1 + } + } + }, "domain.RawOddsByMarketID": { "type": "object", "properties": { @@ -4542,65 +5066,6 @@ const docTemplate = `{ } } }, - "handlers.BetRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 2 - }, - "cashed_id": { - "type": "string", - "example": "21234" - }, - "cashed_out": { - "type": "boolean", - "example": false - }, - "full_name": { - "type": "string", - "example": "John" - }, - "id": { - "type": "integer", - "example": 1 - }, - "is_shop_bet": { - "type": "boolean", - "example": false - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.BetOutcome" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - }, - "total_odds": { - "type": "number", - "example": 4.22 - }, - "user_id": { - "type": "integer", - "example": 2 - } - } - }, "handlers.BranchDetailRes": { "type": "object", "properties": { @@ -4762,58 +5227,6 @@ const docTemplate = `{ } } }, - "handlers.CreateBetOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateBetReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 1 - }, - "full_name": { - "type": "string", - "example": "John" - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateBetOutcomeReq" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - } - } - }, "handlers.CreateBranchOperationReq": { "type": "object", "properties": { @@ -5074,10 +5487,6 @@ const docTemplate = `{ "handlers.CustomerWalletRes": { "type": "object", "properties": { - "company_id": { - "type": "integer", - "example": 1 - }, "created_at": { "type": "string" }, @@ -5113,6 +5522,53 @@ const docTemplate = `{ } } }, + "handlers.GetCashierRes": { + "type": "object", + "properties": { + "branch_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "handlers.ManagersRes": { "type": "object", "properties": { @@ -5245,8 +5701,11 @@ const docTemplate = `{ "handlers.SearchUserByNameOrPhoneReq": { "type": "object", "properties": { - "searchString": { + "query": { "type": "string" + }, + "role": { + "$ref": "#/definitions/domain.Role" } } }, @@ -5463,6 +5922,34 @@ const docTemplate = `{ } } }, + "handlers.UpdateUserSuspendReq": { + "type": "object", + "required": [ + "suspended", + "user_id" + ], + "properties": { + "suspended": { + "type": "boolean", + "example": true + }, + "user_id": { + "type": "integer", + "example": 123 + } + } + }, + "handlers.UpdateUserSuspendRes": { + "type": "object", + "properties": { + "suspended": { + "type": "boolean" + }, + "user_id": { + "type": "integer" + } + } + }, "handlers.UpdateWalletActiveReq": { "type": "object", "required": [ @@ -5655,9 +6142,51 @@ const docTemplate = `{ } } }, - "handlers.updateUserReq": { + "handlers.updateAdminReq": { "type": "object", "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateCashierReq": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateManagerReq": { + "type": "object", + "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, "first_name": { "type": "string", "example": "John" diff --git a/docs/swagger.json b/docs/swagger.json index a0fc42c..15ade76 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -121,6 +121,106 @@ } } }, + "/admin/{id}": { + "get": { + "description": "Get a single admin by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get admin by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, + "put": { + "description": "Update Admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update Admin", + "parameters": [ + { + "description": "Update Admin", + "name": "admin", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateAdminReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/alea-games/launch": { "get": { "security": [ @@ -679,7 +779,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -716,7 +816,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateBetReq" + "$ref": "#/definitions/domain.CreateBetReq" } } ], @@ -724,7 +824,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -768,7 +868,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -812,7 +912,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -1161,7 +1261,54 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/branch/{id}/cashier": { + "get": { + "description": "Gets branch cashiers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "branch" + ], + "summary": "Gets branch cashiers", + "parameters": [ + { + "type": "integer", + "description": "Branch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.GetCashierRes" } } }, @@ -1316,6 +1463,56 @@ } } }, + "/cashier/{id}": { + "get": { + "description": "Get a single cashier by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cashier" + ], + "summary": "Get cashier by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UserProfileRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/cashiers": { "get": { "description": "Get all cashiers", @@ -1448,7 +1645,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateCashierReq" } } ], @@ -1899,6 +2096,54 @@ } }, "/managers/{id}": { + "get": { + "description": "Get a single manager by id", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "manager" + ], + "summary": "Get manager by id", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ManagersRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + }, "put": { "description": "Update Managers", "consumes": [ @@ -1908,7 +2153,7 @@ "application/json" ], "tags": [ - "Managers" + "manager" ], "summary": "Update Managers", "parameters": [ @@ -1918,7 +2163,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateUserReq" + "$ref": "#/definitions/handlers.updateManagerReq" } } ], @@ -2033,6 +2278,18 @@ "description": "Sport ID Filter", "name": "sport_id", "in": "query" + }, + { + "type": "string", + "description": "Start Time", + "name": "first_start_time", + "in": "query" + }, + { + "type": "string", + "description": "End Time", + "name": "last_start_time", + "in": "query" } ], "responses": { @@ -2290,6 +2547,52 @@ } } }, + "/random/bet": { + "post": { + "description": "Generate a random bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Generate a random bet", + "parameters": [ + { + "description": "Create Random bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RandomBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/referral/settings": { "get": { "security": [ @@ -3085,6 +3388,50 @@ } } }, + "/user/delete/{id}": { + "delete": { + "description": "Delete a user by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/profile": { "get": { "security": [ @@ -3381,7 +3728,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.APIResponse" + "$ref": "#/definitions/handlers.UserProfileRes" } }, "400": { @@ -3405,6 +3752,52 @@ } } }, + "/user/suspend": { + "post": { + "description": "Suspend or unsuspend a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Suspend or unsuspend a user", + "parameters": [ + { + "description": "Suspend or unsuspend a user", + "name": "updateUserSuspend", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/wallet": { "get": { "security": [ @@ -3873,6 +4266,65 @@ } } }, + "domain.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "cashed_id": { + "type": "string", + "example": "21234" + }, + "cashed_out": { + "type": "boolean", + "example": false + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.BetOutcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, "domain.ChapaSupportedBank": { "type": "object", "properties": { @@ -3940,6 +4392,58 @@ } } }, + "domain.CreateBetOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 1 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateBetOutcomeReq" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + } + } + }, "domain.CreateTransferResponse": { "type": "object", "properties": { @@ -4068,9 +4572,11 @@ 1, 2, 3, - 4 + 4, + 5 ], "x-enum-comments": { + "OUTCOME_STATUS_ERROR": "Half Win and Half Given Back", "OUTCOME_STATUS_HALF": "Half Win and Half Given Back", "OUTCOME_STATUS_VOID": "Give Back" }, @@ -4079,7 +4585,8 @@ "OUTCOME_STATUS_WIN", "OUTCOME_STATUS_LOSS", "OUTCOME_STATUS_VOID", - "OUTCOME_STATUS_HALF" + "OUTCOME_STATUS_HALF", + "OUTCOME_STATUS_ERROR" ] }, "domain.PaymentOption": { @@ -4125,6 +4632,23 @@ } } }, + "domain.RandomBetReq": { + "type": "object", + "required": [ + "branch_id", + "number_of_bets" + ], + "properties": { + "branch_id": { + "type": "integer", + "example": 1 + }, + "number_of_bets": { + "type": "integer", + "example": 1 + } + } + }, "domain.RawOddsByMarketID": { "type": "object", "properties": { @@ -4534,65 +5058,6 @@ } } }, - "handlers.BetRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 2 - }, - "cashed_id": { - "type": "string", - "example": "21234" - }, - "cashed_out": { - "type": "boolean", - "example": false - }, - "full_name": { - "type": "string", - "example": "John" - }, - "id": { - "type": "integer", - "example": 1 - }, - "is_shop_bet": { - "type": "boolean", - "example": false - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.BetOutcome" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - }, - "total_odds": { - "type": "number", - "example": 4.22 - }, - "user_id": { - "type": "integer", - "example": 2 - } - } - }, "handlers.BranchDetailRes": { "type": "object", "properties": { @@ -4754,58 +5219,6 @@ } } }, - "handlers.CreateBetOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateBetReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 1 - }, - "full_name": { - "type": "string", - "example": "John" - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateBetOutcomeReq" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - } - } - }, "handlers.CreateBranchOperationReq": { "type": "object", "properties": { @@ -5066,10 +5479,6 @@ "handlers.CustomerWalletRes": { "type": "object", "properties": { - "company_id": { - "type": "integer", - "example": 1 - }, "created_at": { "type": "string" }, @@ -5105,6 +5514,53 @@ } } }, + "handlers.GetCashierRes": { + "type": "object", + "properties": { + "branch_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "handlers.ManagersRes": { "type": "object", "properties": { @@ -5237,8 +5693,11 @@ "handlers.SearchUserByNameOrPhoneReq": { "type": "object", "properties": { - "searchString": { + "query": { "type": "string" + }, + "role": { + "$ref": "#/definitions/domain.Role" } } }, @@ -5455,6 +5914,34 @@ } } }, + "handlers.UpdateUserSuspendReq": { + "type": "object", + "required": [ + "suspended", + "user_id" + ], + "properties": { + "suspended": { + "type": "boolean", + "example": true + }, + "user_id": { + "type": "integer", + "example": 123 + } + } + }, + "handlers.UpdateUserSuspendRes": { + "type": "object", + "properties": { + "suspended": { + "type": "boolean" + }, + "user_id": { + "type": "integer" + } + } + }, "handlers.UpdateWalletActiveReq": { "type": "object", "required": [ @@ -5647,9 +6134,51 @@ } } }, - "handlers.updateUserReq": { + "handlers.updateAdminReq": { "type": "object", "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateCashierReq": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "John" + }, + "last_name": { + "type": "string", + "example": "Doe" + }, + "suspended": { + "type": "boolean", + "example": false + } + } + }, + "handlers.updateManagerReq": { + "type": "object", + "properties": { + "company_id": { + "type": "integer", + "example": 1 + }, "first_name": { "type": "string", "example": "John" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2efae74..7ddc40e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -80,6 +80,47 @@ definitions: - $ref: '#/definitions/domain.OutcomeStatus' example: 1 type: object + domain.BetRes: + properties: + amount: + example: 100 + type: number + branch_id: + example: 2 + type: integer + cashed_id: + example: "21234" + type: string + cashed_out: + example: false + type: boolean + full_name: + example: John + type: string + id: + example: 1 + type: integer + is_shop_bet: + example: false + type: boolean + outcomes: + items: + $ref: '#/definitions/domain.BetOutcome' + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.OutcomeStatus' + example: 1 + total_odds: + example: 4.22 + type: number + user_id: + example: 2 + type: integer + type: object domain.ChapaSupportedBank: properties: acct_length: @@ -124,6 +165,41 @@ definitions: message: type: string type: object + domain.CreateBetOutcomeReq: + properties: + event_id: + example: 1 + type: integer + market_id: + example: 1 + type: integer + odd_id: + example: 1 + type: integer + type: object + domain.CreateBetReq: + properties: + amount: + example: 100 + type: number + branch_id: + example: 1 + type: integer + full_name: + example: John + type: string + outcomes: + items: + $ref: '#/definitions/domain.CreateBetOutcomeReq' + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.OutcomeStatus' + example: 1 + type: object domain.CreateTransferResponse: properties: data: @@ -211,8 +287,10 @@ definitions: - 2 - 3 - 4 + - 5 type: integer x-enum-comments: + OUTCOME_STATUS_ERROR: Half Win and Half Given Back OUTCOME_STATUS_HALF: Half Win and Half Given Back OUTCOME_STATUS_VOID: Give Back x-enum-varnames: @@ -221,6 +299,7 @@ definitions: - OUTCOME_STATUS_LOSS - OUTCOME_STATUS_VOID - OUTCOME_STATUS_HALF + - OUTCOME_STATUS_ERROR domain.PaymentOption: enum: - 0 @@ -252,6 +331,18 @@ definitions: description: BET, WIN, REFUND, JACKPOT_WIN type: string type: object + domain.RandomBetReq: + properties: + branch_id: + example: 1 + type: integer + number_of_bets: + example: 1 + type: integer + required: + - branch_id + - number_of_bets + type: object domain.RawOddsByMarketID: properties: fetched_at: @@ -534,47 +625,6 @@ definitions: updated_at: type: string type: object - handlers.BetRes: - properties: - amount: - example: 100 - type: number - branch_id: - example: 2 - type: integer - cashed_id: - example: "21234" - type: string - cashed_out: - example: false - type: boolean - full_name: - example: John - type: string - id: - example: 1 - type: integer - is_shop_bet: - example: false - type: boolean - outcomes: - items: - $ref: '#/definitions/domain.BetOutcome' - type: array - phone_number: - example: "1234567890" - type: string - status: - allOf: - - $ref: '#/definitions/domain.OutcomeStatus' - example: 1 - total_odds: - example: 4.22 - type: number - user_id: - example: 2 - type: integer - type: object handlers.BranchDetailRes: properties: branch_manager_id: @@ -690,41 +740,6 @@ definitions: example: "1234567890" type: string type: object - handlers.CreateBetOutcomeReq: - properties: - event_id: - example: 1 - type: integer - market_id: - example: 1 - type: integer - odd_id: - example: 1 - type: integer - type: object - handlers.CreateBetReq: - properties: - amount: - example: 100 - type: number - branch_id: - example: 1 - type: integer - full_name: - example: John - type: string - outcomes: - items: - $ref: '#/definitions/handlers.CreateBetOutcomeReq' - type: array - phone_number: - example: "1234567890" - type: string - status: - allOf: - - $ref: '#/definitions/domain.OutcomeStatus' - example: 1 - type: object handlers.CreateBranchOperationReq: properties: branch_id: @@ -909,9 +924,6 @@ definitions: type: object handlers.CustomerWalletRes: properties: - company_id: - example: 1 - type: integer created_at: type: string customer_id: @@ -937,6 +949,37 @@ definitions: static_updated_at: type: string type: object + handlers.GetCashierRes: + properties: + branch_id: + type: integer + created_at: + type: string + email: + type: string + email_verified: + type: boolean + first_name: + type: string + id: + type: integer + last_login: + type: string + last_name: + type: string + phone_number: + type: string + phone_verified: + type: boolean + role: + $ref: '#/definitions/domain.Role' + suspended: + type: boolean + suspended_at: + type: string + updated_at: + type: string + type: object handlers.ManagersRes: properties: created_at: @@ -1029,8 +1072,10 @@ definitions: type: object handlers.SearchUserByNameOrPhoneReq: properties: - searchString: + query: type: string + role: + $ref: '#/definitions/domain.Role' type: object handlers.SupportedOperationRes: properties: @@ -1182,6 +1227,25 @@ definitions: example: true type: boolean type: object + handlers.UpdateUserSuspendReq: + properties: + suspended: + example: true + type: boolean + user_id: + example: 123 + type: integer + required: + - suspended + - user_id + type: object + handlers.UpdateUserSuspendRes: + properties: + suspended: + type: boolean + user_id: + type: integer + type: object handlers.UpdateWalletActiveReq: properties: is_active: @@ -1314,8 +1378,38 @@ definitions: - access_token - refresh_token type: object - handlers.updateUserReq: + handlers.updateAdminReq: properties: + company_id: + example: 1 + type: integer + first_name: + example: John + type: string + last_name: + example: Doe + type: string + suspended: + example: false + type: boolean + type: object + handlers.updateCashierReq: + properties: + first_name: + example: John + type: string + last_name: + example: Doe + type: string + suspended: + example: false + type: boolean + type: object + handlers.updateManagerReq: + properties: + company_id: + example: 1 + type: integer first_name: example: John type: string @@ -1431,6 +1525,72 @@ paths: summary: Create Admin tags: - admin + /admin/{id}: + get: + consumes: + - application/json + description: Get a single admin by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get admin by id + tags: + - admin + put: + consumes: + - application/json + description: Update Admin + parameters: + - description: Update Admin + in: body + name: admin + required: true + schema: + $ref: '#/definitions/handlers.updateAdminReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Update Admin + tags: + - admin /api/v1/alea-games/launch: get: consumes: @@ -1791,7 +1951,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' type: array "400": description: Bad Request @@ -1814,14 +1974,14 @@ paths: name: createBet required: true schema: - $ref: '#/definitions/handlers.CreateBetReq' + $ref: '#/definitions/domain.CreateBetReq' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -1878,7 +2038,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -1941,7 +2101,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -2110,7 +2270,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' type: array "400": description: Bad Request @@ -2123,6 +2283,37 @@ paths: summary: Gets bets by its branch id tags: - branch + /branch/{id}/cashier: + get: + consumes: + - application/json + description: Gets branch cashiers + parameters: + - description: Branch ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.GetCashierRes' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Gets branch cashiers + tags: + - branch /branch/{id}/operation: get: consumes: @@ -2213,6 +2404,39 @@ paths: summary: Get all branch wallets tags: - wallet + /cashier/{id}: + get: + consumes: + - application/json + description: Get a single cashier by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UserProfileRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get cashier by id + tags: + - cashier /cashiers: get: consumes: @@ -2298,7 +2522,7 @@ paths: name: cashier required: true schema: - $ref: '#/definitions/handlers.updateUserReq' + $ref: '#/definitions/handlers.updateCashierReq' produces: - application/json responses: @@ -2598,6 +2822,38 @@ paths: tags: - manager /managers/{id}: + get: + consumes: + - application/json + description: Get a single manager by id + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ManagersRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Get manager by id + tags: + - manager put: consumes: - application/json @@ -2608,7 +2864,7 @@ paths: name: Managers required: true schema: - $ref: '#/definitions/handlers.updateUserReq' + $ref: '#/definitions/handlers.updateManagerReq' produces: - application/json responses: @@ -2630,7 +2886,7 @@ paths: $ref: '#/definitions/response.APIResponse' summary: Update Managers tags: - - Managers + - manager /operation: post: consumes: @@ -2683,6 +2939,14 @@ paths: in: query name: sport_id type: string + - description: Start Time + in: query + name: first_start_time + type: string + - description: End Time + in: query + name: last_start_time + type: string produces: - application/json responses: @@ -2856,6 +3120,36 @@ paths: summary: Retrieve raw odds by Market ID tags: - prematch + /random/bet: + post: + consumes: + - application/json + description: Generate a random bet + parameters: + - description: Create Random bet + in: body + name: createBet + required: true + schema: + $ref: '#/definitions/domain.RandomBetReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.BetRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Generate a random bet + tags: + - bet /referral/settings: get: consumes: @@ -3375,6 +3669,35 @@ paths: summary: Check if phone number or email exist tags: - user + /user/delete/{id}: + delete: + consumes: + - application/json + description: Delete a user by their ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Delete user by ID + tags: + - user /user/profile: get: consumes: @@ -3567,7 +3890,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.APIResponse' + $ref: '#/definitions/handlers.UserProfileRes' "400": description: Bad Request schema: @@ -3583,6 +3906,36 @@ paths: summary: Get user by id tags: - user + /user/suspend: + post: + consumes: + - application/json + description: Suspend or unsuspend a user + parameters: + - description: Suspend or unsuspend a user + in: body + name: updateUserSuspend + required: true + schema: + $ref: '#/definitions/handlers.UpdateUserSuspendReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UpdateUserSuspendRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Suspend or unsuspend a user + tags: + - user /user/wallet: get: consumes: diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index e236690..823fb43 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -243,6 +243,48 @@ func (q *Queries) GetBetByID(ctx context.Context, id int64) (BetWithOutcome, err return i, err } +const GetBetOutcomeByBetID = `-- name: GetBetOutcomeByBetID :many +SELECT id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires +FROM bet_outcomes +WHERE bet_id = $1 +` + +func (q *Queries) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]BetOutcome, error) { + rows, err := q.db.Query(ctx, GetBetOutcomeByBetID, betID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BetOutcome + for rows.Next() { + var i BetOutcome + if err := rows.Scan( + &i.ID, + &i.BetID, + &i.SportID, + &i.EventID, + &i.OddID, + &i.HomeTeamName, + &i.AwayTeamName, + &i.MarketID, + &i.MarketName, + &i.Odd, + &i.OddName, + &i.OddHeader, + &i.OddHandicap, + &i.Status, + &i.Expires, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetBetOutcomeByEventID = `-- name: GetBetOutcomeByEventID :many SELECT id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires FROM bet_outcomes @@ -285,10 +327,11 @@ func (q *Queries) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([] return items, nil } -const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :exec +const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 WHERE id = $2 +RETURNING id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires ` type UpdateBetOutcomeStatusParams struct { @@ -296,9 +339,27 @@ type UpdateBetOutcomeStatusParams struct { ID int64 `json:"id"` } -func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) error { - _, err := q.db.Exec(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) - return err +func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) (BetOutcome, error) { + row := q.db.QueryRow(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) + var i BetOutcome + err := row.Scan( + &i.ID, + &i.BetID, + &i.SportID, + &i.EventID, + &i.OddID, + &i.HomeTeamName, + &i.AwayTeamName, + &i.MarketID, + &i.MarketName, + &i.Odd, + &i.OddName, + &i.OddHeader, + &i.OddHandicap, + &i.Status, + &i.Expires, + ) + return i, err } const UpdateCashOut = `-- name: UpdateCashOut :exec @@ -320,17 +381,17 @@ func (q *Queries) UpdateCashOut(ctx context.Context, arg UpdateCashOutParams) er const UpdateStatus = `-- name: UpdateStatus :exec UPDATE bets -SET status = $2, +SET status = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = $1 +WHERE id = $2 ` type UpdateStatusParams struct { - ID int64 `json:"id"` Status int32 `json:"status"` + ID int64 `json:"id"` } func (q *Queries) UpdateStatus(ctx context.Context, arg UpdateStatusParams) error { - _, err := q.db.Exec(ctx, UpdateStatus, arg.ID, arg.Status) + _, err := q.db.Exec(ctx, UpdateStatus, arg.Status, arg.ID) return err } diff --git a/gen/db/branch.sql.go b/gen/db/branch.sql.go index cf16465..93e9b2b 100644 --- a/gen/db/branch.sql.go +++ b/gen/db/branch.sql.go @@ -190,49 +190,6 @@ func (q *Queries) GetAllBranches(ctx context.Context) ([]BranchDetail, error) { return items, nil } -const GetAllCashiers = `-- name: GetAllCashiers :many -SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -` - -func (q *Queries) GetAllCashiers(ctx context.Context) ([]User, error) { - rows, err := q.db.Query(ctx, GetAllCashiers) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.FirstName, - &i.LastName, - &i.Email, - &i.PhoneNumber, - &i.Role, - &i.Password, - &i.EmailVerified, - &i.PhoneVerified, - &i.CreatedAt, - &i.UpdatedAt, - &i.CompanyID, - &i.SuspendedAt, - &i.Suspended, - &i.ReferralCode, - &i.ReferredBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const GetAllSupportedOperations = `-- name: GetAllSupportedOperations :many SELECT id, name, description FROM supported_operations @@ -430,50 +387,6 @@ func (q *Queries) GetBranchOperations(ctx context.Context, branchID int64) ([]Ge return items, nil } -const GetCashiersByBranch = `-- name: GetCashiersByBranch :many -SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -WHERE branch_cashiers.branch_id = $1 -` - -func (q *Queries) GetCashiersByBranch(ctx context.Context, branchID int64) ([]User, error) { - rows, err := q.db.Query(ctx, GetCashiersByBranch, branchID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.FirstName, - &i.LastName, - &i.Email, - &i.PhoneNumber, - &i.Role, - &i.Password, - &i.EmailVerified, - &i.PhoneVerified, - &i.CreatedAt, - &i.UpdatedAt, - &i.CompanyID, - &i.SuspendedAt, - &i.Suspended, - &i.ReferralCode, - &i.ReferredBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const SearchBranchByName = `-- name: SearchBranchByName :many SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number FROM branch_details diff --git a/gen/db/cashier.sql.go b/gen/db/cashier.sql.go new file mode 100644 index 0000000..d0f6768 --- /dev/null +++ b/gen/db/cashier.sql.go @@ -0,0 +1,173 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: cashier.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetAllCashiers = `-- name: GetAllCashiers :many +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +` + +type GetAllCashiersRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email pgtype.Text `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Role string `json:"role"` + Password []byte `json:"password"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CompanyID pgtype.Int8 `json:"company_id"` + SuspendedAt pgtype.Timestamptz `json:"suspended_at"` + Suspended bool `json:"suspended"` + ReferralCode pgtype.Text `json:"referral_code"` + ReferredBy pgtype.Text `json:"referred_by"` + BranchID int64 `json:"branch_id"` +} + +func (q *Queries) GetAllCashiers(ctx context.Context) ([]GetAllCashiersRow, error) { + rows, err := q.db.Query(ctx, GetAllCashiers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllCashiersRow + for rows.Next() { + var i GetAllCashiersRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + &i.BranchID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetCashierByID = `-- name: GetCashierByID :one +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = $1 +` + +type GetCashierByIDRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email pgtype.Text `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Role string `json:"role"` + Password []byte `json:"password"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CompanyID pgtype.Int8 `json:"company_id"` + SuspendedAt pgtype.Timestamptz `json:"suspended_at"` + Suspended bool `json:"suspended"` + ReferralCode pgtype.Text `json:"referral_code"` + ReferredBy pgtype.Text `json:"referred_by"` + BranchID int64 `json:"branch_id"` +} + +func (q *Queries) GetCashierByID(ctx context.Context, userID int64) (GetCashierByIDRow, error) { + row := q.db.QueryRow(ctx, GetCashierByID, userID) + var i GetCashierByIDRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + &i.BranchID, + ) + return i, err +} + +const GetCashiersByBranch = `-- name: GetCashiersByBranch :many +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +WHERE branch_cashiers.branch_id = $1 +` + +func (q *Queries) GetCashiersByBranch(ctx context.Context, branchID int64) ([]User, error) { + rows, err := q.db.Query(ctx, GetCashiersByBranch, branchID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/company.sql.go b/gen/db/company.sql.go index 13a1940..3c5a6b1 100644 --- a/gen/db/company.sql.go +++ b/gen/db/company.sql.go @@ -50,19 +50,19 @@ func (q *Queries) DeleteCompany(ctx context.Context, id int64) error { } const GetAllCompanies = `-- name: GetAllCompanies :many -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details ` -func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, error) { +func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesDetail, error) { rows, err := q.db.Query(ctx, GetAllCompanies) if err != nil { return nil, err } defer rows.Close() - var items []CompaniesWithWallet + var items []CompaniesDetail for rows.Next() { - var i CompaniesWithWallet + var i CompaniesDetail if err := rows.Scan( &i.ID, &i.Name, @@ -70,6 +70,9 @@ func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, e &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ); err != nil { return nil, err } @@ -82,14 +85,14 @@ func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, e } const GetCompanyByID = `-- name: GetCompanyByID :one -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details WHERE id = $1 ` -func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesWithWallet, error) { +func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesDetail, error) { row := q.db.QueryRow(ctx, GetCompanyByID, id) - var i CompaniesWithWallet + var i CompaniesDetail err := row.Scan( &i.ID, &i.Name, @@ -97,25 +100,28 @@ func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesWithWa &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ) return i, err } const SearchCompanyByName = `-- name: SearchCompanyByName :many -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details WHERE name ILIKE '%' || $1 || '%' ` -func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) ([]CompaniesWithWallet, error) { +func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) ([]CompaniesDetail, error) { rows, err := q.db.Query(ctx, SearchCompanyByName, dollar_1) if err != nil { return nil, err } defer rows.Close() - var items []CompaniesWithWallet + var items []CompaniesDetail for rows.Next() { - var i CompaniesWithWallet + var i CompaniesDetail if err := rows.Scan( &i.ID, &i.Name, @@ -123,6 +129,9 @@ func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ); err != nil { return nil, err } diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 94315a7..d95a9db 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -118,9 +118,7 @@ SELECT id, status, fetched_at FROM events -WHERE is_live = false - AND status = 'upcoming' - AND start_time < now() +WHERE start_time < now() ORDER BY start_time ASC ` @@ -201,22 +199,32 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $3 + league_id = $1 + OR $1 IS NULL + ) + AND ( + sport_id = $2 + OR $2 IS NULL + ) + AND ( + start_time < $3 OR $3 IS NULL ) AND ( - sport_id = $4 + start_time > $4 OR $4 IS NULL ) ORDER BY start_time ASC -LIMIT $1 OFFSET $2 +LIMIT $6 OFFSET $5 ` type GetPaginatedUpcomingEventsParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Text `json:"league_id"` + SportID pgtype.Text `json:"sport_id"` + LastStartTime pgtype.Timestamp `json:"last_start_time"` + FirstStartTime pgtype.Timestamp `json:"first_start_time"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetPaginatedUpcomingEventsRow struct { @@ -240,10 +248,12 @@ type GetPaginatedUpcomingEventsRow struct { func (q *Queries) GetPaginatedUpcomingEvents(ctx context.Context, arg GetPaginatedUpcomingEventsParams) ([]GetPaginatedUpcomingEventsRow, error) { rows, err := q.db.Query(ctx, GetPaginatedUpcomingEvents, - arg.Limit, - arg.Offset, arg.LeagueID, arg.SportID, + arg.LastStartTime, + arg.FirstStartTime, + arg.Offset, + arg.Limit, ) if err != nil { return nil, err @@ -293,15 +303,30 @@ WHERE is_live = false sport_id = $2 OR $2 IS NULL ) + AND ( + start_time < $3 + OR $3 IS NULL + ) + AND ( + start_time > $4 + OR $4 IS NULL + ) ` type GetTotalEventsParams struct { - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Text `json:"league_id"` + SportID pgtype.Text `json:"sport_id"` + LastStartTime pgtype.Timestamp `json:"last_start_time"` + FirstStartTime pgtype.Timestamp `json:"first_start_time"` } func (q *Queries) GetTotalEvents(ctx context.Context, arg GetTotalEventsParams) (int64, error) { - row := q.db.QueryRow(ctx, GetTotalEvents, arg.LeagueID, arg.SportID) + row := q.db.QueryRow(ctx, GetTotalEvents, + arg.LeagueID, + arg.SportID, + arg.LastStartTime, + arg.FirstStartTime, + ) var count int64 err := row.Scan(&count) return count, err diff --git a/gen/db/models.go b/gen/db/models.go index 0cc5956..7c1695f 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -146,13 +146,16 @@ type BranchOperation struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } -type CompaniesWithWallet struct { - ID int64 `json:"id"` - Name string `json:"name"` - AdminID int64 `json:"admin_id"` - WalletID int64 `json:"wallet_id"` - Balance int64 `json:"balance"` - IsActive bool `json:"is_active"` +type CompaniesDetail struct { + ID int64 `json:"id"` + Name string `json:"name"` + AdminID int64 `json:"admin_id"` + WalletID int64 `json:"wallet_id"` + Balance int64 `json:"balance"` + IsActive bool `json:"is_active"` + AdminFirstName string `json:"admin_first_name"` + AdminLastName string `json:"admin_last_name"` + AdminPhoneNumber pgtype.Text `json:"admin_phone_number"` } type Company struct { @@ -165,7 +168,6 @@ type Company struct { type CustomerWallet struct { ID int64 `json:"id"` CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularWalletID int64 `json:"regular_wallet_id"` StaticWalletID int64 `json:"static_wallet_id"` CreatedAt pgtype.Timestamp `json:"created_at"` diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index 5bfedd6..d30b3d1 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -11,12 +11,52 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const CountUnreadNotifications = `-- name: CountUnreadNotifications :one +SELECT count(id) +FROM notifications +WHERE recipient_id = $1 + AND is_read = false +` + +func (q *Queries) CountUnreadNotifications(ctx context.Context, recipientID int64) (int64, error) { + row := q.db.QueryRow(ctx, CountUnreadNotifications, recipientID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CreateNotification = `-- name: CreateNotification :one INSERT INTO notifications ( - id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, timestamp, metadata -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 -) RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata + id, + recipient_id, + type, + level, + error_severity, + reciever, + is_read, + delivery_status, + delivery_channel, + payload, + priority, + timestamp, + metadata + ) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13 + ) +RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata ` type CreateNotificationParams struct { @@ -71,8 +111,58 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification return i, err } +const GetAllNotifications = `-- name: GetAllNotifications :many +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +ORDER BY timestamp DESC +LIMIT $1 OFFSET $2 +` + +type GetAllNotificationsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificationsParams) ([]Notification, error) { + rows, err := q.db.Query(ctx, GetAllNotifications, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Notification + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.ID, + &i.RecipientID, + &i.Type, + &i.Level, + &i.ErrorSeverity, + &i.Reciever, + &i.IsRead, + &i.DeliveryStatus, + &i.DeliveryChannel, + &i.Payload, + &i.Priority, + &i.Version, + &i.Timestamp, + &i.Metadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetNotification = `-- name: GetNotification :one -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE id = $1 LIMIT 1 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE id = $1 +LIMIT 1 ` func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, error) { @@ -98,7 +188,12 @@ func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, } const ListFailedNotifications = `-- name: ListFailedNotifications :many -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE delivery_status = 'failed' AND timestamp < NOW() - INTERVAL '1 hour' ORDER BY timestamp ASC LIMIT $1 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE delivery_status = 'failed' + AND timestamp < NOW() - INTERVAL '1 hour' +ORDER BY timestamp ASC +LIMIT $1 ` func (q *Queries) ListFailedNotifications(ctx context.Context, limit int32) ([]Notification, error) { @@ -137,7 +232,11 @@ func (q *Queries) ListFailedNotifications(ctx context.Context, limit int32) ([]N } const ListNotifications = `-- name: ListNotifications :many -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE recipient_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE recipient_id = $1 +ORDER BY timestamp DESC +LIMIT $2 OFFSET $3 ` type ListNotificationsParams struct { @@ -182,7 +281,9 @@ func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsPa } const ListRecipientIDsByReceiver = `-- name: ListRecipientIDsByReceiver :many -SELECT recipient_id FROM notifications WHERE reciever = $1 +SELECT recipient_id +FROM notifications +WHERE reciever = $1 ` func (q *Queries) ListRecipientIDsByReceiver(ctx context.Context, reciever string) ([]int64, error) { @@ -206,7 +307,12 @@ func (q *Queries) ListRecipientIDsByReceiver(ctx context.Context, reciever strin } const UpdateNotificationStatus = `-- name: UpdateNotificationStatus :one -UPDATE notifications SET delivery_status = $2, is_read = $3, metadata = $4 WHERE id = $1 RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +UPDATE notifications +SET delivery_status = $2, + is_read = $3, + metadata = $4 +WHERE id = $1 +RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata ` type UpdateNotificationStatusParams struct { diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go index 3f920f4..3d92299 100644 --- a/gen/db/odds.sql.go +++ b/gen/db/odds.sql.go @@ -86,6 +86,61 @@ func (q *Queries) GetALLPrematchOdds(ctx context.Context) ([]GetALLPrematchOddsR return items, nil } +const GetPaginatedPrematchOddsByUpcomingID = `-- name: GetPaginatedPrematchOddsByUpcomingID :many +SELECT o.id, o.event_id, o.fi, o.market_type, o.market_name, o.market_category, o.market_id, o.name, o.handicap, o.odds_value, o.section, o.category, o.raw_odds, o.fetched_at, o.source, o.is_active +FROM odds o + JOIN events e ON o.fi = e.id +WHERE e.id = $1 + AND e.is_live = false + AND e.status = 'upcoming' + AND o.is_active = true + AND o.source = 'b365api' +LIMIT $3 OFFSET $2 +` + +type GetPaginatedPrematchOddsByUpcomingIDParams struct { + ID string `json:"id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +func (q *Queries) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, arg GetPaginatedPrematchOddsByUpcomingIDParams) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPaginatedPrematchOddsByUpcomingID, arg.ID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Odd + for rows.Next() { + var i Odd + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Fi, + &i.MarketType, + &i.MarketName, + &i.MarketCategory, + &i.MarketID, + &i.Name, + &i.Handicap, + &i.OddsValue, + &i.Section, + &i.Category, + &i.RawOdds, + &i.FetchedAt, + &i.Source, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetPrematchOdds = `-- name: GetPrematchOdds :many SELECT event_id, fi, @@ -162,21 +217,7 @@ func (q *Queries) GetPrematchOdds(ctx context.Context) ([]GetPrematchOddsRow, er } const GetPrematchOddsByUpcomingID = `-- name: GetPrematchOddsByUpcomingID :many -SELECT o.event_id, - o.fi, - o.market_type, - o.market_name, - o.market_category, - o.market_id, - o.name, - o.handicap, - o.odds_value, - o.section, - o.category, - o.raw_odds, - o.fetched_at, - o.source, - o.is_active +SELECT o.id, o.event_id, o.fi, o.market_type, o.market_name, o.market_category, o.market_id, o.name, o.handicap, o.odds_value, o.section, o.category, o.raw_odds, o.fetched_at, o.source, o.is_active FROM odds o JOIN events e ON o.fi = e.id WHERE e.id = $1 @@ -184,43 +225,19 @@ WHERE e.id = $1 AND e.status = 'upcoming' AND o.is_active = true AND o.source = 'b365api' -LIMIT $2 OFFSET $3 ` -type GetPrematchOddsByUpcomingIDParams struct { - ID string `json:"id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type GetPrematchOddsByUpcomingIDRow struct { - EventID pgtype.Text `json:"event_id"` - Fi pgtype.Text `json:"fi"` - MarketType string `json:"market_type"` - MarketName pgtype.Text `json:"market_name"` - MarketCategory pgtype.Text `json:"market_category"` - MarketID pgtype.Text `json:"market_id"` - Name pgtype.Text `json:"name"` - Handicap pgtype.Text `json:"handicap"` - OddsValue pgtype.Float8 `json:"odds_value"` - Section string `json:"section"` - Category pgtype.Text `json:"category"` - RawOdds []byte `json:"raw_odds"` - FetchedAt pgtype.Timestamp `json:"fetched_at"` - Source pgtype.Text `json:"source"` - IsActive pgtype.Bool `json:"is_active"` -} - -func (q *Queries) GetPrematchOddsByUpcomingID(ctx context.Context, arg GetPrematchOddsByUpcomingIDParams) ([]GetPrematchOddsByUpcomingIDRow, error) { - rows, err := q.db.Query(ctx, GetPrematchOddsByUpcomingID, arg.ID, arg.Limit, arg.Offset) +func (q *Queries) GetPrematchOddsByUpcomingID(ctx context.Context, id string) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPrematchOddsByUpcomingID, id) if err != nil { return nil, err } defer rows.Close() - var items []GetPrematchOddsByUpcomingIDRow + var items []Odd for rows.Next() { - var i GetPrematchOddsByUpcomingIDRow + var i Odd if err := rows.Scan( + &i.ID, &i.EventID, &i.Fi, &i.MarketType, diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index a595372..e0860c6 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -182,14 +182,14 @@ wHERE ( company_id = $2 OR $2 IS NULL ) -LIMIT $3 OFFSET $4 +LIMIT $4 OFFSET $3 ` type GetAllUsersParams struct { Role string `json:"role"` CompanyID pgtype.Int8 `json:"company_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetAllUsersRow struct { @@ -212,8 +212,8 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get rows, err := q.db.Query(ctx, GetAllUsers, arg.Role, arg.CompanyID, - arg.Limit, arg.Offset, + arg.Limit, ) if err != nil { return nil, err @@ -427,11 +427,27 @@ SELECT id, suspended_at, company_id FROM users -WHERE first_name ILIKE '%' || $1 || '%' - OR last_name ILIKE '%' || $1 || '%' - OR phone_number LIKE '%' || $1 || '%' +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR phone_number LIKE '%' || $1 || '%' + ) + AND ( + role = $2 + OR $2 IS NULL + ) + AND ( + company_id = $3 + OR $3 IS NULL + ) ` +type SearchUserByNameOrPhoneParams struct { + Column1 pgtype.Text `json:"column_1"` + Role pgtype.Text `json:"role"` + CompanyID pgtype.Int8 `json:"company_id"` +} + type SearchUserByNameOrPhoneRow struct { ID int64 `json:"id"` FirstName string `json:"first_name"` @@ -448,8 +464,8 @@ type SearchUserByNameOrPhoneRow struct { CompanyID pgtype.Int8 `json:"company_id"` } -func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, dollar_1 pgtype.Text) ([]SearchUserByNameOrPhoneRow, error) { - rows, err := q.db.Query(ctx, SearchUserByNameOrPhone, dollar_1) +func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, arg SearchUserByNameOrPhoneParams) ([]SearchUserByNameOrPhoneRow, error) { + rows, err := q.db.Query(ctx, SearchUserByNameOrPhone, arg.Column1, arg.Role, arg.CompanyID) if err != nil { return nil, err } @@ -532,31 +548,23 @@ const UpdateUser = `-- name: UpdateUser :exec UPDATE users SET first_name = $1, last_name = $2, - email = $3, - phone_number = $4, - role = $5, - updated_at = $6 -WHERE id = $7 + suspended = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 ` type UpdateUserParams struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email pgtype.Text `json:"email"` - PhoneNumber pgtype.Text `json:"phone_number"` - Role string `json:"role"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Suspended bool `json:"suspended"` + ID int64 `json:"id"` } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { _, err := q.db.Exec(ctx, UpdateUser, arg.FirstName, arg.LastName, - arg.Email, - arg.PhoneNumber, - arg.Role, - arg.UpdatedAt, + arg.Suspended, arg.ID, ) return err diff --git a/gen/db/wallet.sql.go b/gen/db/wallet.sql.go index b3637f8..64c3359 100644 --- a/gen/db/wallet.sql.go +++ b/gen/db/wallet.sql.go @@ -14,33 +14,25 @@ import ( const CreateCustomerWallet = `-- name: CreateCustomerWallet :one INSERT INTO customer_wallets ( customer_id, - company_id, regular_wallet_id, static_wallet_id ) -VALUES ($1, $2, $3, $4) -RETURNING id, customer_id, company_id, regular_wallet_id, static_wallet_id, created_at, updated_at +VALUES ($1, $2, $3) +RETURNING id, customer_id, regular_wallet_id, static_wallet_id, created_at, updated_at ` type CreateCustomerWalletParams struct { CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularWalletID int64 `json:"regular_wallet_id"` StaticWalletID int64 `json:"static_wallet_id"` } func (q *Queries) CreateCustomerWallet(ctx context.Context, arg CreateCustomerWalletParams) (CustomerWallet, error) { - row := q.db.QueryRow(ctx, CreateCustomerWallet, - arg.CustomerID, - arg.CompanyID, - arg.RegularWalletID, - arg.StaticWalletID, - ) + row := q.db.QueryRow(ctx, CreateCustomerWallet, arg.CustomerID, arg.RegularWalletID, arg.StaticWalletID) var i CustomerWallet err := row.Scan( &i.ID, &i.CustomerID, - &i.CompanyID, &i.RegularWalletID, &i.StaticWalletID, &i.CreatedAt, @@ -190,7 +182,6 @@ func (q *Queries) GetAllWallets(ctx context.Context) ([]Wallet, error) { const GetCustomerWallet = `-- name: GetCustomerWallet :one SELECT cw.id, cw.customer_id, - cw.company_id, rw.id AS regular_id, rw.balance AS regular_balance, sw.id AS static_id, @@ -202,18 +193,11 @@ FROM customer_wallets cw JOIN wallets rw ON cw.regular_wallet_id = rw.id JOIN wallets sw ON cw.static_wallet_id = sw.id WHERE cw.customer_id = $1 - AND cw.company_id = $2 ` -type GetCustomerWalletParams struct { - CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` -} - type GetCustomerWalletRow struct { ID int64 `json:"id"` CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularID int64 `json:"regular_id"` RegularBalance int64 `json:"regular_balance"` StaticID int64 `json:"static_id"` @@ -223,13 +207,12 @@ type GetCustomerWalletRow struct { CreatedAt pgtype.Timestamp `json:"created_at"` } -func (q *Queries) GetCustomerWallet(ctx context.Context, arg GetCustomerWalletParams) (GetCustomerWalletRow, error) { - row := q.db.QueryRow(ctx, GetCustomerWallet, arg.CustomerID, arg.CompanyID) +func (q *Queries) GetCustomerWallet(ctx context.Context, customerID int64) (GetCustomerWalletRow, error) { + row := q.db.QueryRow(ctx, GetCustomerWallet, customerID) var i GetCustomerWalletRow err := row.Scan( &i.ID, &i.CustomerID, - &i.CompanyID, &i.RegularID, &i.RegularBalance, &i.StaticID, diff --git a/go.mod b/go.mod index 5a8e5eb..5a55392 100644 --- a/go.mod +++ b/go.mod @@ -7,24 +7,28 @@ require ( github.com/bytedance/sonic v1.13.2 github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 - github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 github.com/robfig/cron/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 golang.org/x/crypto v0.36.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect // github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/fasthttp/websocket v1.5.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -32,7 +36,7 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - // github.com/gofiber/contrib/websocket v1.3.4 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -46,11 +50,10 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.59.0 // indirect + github.com/valyala/fasthttp v1.59.0 golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect diff --git a/go.sum b/go.sum index 87d14e2..ab1ac26 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ 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/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= -github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= 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= @@ -52,13 +50,13 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= -github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -114,8 +112,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/internal/domain/bet.go b/internal/domain/bet.go index 6e2d81a..d681bb8 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -80,3 +80,83 @@ type CreateBet struct { IsShopBet bool CashoutID string } + +type CreateBetOutcomeReq struct { + EventID int64 `json:"event_id" example:"1"` + OddID int64 `json:"odd_id" example:"1"` + MarketID int64 `json:"market_id" example:"1"` +} + +type CreateBetReq struct { + Outcomes []CreateBetOutcomeReq `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID *int64 `json:"branch_id,omitempty" example:"1"` +} + +type RandomBetReq struct { + BranchID int64 `json:"branch_id" validate:"required" example:"1"` + NumberOfBets int64 `json:"number_of_bets" validate:"required" example:"1"` +} + +type CreateBetRes struct { + ID int64 `json:"id" example:"1"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CreatedNumber int64 `json:"created_number" example:"2"` + CashedID string `json:"cashed_id" example:"21234"` +} +type BetRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []BetOutcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CashedOut bool `json:"cashed_out" example:"false"` + CashedID string `json:"cashed_id" example:"21234"` +} + +func ConvertCreateBet(bet Bet, createdNumber int64) CreateBetRes { + return CreateBetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + CreatedNumber: createdNumber, + CashedID: bet.CashoutID, + } +} + +func ConvertBet(bet GetBet) BetRes { + return BetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + Outcomes: bet.Outcomes, + IsShopBet: bet.IsShopBet, + CashedOut: bet.CashedOut, + CashedID: bet.CashoutID, + } +} diff --git a/internal/domain/common.go b/internal/domain/common.go index f5969d9..fc652d1 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -1,16 +1,27 @@ package domain -import "fmt" +import ( + "fmt" + "time" +) type ValidInt64 struct { Value int64 Valid bool } +type ValidInt struct { + Value int + Valid bool +} type ValidString struct { Value string Valid bool } +type ValidTime struct { + Value time.Time + Valid bool +} type ValidBool struct { Value bool Valid bool diff --git a/internal/domain/company.go b/internal/domain/company.go index 9a05e4c..f0a6420 100644 --- a/internal/domain/company.go +++ b/internal/domain/company.go @@ -11,12 +11,15 @@ type Company struct { } type GetCompany struct { - ID int64 - Name string - AdminID int64 - WalletID int64 - WalletBalance Currency - IsWalletActive bool + ID int64 + Name string + AdminID int64 + AdminFirstName string + AdminLastName string + AdminPhoneNumber string + WalletID int64 + WalletBalance Currency + IsWalletActive bool } type CreateCompany struct { diff --git a/internal/domain/league.go b/internal/domain/league.go index f05914a..a4a9cc2 100644 --- a/internal/domain/league.go +++ b/internal/domain/league.go @@ -12,9 +12,8 @@ var SupportedLeagues = []int64{ 10041957, //UEFA Europa League 10079560, //UEFA Conference League 10047168, // US MLS - + 10044469, // Ethiopian Premier League 10050282, //UEFA Nations League - 10040795, //EuroLeague 10043156, //England FA Cup 10042103, //France Cup @@ -26,5 +25,15 @@ var SupportedLeagues = []int64{ // Basketball 173998768, //NBA + 10041830, //NBA + 10049984, //WNBA + 10037165, //German Bundesliga + 10036608, //Italian Lega 1 + 10040795, //EuroLeague + + // Ice Hockey + 10037477, //NHL + 10037447, //AHL + 10069385, //IIHF World Championship } diff --git a/internal/domain/oddres.go b/internal/domain/oddres.go new file mode 100644 index 0000000..48540f0 --- /dev/null +++ b/internal/domain/oddres.go @@ -0,0 +1,51 @@ +package domain + +import "encoding/json" + +type BaseNonLiveOddResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type OddsSection struct { + UpdatedAt string `json:"updated_at"` + Sp map[string]OddsMarket `json:"sp"` +} + +// The Market ID for the json data can be either string / int which is causing problems when UnMarshalling +type OddsMarket struct { + ID json.RawMessage `json:"id"` + Name string `json:"name"` + Odds []json.RawMessage `json:"odds"` + Header string `json:"header,omitempty"` + Handicap string `json:"handicap,omitempty"` + Open int64 `json:"open,omitempty"` +} + +type FootballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + AsianLines OddsSection `json:"asian_lines"` + Goals OddsSection `json:"goals"` + Half OddsSection `json:"half"` +} + +type BasketballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + HalfProps OddsSection `json:"half_props"` + QuarterProps OddsSection `json:"quarter_props"` + TeamProps OddsSection `json:"team_props"` + Others []OddsSection `json:"others"` +} + +type IceHockeyOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + Main2 OddsSection `json:"main_2"` + FirstPeriod OddsSection `json:"1st_period"` + Others []OddsSection `json:"others"` +} diff --git a/internal/domain/result.go b/internal/domain/result.go index dacd634..3400e4e 100644 --- a/internal/domain/result.go +++ b/internal/domain/result.go @@ -1,185 +1,9 @@ package domain import ( - "encoding/json" "time" ) -type BaseResultResponse struct { - Success int `json:"success"` - Results []json.RawMessage `json:"results"` -} -type FootballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstHalf Score `json:"1"` - SecondHalf Score `json:"2"` - } `json:"scores"` - Stats struct { - Attacks []string `json:"attacks"` - Corners []string `json:"corners"` - DangerousAttacks []string `json:"dangerous_attacks"` - Goals []string `json:"goals"` - OffTarget []string `json:"off_target"` - OnTarget []string `json:"on_target"` - Penalties []string `json:"penalties"` - PossessionRT []string `json:"possession_rt"` - RedCards []string `json:"redcards"` - Substitutions []string `json:"substitutions"` - YellowCards []string `json:"yellowcards"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - StadiumData map[string]string `json:"stadium_data"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type BasketballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstQuarter Score `json:"1"` - SecondQuarter Score `json:"2"` - FirstHalf Score `json:"3"` - ThirdQuarter Score `json:"4"` - FourthQuarter Score `json:"5"` - TotalScore Score `json:"7"` - } `json:"scores"` - Stats struct { - TwoPoints []string `json:"2points"` - ThreePoints []string `json:"3points"` - BiggestLead []string `json:"biggest_lead"` - Fouls []string `json:"fouls"` - FreeThrows []string `json:"free_throws"` - FreeThrowRate []string `json:"free_throws_rate"` - LeadChanges []string `json:"lead_changes"` - MaxpointsInarow []string `json:"maxpoints_inarow"` - Possession []string `json:"possession"` - SuccessAttempts []string `json:"success_attempts"` - TimeSpendInLead []string `json:"timespent_inlead"` - Timeuts []string `json:"time_outs"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} -type IceHockeyResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstPeriod Score `json:"1"` - SecondPeriod Score `json:"2"` - ThirdPeriod Score `json:"3"` - TotalScore Score `json:"5"` - } `json:"scores"` - - Stats struct { - Shots []string `json:"shots"` - Penalties []string `json:"penalties"` - GoalsOnPowerPlay []string `json:"goals_on_power_play"` - SSeven []string `json:"s7"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type Score struct { - Home string `json:"home"` - Away string `json:"away"` -} - type MarketConfig struct { Sport string MarketCategories map[string]bool @@ -219,4 +43,42 @@ const ( OUTCOME_STATUS_LOSS OutcomeStatus = 2 OUTCOME_STATUS_VOID OutcomeStatus = 3 //Give Back OUTCOME_STATUS_HALF OutcomeStatus = 4 //Half Win and Half Given Back + OUTCOME_STATUS_ERROR OutcomeStatus = 5 //Half Win and Half Given Back +) + +func (o *OutcomeStatus) String() string { + switch *o { + case OUTCOME_STATUS_PENDING: + return "PENDING" + case OUTCOME_STATUS_WIN: + return "WIN" + case OUTCOME_STATUS_LOSS: + return "LOSS" + case OUTCOME_STATUS_VOID: + return "VOID" + case OUTCOME_STATUS_HALF: + return "HALF" + case OUTCOME_STATUS_ERROR: + return "ERROR" + default: + return "UNKNOWN" + } +} + +type TimeStatus int32 + +const ( + TIME_STATUS_NOT_STARTED TimeStatus = 0 + TIME_STATUS_IN_PLAY TimeStatus = 1 + TIME_STATUS_TO_BE_FIXED TimeStatus = 2 + TIME_STATUS_ENDED TimeStatus = 3 + TIME_STATUS_POSTPONED TimeStatus = 4 + TIME_STATUS_CANCELLED TimeStatus = 5 + TIME_STATUS_WALKOVER TimeStatus = 6 + TIME_STATUS_INTERRUPTED TimeStatus = 7 + TIME_STATUS_ABANDONED TimeStatus = 8 + TIME_STATUS_RETIRED TimeStatus = 9 + TIME_STATUS_SUSPENDED TimeStatus = 10 + TIME_STATUS_DECIDED_BY_FA TimeStatus = 11 + TIME_STATUS_REMOVED TimeStatus = 99 ) diff --git a/internal/domain/resultres.go b/internal/domain/resultres.go new file mode 100644 index 0000000..8a17f24 --- /dev/null +++ b/internal/domain/resultres.go @@ -0,0 +1,153 @@ +package domain + +import ( + "encoding/json" +) + +type BaseResultResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` +} + +type Score struct { + Home string `json:"home"` + Away string `json:"away"` +} + +type FootballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + } `json:"scores"` + Stats struct { + Attacks []string `json:"attacks"` + Corners []string `json:"corners"` + HalfTimeCorners []string `json:"corner_h"` + DangerousAttacks []string `json:"dangerous_attacks"` + Goals []string `json:"goals"` + OffTarget []string `json:"off_target"` + OnTarget []string `json:"on_target"` + Penalties []string `json:"penalties"` + PossessionRT []string `json:"possession_rt"` + RedCards []string `json:"redcards"` + Substitutions []string `json:"substitutions"` + YellowCards []string `json:"yellowcards"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +type BasketballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + FirstHalf Score `json:"3"` + ThirdQuarter Score `json:"4"` + FourthQuarter Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + TwoPoints []string `json:"2points"` + ThreePoints []string `json:"3points"` + BiggestLead []string `json:"biggest_lead"` + Fouls []string `json:"fouls"` + FreeThrows []string `json:"free_throws"` + FreeThrowRate []string `json:"free_throws_rate"` + LeadChanges []string `json:"lead_changes"` + MaxpointsInarow []string `json:"maxpoints_inarow"` + Possession []string `json:"possession"` + SuccessAttempts []string `json:"success_attempts"` + TimeSpendInLead []string `json:"timespent_inlead"` + TimeOuts []string `json:"time_outs"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length int `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} +type IceHockeyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstPeriod Score `json:"1"` + SecondPeriod Score `json:"2"` + ThirdPeriod Score `json:"3"` + TotalScore Score `json:"5"` + } `json:"scores"` + + Stats struct { + Shots []string `json:"shots"` + Penalties []string `json:"penalties"` + GoalsOnPowerPlay []string `json:"goals_on_power_play"` + SSeven []string `json:"s7"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length int `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} diff --git a/internal/domain/sportmarket.go b/internal/domain/sportmarket.go index b6fde09..c50a032 100644 --- a/internal/domain/sportmarket.go +++ b/internal/domain/sportmarket.go @@ -3,18 +3,28 @@ package domain type FootballMarket int64 const ( - FOOTBALL_FULL_TIME_RESULT FootballMarket = 40 //"full_time_result" - FOOTBALL_DOUBLE_CHANCE FootballMarket = 10114 //"double_chance" - FOOTBALL_GOALS_OVER_UNDER FootballMarket = 981 //"goals_over_under" - FOOTBALL_CORRECT_SCORE FootballMarket = 43 //"correct_score" - FOOTBALL_ASIAN_HANDICAP FootballMarket = 938 //"asian_handicap" - FOOTBALL_GOAL_LINE FootballMarket = 10143 //"goal_line" + FOOTBALL_FULL_TIME_RESULT FootballMarket = 40 //"full_time_result" + FOOTBALL_DOUBLE_CHANCE FootballMarket = 10114 //"double_chance" + FOOTBALL_GOALS_OVER_UNDER FootballMarket = 981 //"goals_over_under" + FOOTBALL_CORRECT_SCORE FootballMarket = 43 //"correct_score" + FOOTBALL_ASIAN_HANDICAP FootballMarket = 938 //"asian_handicap" + FOOTBALL_GOAL_LINE FootballMarket = 10143 //"goal_line" + FOOTBALL_HALF_TIME_RESULT FootballMarket = 1579 //"half_time_result" FOOTBALL_FIRST_HALF_ASIAN_HANDICAP FootballMarket = 50137 //"1st_half_asian_handicap" FOOTBALL_FIRST_HALF_GOAL_LINE FootballMarket = 50136 //"1st_half_goal_line" FOOTBALL_FIRST_TEAM_TO_SCORE FootballMarket = 1178 //"first_team_to_score" FOOTBALL_GOALS_ODD_EVEN FootballMarket = 10111 //"goals_odd_even" FOOTBALL_DRAW_NO_BET FootballMarket = 10544 //"draw_no_bet" + + FOOTBALL_CORNERS FootballMarket = 760 //"corners" + FOOTBALL_CORNERS_TWO_WAY FootballMarket = 10235 //"corners_2_way" + FOOTBALL_FIRST_HALF_CORNERS FootballMarket = 10539 //"first_half_corners" + FOOTBALL_ASIAN_TOTAL_CORNERS FootballMarket = 10164 //"asian_total_corners" + FOOTBALL_FIRST_HALF_ASIAN_CORNERS FootballMarket = 10233 //"1st_half_asian_corners" + FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN FootballMarket = 10206 //"1st_half_goals_odd_even" + FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN FootballMarket = 50433 //"2nd_half_goals_odd_even" + ) type BasketBallMarket int64 @@ -91,24 +101,63 @@ const ( ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY IceHockeyMarket = 170240 ) +type AmericanFootballMarket int64 + +const ( + // Main + AMERICAN_FOOTBALL_MONEY_LINE AmericanFootballMarket = 170001 + AMERICAN_FOOTBALL_SPREAD AmericanFootballMarket = 170002 + AMERICAN_FOOTBALL_TOTAL_POINTS AmericanFootballMarket = 170003 +) + +type RugbyMarket int64 + +const ( + // Main + RUGBY_MONEY_LINE RugbyMarket = 180001 + RUGBY_SPREAD RugbyMarket = 180002 + RUGBY_TOTAL_POINTS RugbyMarket = 180003 + RUGBY_HANDICAP RugbyMarket = 180004 + RUGBY_FIRST_HALF RugbyMarket = 180005 + RUGBY_SECOND_HALF RugbyMarket = 180006 +) + +type BaseballMarket int64 + +const ( + // Main + BASEBALL_MONEY_LINE BaseballMarket = 190001 + BASEBALL_SPREAD BaseballMarket = 190002 + BASEBALL_TOTAL_RUNS BaseballMarket = 190003 + BASEBALL_FIRST_INNING BaseballMarket = 190004 + BASEBALL_FIRST_5_INNINGS BaseballMarket = 190005 +) + // TODO: Move this into the database so that it can be modified dynamically var SupportedMarkets = map[int64]bool{ // Football Markets - int64(FOOTBALL_FULL_TIME_RESULT): true, //"full_time_result" - int64(FOOTBALL_DOUBLE_CHANCE): true, //"double_chance" - int64(FOOTBALL_GOALS_OVER_UNDER): true, //"goals_over_under" - int64(FOOTBALL_CORRECT_SCORE): true, //"correct_score" - int64(FOOTBALL_ASIAN_HANDICAP): true, //"asian_handicap" - int64(FOOTBALL_GOAL_LINE): true, //"goal_line" - int64(FOOTBALL_HALF_TIME_RESULT): true, //"half_time_result" - int64(FOOTBALL_FIRST_HALF_ASIAN_HANDICAP): true, //"1st_half_asian_handicap" - int64(FOOTBALL_FIRST_HALF_GOAL_LINE): true, //"1st_half_goal_line" - int64(FOOTBALL_FIRST_TEAM_TO_SCORE): true, //"first_team_to_score" - int64(FOOTBALL_GOALS_ODD_EVEN): true, //"goals_odd_even" - int64(FOOTBALL_DRAW_NO_BET): true, //"draw_no_bet" - + int64(FOOTBALL_FULL_TIME_RESULT): true, //"full_time_result" + int64(FOOTBALL_DOUBLE_CHANCE): true, //"double_chance" + int64(FOOTBALL_GOALS_OVER_UNDER): true, //"goals_over_under" + int64(FOOTBALL_CORRECT_SCORE): true, //"correct_score" + int64(FOOTBALL_ASIAN_HANDICAP): true, //"asian_handicap" + int64(FOOTBALL_GOAL_LINE): true, //"goal_line" + int64(FOOTBALL_HALF_TIME_RESULT): true, //"half_time_result" + int64(FOOTBALL_FIRST_HALF_ASIAN_HANDICAP): true, //"1st_half_asian_handicap" + int64(FOOTBALL_FIRST_HALF_GOAL_LINE): true, //"1st_half_goal_line" + int64(FOOTBALL_FIRST_TEAM_TO_SCORE): true, //"first_team_to_score" + int64(FOOTBALL_GOALS_ODD_EVEN): true, //"goals_odd_even" + int64(FOOTBALL_DRAW_NO_BET): true, //"draw_no_bet" + int64(FOOTBALL_CORNERS): true, + int64(FOOTBALL_CORNERS_TWO_WAY): true, + int64(FOOTBALL_FIRST_HALF_CORNERS): true, + int64(FOOTBALL_ASIAN_TOTAL_CORNERS): true, + int64(FOOTBALL_FIRST_HALF_ASIAN_CORNERS): true, + int64(FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN): true, + int64(FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN): true, + // Basketball Markets int64(BASKETBALL_GAME_LINES): true, int64(BASKETBALL_RESULT_AND_BOTH_TEAMS_TO_SCORE_X_POINTS): true, @@ -164,4 +213,24 @@ var SupportedMarkets = map[int64]bool{ int64(ICE_HOCKEY_ALTERNATIVE_PUCK_LINE_TWO_WAY): false, int64(ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY): false, + + // American Football Markets + int64(AMERICAN_FOOTBALL_MONEY_LINE): true, + int64(AMERICAN_FOOTBALL_SPREAD): true, + int64(AMERICAN_FOOTBALL_TOTAL_POINTS): true, + + // Rugby Markets + int64(RUGBY_MONEY_LINE): true, + int64(RUGBY_SPREAD): true, + int64(RUGBY_TOTAL_POINTS): true, + int64(RUGBY_HANDICAP): true, + int64(RUGBY_FIRST_HALF): true, + int64(RUGBY_SECOND_HALF): true, + + // Baseball Markets + int64(BASEBALL_MONEY_LINE): true, + int64(BASEBALL_SPREAD): true, + int64(BASEBALL_TOTAL_RUNS): true, + int64(BASEBALL_FIRST_INNING): true, + int64(BASEBALL_FIRST_5_INNINGS): true, } diff --git a/internal/domain/sports_result.go b/internal/domain/sports_result.go new file mode 100644 index 0000000..448c4de --- /dev/null +++ b/internal/domain/sports_result.go @@ -0,0 +1,290 @@ +package domain + +import ( + "encoding/json" + "strconv" + "strings" +) + +// NFLResultResponse represents the structure for NFL game results +type NFLResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + ThirdQuarter Score `json:"3"` + FourthQuarter Score `json:"4"` + Overtime Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + FirstDowns []string `json:"first_downs"` + TotalYards []string `json:"total_yards"` + PassingYards []string `json:"passing_yards"` + RushingYards []string `json:"rushing_yards"` + Turnovers []string `json:"turnovers"` + TimeOfPossession []string `json:"time_of_possession"` + ThirdDownEfficiency []string `json:"third_down_efficiency"` + FourthDownEfficiency []string `json:"fourth_down_efficiency"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// RugbyResultResponse represents the structure for Rugby game results +type RugbyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Tries []string `json:"tries"` + Conversions []string `json:"conversions"` + Penalties []string `json:"penalties"` + DropGoals []string `json:"drop_goals"` + Possession []string `json:"possession"` + Territory []string `json:"territory"` + Lineouts []string `json:"lineouts"` + Scrums []string `json:"scrums"` + PenaltiesConceded []string `json:"penalties_conceded"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// BaseballResultResponse represents the structure for Baseball game results +type BaseballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstInning Score `json:"1"` + SecondInning Score `json:"2"` + ThirdInning Score `json:"3"` + FourthInning Score `json:"4"` + FifthInning Score `json:"5"` + SixthInning Score `json:"6"` + SeventhInning Score `json:"7"` + EighthInning Score `json:"8"` + NinthInning Score `json:"9"` + ExtraInnings Score `json:"10"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Hits []string `json:"hits"` + Errors []string `json:"errors"` + LeftOnBase []string `json:"left_on_base"` + Strikeouts []string `json:"strikeouts"` + Walks []string `json:"walks"` + HomeRuns []string `json:"home_runs"` + TotalBases []string `json:"total_bases"` + BattingAverage []string `json:"batting_average"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// ParseNFLResult parses NFL result from raw JSON data +func ParseNFLResult(data json.RawMessage) (*NFLResultResponse, error) { + var result NFLResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyResult parses Rugby result from raw JSON data +func ParseRugbyResult(data json.RawMessage) (*RugbyResultResponse, error) { + var result RugbyResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyUnionResult parses Rugby Union result from raw JSON data +func ParseRugbyUnionResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseRugbyLeagueResult parses Rugby League result from raw JSON data +func ParseRugbyLeagueResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseBaseballResult parses Baseball result from raw JSON data +func ParseBaseballResult(data json.RawMessage) (*BaseballResultResponse, error) { + var result BaseballResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetNFLWinner determines the winner of an NFL game +func GetNFLWinner(result *NFLResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetRugbyWinner determines the winner of a Rugby game +func GetRugbyWinner(result *RugbyResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetBaseballWinner determines the winner of a Baseball game +func GetBaseballWinner(result *BaseballResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// FormatNFLScore formats the NFL score in a readable format +func FormatNFLScore(result *NFLResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatRugbyScore formats the Rugby score in a readable format +func FormatRugbyScore(result *RugbyResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatBaseballScore formats the Baseball score in a readable format +func FormatBaseballScore(result *BaseballResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 1cd27d6..bdafe6c 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -62,9 +62,26 @@ type UpdateUserReq struct { FirstName ValidString LastName ValidString Suspended ValidBool + CompanyID ValidInt64 } type UpdateUserReferalCode struct { UserID int64 Code string } + +type GetCashier struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` + BranchID int64 `json:"branch_id"` +} diff --git a/internal/domain/wallet.go b/internal/domain/wallet.go index 33e9466..387dbd7 100644 --- a/internal/domain/wallet.go +++ b/internal/domain/wallet.go @@ -19,7 +19,6 @@ type CustomerWallet struct { RegularID int64 StaticID int64 CustomerID int64 - CompanyID int64 } type GetCustomerWallet struct { ID int64 @@ -28,7 +27,6 @@ type GetCustomerWallet struct { StaticID int64 StaticBalance Currency CustomerID int64 - CompanyID int64 RegularUpdatedAt time.Time StaticUpdatedAt time.Time CreatedAt time.Time @@ -56,7 +54,6 @@ type CreateWallet struct { type CreateCustomerWallet struct { CustomerID int64 - CompanyID int64 RegularWalletID int64 StaticWalletID int64 } diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 5ff779f..81a501c 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -2,6 +2,7 @@ package repository import ( "context" + // "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" @@ -225,12 +226,26 @@ func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]do } return result, nil } -func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ + +func (s *Store) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) { + outcomes, err := s.queries.GetBetOutcomeByBetID(ctx, betID) + if err != nil { + return nil, nil + } + var result []domain.BetOutcome = make([]domain.BetOutcome, 0, len(outcomes)) + + for _, outcome := range outcomes { + result = append(result, convertDBBetOutcomes(outcome)) + } + return result, nil +} +func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { + update, err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ Status: int32(status), ID: id, }) - return err + res := convertDBBetOutcomes(update) + return res, err } func (s *Store) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/repository/company.go b/internal/repository/company.go index 8f3fe1a..d9b8e06 100644 --- a/internal/repository/company.go +++ b/internal/repository/company.go @@ -25,14 +25,17 @@ func convertDBCompany(dbCompany dbgen.Company) domain.Company { } } -func convertDBCompanyWithWallet(dbCompany dbgen.CompaniesWithWallet) domain.GetCompany { +func convertDBCompanyDetails(dbCompany dbgen.CompaniesDetail) domain.GetCompany { return domain.GetCompany{ - ID: dbCompany.ID, - Name: dbCompany.Name, - AdminID: dbCompany.AdminID, - WalletID: dbCompany.WalletID, - WalletBalance: domain.Currency(dbCompany.Balance), - IsWalletActive: dbCompany.IsActive, + ID: dbCompany.ID, + Name: dbCompany.Name, + AdminID: dbCompany.AdminID, + WalletID: dbCompany.WalletID, + WalletBalance: domain.Currency(dbCompany.Balance), + IsWalletActive: dbCompany.IsActive, + AdminFirstName: dbCompany.AdminFirstName, + AdminLastName: dbCompany.AdminLastName, + AdminPhoneNumber: dbCompany.AdminPhoneNumber.String, } } @@ -74,7 +77,7 @@ func (s *Store) GetAllCompanies(ctx context.Context) ([]domain.GetCompany, error var companies []domain.GetCompany = make([]domain.GetCompany, 0, len(dbCompanies)) for _, dbCompany := range dbCompanies { - companies = append(companies, convertDBCompanyWithWallet(dbCompany)) + companies = append(companies, convertDBCompanyDetails(dbCompany)) } return companies, nil @@ -92,7 +95,7 @@ func (s *Store) SearchCompanyByName(ctx context.Context, name string) ([]domain. var companies []domain.GetCompany = make([]domain.GetCompany, 0, len(dbCompanies)) for _, dbCompany := range dbCompanies { - companies = append(companies, convertDBCompanyWithWallet(dbCompany)) + companies = append(companies, convertDBCompanyDetails(dbCompany)) } return companies, nil } @@ -103,7 +106,7 @@ func (s *Store) GetCompanyByID(ctx context.Context, id int64) (domain.GetCompany if err != nil { return domain.GetCompany{}, err } - return convertDBCompanyWithWallet(dbCompany), nil + return convertDBCompanyDetails(dbCompany), nil } func (s *Store) UpdateCompany(ctx context.Context, company domain.UpdateCompany) (domain.Company, error) { diff --git a/internal/repository/event.go b/internal/repository/event.go index 630cd39..b65e034 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -117,7 +117,8 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcoming return upcomingEvents, nil } -func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) { +func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { + events, err := s.queries.GetPaginatedUpcomingEvents(ctx, dbgen.GetPaginatedUpcomingEventsParams{ LeagueID: pgtype.Text{ String: leagueID.Value, @@ -127,14 +128,27 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, off String: sportID.Value, Valid: sportID.Valid, }, - Limit: limit, - Offset: offset * limit, + Limit: pgtype.Int4{ + Int32: int32(limit.Value), + Valid: limit.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(offset.Value * limit.Value), + Valid: offset.Valid, + }, + FirstStartTime: pgtype.Timestamp{ + Time: firstStartTime.Value.UTC(), + Valid: firstStartTime.Valid, + }, + LastStartTime: pgtype.Timestamp{ + Time: lastStartTime.Value.UTC(), + Valid: lastStartTime.Valid, + }, }) if err != nil { return nil, 0, err } - upcomingEvents := make([]domain.UpcomingEvent, len(events)) for i, e := range events { upcomingEvents[i] = domain.UpcomingEvent{ @@ -162,12 +176,20 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, off String: sportID.Value, Valid: sportID.Valid, }, + FirstStartTime: pgtype.Timestamp{ + Time: firstStartTime.Value.UTC(), + Valid: firstStartTime.Valid, + }, + LastStartTime: pgtype.Timestamp{ + Time: lastStartTime.Value.UTC(), + Valid: lastStartTime.Valid, + }, }) if err != nil { return nil, 0, err } - numberOfPages := math.Ceil(float64(totalCount) / float64(limit)) + numberOfPages := math.Ceil(float64(totalCount) / float64(limit.Value)) return upcomingEvents, int64(numberOfPages), nil } func (s *Store) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) { diff --git a/internal/repository/notification.go b/internal/repository/notification.go index eb922a9..b189ccf 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -15,6 +15,8 @@ type NotificationRepository interface { ListNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, error) ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) + CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) + GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) } type Repository struct { @@ -105,6 +107,24 @@ func (r *Repository) ListNotifications(ctx context.Context, recipientID int64, l return result, nil } +func (r *Repository) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { + + dbNotifications, err := r.store.queries.GetAllNotifications(ctx, dbgen.GetAllNotificationsParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, err + } + + var result []domain.Notification = make([]domain.Notification, 0, len(dbNotifications)) + for _, dbNotif := range dbNotifications { + domainNotif := r.mapDBToDomain(&dbNotif) + result = append(result, *domainNotif) + } + return result, nil +} + func (r *Repository) ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) { dbNotifications, err := r.store.queries.ListFailedNotifications(ctx, int32(limit)) if err != nil { @@ -177,3 +197,7 @@ func unmarshalPayload(data []byte) (domain.NotificationPayload, error) { } return payload, nil } + +func (r *Repository) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { + return r.store.queries.CountUnreadNotifications(ctx, recipient_id) +} diff --git a/internal/repository/odds.go b/internal/repository/odds.go index 31810f5..fd20d1c 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -205,15 +205,54 @@ func (s *Store) GetRawOddsByMarketID(ctx context.Context, rawOddsID string, upco FetchedAt: odds.FetchedAt.Time, }, nil } +func (s *Store) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit domain.ValidInt64, offset domain.ValidInt64) ([]domain.Odd, error) { + odds, err := s.queries.GetPaginatedPrematchOddsByUpcomingID(ctx, dbgen.GetPaginatedPrematchOddsByUpcomingIDParams{ + ID: upcomingID, + Limit: pgtype.Int4{ + Int32: int32(limit.Value), + Valid: limit.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(offset.Value), + Valid: offset.Valid, + }, + }) + if err != nil { + return nil, err + } + // Map the results to domain.Odd + domainOdds := make([]domain.Odd, len(odds)) + for i, odd := range odds { + var rawOdds []domain.RawMessage + if err := json.Unmarshal(odd.RawOdds, &rawOdds); err != nil { + rawOdds = nil + } -func (s *Store) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset int32) ([]domain.Odd, error) { - params := dbgen.GetPrematchOddsByUpcomingIDParams{ - ID: upcomingID, - Limit: limit, - Offset: offset, + domainOdds[i] = domain.Odd{ + EventID: odd.EventID.String, + Fi: odd.Fi.String, + MarketType: odd.MarketType, + MarketName: odd.MarketName.String, + MarketCategory: odd.MarketCategory.String, + MarketID: odd.MarketID.String, + Name: odd.Name.String, + Handicap: odd.Handicap.String, + OddsValue: odd.OddsValue.Float64, + Section: odd.Section, + Category: odd.Category.String, + RawOdds: rawOdds, + FetchedAt: odd.FetchedAt.Time, + Source: odd.Source.String, + IsActive: odd.IsActive.Bool, + } } - odds, err := s.queries.GetPrematchOddsByUpcomingID(ctx, params) + return domainOdds, nil +} + +func (s *Store) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) { + + odds, err := s.queries.GetPrematchOddsByUpcomingID(ctx, upcomingID) if err != nil { return nil, err } diff --git a/internal/repository/user.go b/internal/repository/user.go index 3c0c910..7405542 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" @@ -90,8 +91,14 @@ func (s *Store) GetAllUsers(ctx context.Context, filter user.Filter) ([]domain.U Int64: filter.CompanyID.Value, Valid: filter.CompanyID.Valid, }, - Limit: int32(filter.PageSize), - Offset: int32(filter.Page), + Limit: pgtype.Int4{ + Int32: int32(filter.PageSize.Value), + Valid: filter.PageSize.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(filter.Page.Value), + Valid: filter.Page.Valid, + }, }) if err != nil { return nil, 0, err @@ -123,14 +130,14 @@ func (s *Store) GetAllUsers(ctx context.Context, filter user.Filter) ([]domain.U return userList, totalCount, nil } -func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.User, error) { +func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) { users, err := s.queries.GetAllCashiers(ctx) if err != nil { return nil, err } - userList := make([]domain.User, len(users)) + userList := make([]domain.GetCashier, len(users)) for i, user := range users { - userList[i] = domain.User{ + userList[i] = domain.GetCashier{ ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, @@ -148,6 +155,28 @@ func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.User, error) { return userList, nil } +func (s *Store) GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) { + user, err := s.queries.GetCashierByID(ctx, cashierID) + if err != nil { + return domain.GetCashier{}, err + } + return domain.GetCashier{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt.Time, + UpdatedAt: user.UpdatedAt.Time, + SuspendedAt: user.SuspendedAt.Time, + Suspended: user.Suspended, + BranchID: user.BranchID, + }, nil +} + func (s *Store) GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) { users, err := s.queries.GetCashiersByBranch(ctx, branchID) if err != nil { @@ -173,11 +202,28 @@ func (s *Store) GetCashiersByBranch(ctx context.Context, branchID int64) ([]doma return userList, nil } -func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) { - users, err := s.queries.SearchUserByNameOrPhone(ctx, pgtype.Text{ - String: searchString, - Valid: true, - }) +func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) { + + query := dbgen.SearchUserByNameOrPhoneParams{ + Column1: pgtype.Text{ + String: searchString, + Valid: true, + }, + CompanyID: pgtype.Int8{ + Int64: companyID.Value, + Valid: companyID.Valid, + }, + } + + if role != nil { + + query.Role = pgtype.Text{ + String: string(*role), + Valid: true, + } + } + + users, err := s.queries.SearchUserByNameOrPhone(ctx, query) if err != nil { return nil, err } @@ -204,13 +250,12 @@ func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string func (s *Store) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { err := s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{ - // ID: user.ID, - // FirstName: user.FirstName, - // LastName: user.LastName, - // Email: user.Email, - // PhoneNumber: user.PhoneNumber, - + ID: user.UserId, + FirstName: user.FirstName.Value, + LastName: user.LastName.Value, + Suspended: user.Suspended.Value, }) + fmt.Printf("Updating User %v with values %v", user.UserId, user) if err != nil { return err } @@ -230,6 +275,22 @@ func (s *Store) UpdateUserCompany(ctx context.Context, id int64, companyID int64 } return nil } + +func (s *Store) UpdateUserSuspend(ctx context.Context, id int64, status bool) error { + err := s.queries.SuspendUser(ctx, dbgen.SuspendUserParams{ + ID: id, + Suspended: status, + SuspendedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + return err + } + return nil +} + func (s *Store) DeleteUser(ctx context.Context, id int64) error { err := s.queries.DeleteUser(ctx, id) if err != nil { diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go index 86bf670..54fd077 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -36,13 +36,11 @@ func convertDBCustomerWallet(customerWallet dbgen.CustomerWallet) domain.Custome RegularID: customerWallet.RegularWalletID, StaticID: customerWallet.StaticWalletID, CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, } } func convertCreateCustomerWallet(customerWallet domain.CreateCustomerWallet) dbgen.CreateCustomerWalletParams { return dbgen.CreateCustomerWalletParams{ CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, RegularWalletID: customerWallet.RegularWalletID, StaticWalletID: customerWallet.StaticWalletID, } @@ -56,7 +54,6 @@ func convertDBGetCustomerWallet(customerWallet dbgen.GetCustomerWalletRow) domai StaticID: customerWallet.StaticID, StaticBalance: domain.Currency(customerWallet.StaticBalance), CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, RegularUpdatedAt: customerWallet.RegularUpdatedAt.Time, StaticUpdatedAt: customerWallet.StaticUpdatedAt.Time, CreatedAt: customerWallet.CreatedAt.Time, @@ -117,11 +114,8 @@ func (s *Store) GetWalletsByUser(ctx context.Context, userID int64) ([]domain.Wa return result, nil } -func (s *Store) GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) { - customerWallet, err := s.queries.GetCustomerWallet(ctx, dbgen.GetCustomerWalletParams{ - CustomerID: customerID, - CompanyID: companyID, - }) +func (s *Store) GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) { + customerWallet, err := s.queries.GetCustomerWallet(ctx, customerID) if err != nil { return domain.GetCustomerWallet{}, err diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index d5ea609..cdd1ea0 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -13,8 +13,10 @@ type BetStore interface { GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) GetAllBets(ctx context.Context) ([]domain.GetBet, error) GetBetByBranchID(ctx context.Context, BranchID int64) ([]domain.GetBet, error) + GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]domain.BetOutcome, error) + GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error - UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error + UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) DeleteBet(ctx context.Context, id int64) error } diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 1a2cb8d..4e3f9bf 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -3,21 +3,56 @@ package bet import ( "context" "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log/slog" "math/big" + random "math/rand" + "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" +) + +var ( + ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") + ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") + ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") + ErrEventHasBeenRemoved = errors.New("Event has been removed") ) type Service struct { - betStore BetStore + betStore BetStore + eventSvc event.Service + prematchSvc odds.Service + walletSvc wallet.Service + branchSvc branch.Service + logger *slog.Logger } -func NewService(betStore BetStore) *Service { +func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Service, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger) *Service { return &Service{ - betStore: betStore, + betStore: betStore, + eventSvc: eventSvc, + prematchSvc: prematchSvc, + walletSvc: walletSvc, + branchSvc: branchSvc, + logger: logger, } } +var ( + ErrEventHasNotEnded = errors.New("Event has not ended yet") + ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") + ErrBranchIDRequired = errors.New("Branch ID required for this role") + ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") +) + func (s *Service) GenerateCashoutID() (string, error) { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" const length int = 13 @@ -33,8 +68,402 @@ func (s *Service) GenerateCashoutID() (string, error) { return string(result), nil } -func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { +func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateBetOutcome, error) { + // TODO: Change this when you refactor the database code + eventIDStr := strconv.FormatInt(eventID, 10) + marketIDStr := strconv.FormatInt(marketID, 10) + oddIDStr := strconv.FormatInt(oddID, 10) + event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) + if err != nil { + return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved + } + + currentTime := time.Now() + if event.StartTime.Before(currentTime) { + return domain.CreateBetOutcome{}, ErrEventHasNotEnded + } + + odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr) + + if err != nil { + return domain.CreateBetOutcome{}, err + } + type rawOddType struct { + ID string + Name string + Odds string + Header string + Handicap string + } + + var selectedOdd rawOddType + var isOddFound bool = false + + for _, raw := range odds.RawOdds { + var rawOdd rawOddType + rawBytes, err := json.Marshal(raw) + err = json.Unmarshal(rawBytes, &rawOdd) + if err != nil { + fmt.Printf("Failed to unmarshal raw odd %v", err) + continue + } + if rawOdd.ID == oddIDStr { + selectedOdd = rawOdd + isOddFound = true + } + } + if !isOddFound { + return domain.CreateBetOutcome{}, ErrRawOddInvalid + } + + parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) + if err != nil { + return domain.CreateBetOutcome{}, err + } + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + if err != nil { + return domain.CreateBetOutcome{}, err + } + newOutcome := domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: sportID, + HomeTeamName: event.HomeTeam, + AwayTeamName: event.AwayTeam, + MarketName: odds.MarketName, + Odd: float32(parsedOdd), + OddName: selectedOdd.Name, + OddHeader: selectedOdd.Header, + OddHandicap: selectedOdd.Handicap, + Expires: event.StartTime, + } + + return newOutcome, nil + +} + +func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role) (domain.CreateBetRes, error) { + // You can move the loop over req.Outcomes and all the business logic here. + + if len(req.Outcomes) > 30 { + return domain.CreateBetRes{}, ErrOutcomeLimit + } + + var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) + var totalOdds float32 = 1 + + for _, outcomeReq := range req.Outcomes { + newOutcome, err := s.GenerateBetOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID) + if err != nil { + return domain.CreateBetRes{}, err + } + totalOdds = totalOdds * float32(newOutcome.Odd) + outcomes = append(outcomes, newOutcome) + } + + // Handle role-specific logic and wallet deduction if needed. + var cashoutID string + cashoutID, err := s.GenerateCashoutID() + + if err != nil { + return domain.CreateBetRes{}, err + } + + newBet := domain.CreateBet{ + Amount: domain.ToCurrency(req.Amount), + TotalOdds: totalOdds, + Status: req.Status, + FullName: req.FullName, + PhoneNumber: req.PhoneNumber, + CashoutID: cashoutID, + } + switch role { + case domain.RoleCashier: + branch, err := s.branchSvc.GetBranchByCashier(ctx, userID) + if err != nil { + return domain.CreateBetRes{}, err + } + // Deduct from wallet: + // TODO: Make this percentage come from the company + var deductedAmount = req.Amount / 10 + err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) + + if err != nil { + return domain.CreateBetRes{}, err + } + newBet.BranchID = domain.ValidInt64{ + Value: branch.ID, + Valid: true, + } + newBet.UserID = domain.ValidInt64{ + Value: userID, + Valid: true, + } + newBet.IsShopBet = true + // bet, err = s.betStore.CreateBet(ctx) + case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin: + // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company + // If a non cashier wants to create a bet, they will need to provide the Branch ID + if req.BranchID == nil { + return domain.CreateBetRes{}, ErrBranchIDRequired + } + + branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID) + if err != nil { + return domain.CreateBetRes{}, err + } + // Deduct from wallet: + // TODO: Make this percentage come from the company + var deductedAmount = req.Amount / 10 + err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) + + if err != nil { + return domain.CreateBetRes{}, err + } + + newBet.BranchID = domain.ValidInt64{ + Value: branch.ID, + Valid: true, + } + newBet.UserID = domain.ValidInt64{ + Value: userID, + Valid: true, + } + newBet.IsShopBet = true + case domain.RoleCustomer: + // Get User Wallet + + wallet, err := s.walletSvc.GetWalletsByUser(ctx, userID) + + if err != nil { + return domain.CreateBetRes{}, err + } + + userWallet := wallet[0] + + err = s.walletSvc.DeductFromWallet(ctx, userWallet.ID, domain.ToCurrency(req.Amount)) + if err != nil { + return domain.CreateBetRes{}, err + } + + default: + return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") + } + + bet, err := s.CreateBet(ctx, newBet) + + if err != nil { + return domain.CreateBetRes{}, err + } + + // Associate outcomes with the bet. + for i := range outcomes { + outcomes[i].BetID = bet.ID + } + rows, err := s.betStore.CreateBetOutcome(ctx, outcomes) + if err != nil { + return domain.CreateBetRes{}, err + } + + res := domain.ConvertCreateBet(bet, rows) + + return res, nil +} + +func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportID, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { + + var newOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + + markets, err := s.prematchSvc.GetPrematchOddsByUpcomingID(ctx, eventID) + + if err != nil { + s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) + return nil, 0, err + } + + if len(markets) == 0 { + s.logger.Error("empty odds for event", "event id", eventID) + return nil, 0, fmt.Errorf("empty odds or event %v", eventID) + } + + var selectedMarkets []domain.Odd + numMarkets = min(numMarkets, len(markets)) + for i := 0; i < numMarkets; i++ { + randomIndex := random.Intn(len(markets)) + selectedMarkets = append(selectedMarkets, markets[randomIndex]) + markets = append(markets[:randomIndex], markets[randomIndex+1:]...) + } + + for _, market := range selectedMarkets { + + randomRawOdd := market.RawOdds[random.Intn(len(market.RawOdds))] + + type rawOddType struct { + ID string + Name string + Odds string + Header string + Handicap string + } + + var selectedOdd rawOddType + rawBytes, err := json.Marshal(randomRawOdd) + err = json.Unmarshal(rawBytes, &selectedOdd) + + if err != nil { + fmt.Printf("Failed to unmarshal raw odd %v", err) + continue + } + parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) + if err != nil { + s.logger.Error("Failed to parse odd", "error", err) + continue + } + sportID, err := strconv.ParseInt(sportID, 10, 64) + if err != nil { + s.logger.Error("Failed to get sport id", "error", err) + continue + } + eventID, err := strconv.ParseInt(eventID, 10, 64) + if err != nil { + s.logger.Error("Failed to get event id", "error", err) + continue + } + oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) + if err != nil { + s.logger.Error("Failed to get odd id", "error", err) + continue + } + + marketID, err := strconv.ParseInt(market.MarketID, 10, 64) + if err != nil { + s.logger.Error("Failed to get odd id", "error", err) + continue + } + + marketName := market.MarketName + + newOdds = append(newOdds, domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: sportID, + HomeTeamName: HomeTeam, + AwayTeamName: AwayTeam, + MarketName: marketName, + Odd: float32(parsedOdd), + OddName: selectedOdd.Name, + OddHeader: selectedOdd.Header, + OddHandicap: selectedOdd.Handicap, + Expires: StartTime, + }) + + totalOdds = totalOdds * float32(parsedOdd) + + } + + if len(newOdds) == 0 { + s.logger.Error("Bet Outcomes is empty for market", "selectedMarket", selectedMarkets[0].MarketName) + return nil, 0, ErrGenerateRandomOutcome + } + + return newOdds, totalOdds, nil +} + +func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID, sportID domain.ValidString, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { + + // Get a unexpired event id + + events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, + domain.ValidInt64{}, domain.ValidInt64{}, leagueID, sportID, firstStartTime, lastStartTime) + + if err != nil { + return domain.CreateBetRes{}, err + } + + if len(events) == 0 { + return domain.CreateBetRes{}, ErrNoEventsAvailable + } + + // TODO: Add the option of passing number of created events + var selectedUpcomingEvents []domain.UpcomingEvent + numEventsPerBet := min(random.Intn(4)+1, len(events)) //Eliminate the option of 0 + + for i := 0; i < int(numEventsPerBet); i++ { + randomIndex := random.Intn(len(events)) + selectedUpcomingEvents = append(selectedUpcomingEvents, events[randomIndex]) + events = append(events[:randomIndex], events[randomIndex+1:]...) + + } + + // s.logger.Info("Generating random bet events", "selectedUpcomingEvents", len(selectedUpcomingEvents)) + + // Get market and odds for that + var randomOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + numMarketsPerBet := random.Intn(2) + 1 + for _, event := range selectedUpcomingEvents { + + newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime, numMarketsPerBet) + + if err != nil { + s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) + continue + } + + randomOdds = append(randomOdds, newOdds...) + totalOdds = totalOdds * total + + } + if len(randomOdds) == 0 { + s.logger.Error("Failed to generate random any outcomes for all events") + return domain.CreateBetRes{}, ErrGenerateRandomOutcome + } + + // s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) + + var cashoutID string + + cashoutID, err = s.GenerateCashoutID() + if err != nil { + return domain.CreateBetRes{}, err + } + + randomNumber := strconv.FormatInt(int64(random.Intn(100000000000)), 10) + newBet := domain.CreateBet{ + Amount: domain.ToCurrency(123.5), + TotalOdds: totalOdds, + Status: domain.OUTCOME_STATUS_PENDING, + FullName: "test" + randomNumber, + PhoneNumber: "0900000000", + CashoutID: cashoutID, + BranchID: domain.ValidInt64{Valid: true, Value: branchID}, + UserID: domain.ValidInt64{Valid: true, Value: userID}, + } + + bet, err := s.CreateBet(ctx, newBet) + if err != nil { + return domain.CreateBetRes{}, err + } + + for i := range randomOdds { + randomOdds[i].BetID = bet.ID + } + + rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) + if err != nil { + return domain.CreateBetRes{}, err + } + + res := domain.ConvertCreateBet(bet, rows) + + return res, nil +} + +func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { return s.betStore.CreateBet(ctx, bet) } @@ -64,8 +493,100 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return s.betStore.UpdateStatus(ctx, id, status) } -func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - return s.betStore.UpdateBetOutcomeStatus(ctx, id, status) +func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { + betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, err + } + status := domain.OUTCOME_STATUS_PENDING + + for _, betOutcome := range betOutcomes { + // If any of the bet outcomes are pending return + if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { + return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted + } + + if betOutcome.Status == domain.OUTCOME_STATUS_ERROR { + return domain.OUTCOME_STATUS_ERROR, nil + } + + // The bet status can only be updated if its not lost or error + // If all the bet outcomes are a win, then set the bet status to win + // If even one of the bet outcomes is a loss then set the bet status to loss + // If even one of the bet outcomes is an error, then set the bet status to error + switch status { + case domain.OUTCOME_STATUS_PENDING: + status = betOutcome.Status + case domain.OUTCOME_STATUS_WIN: + if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_HALF + } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_WIN + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_LOSS: + if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_LOSS + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_VOID: + if betOutcome.Status == domain.OUTCOME_STATUS_VOID || + betOutcome.Status == domain.OUTCOME_STATUS_WIN || + betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_VOID + } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_HALF: + if betOutcome.Status == domain.OUTCOME_STATUS_HALF || + betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_HALF + } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + default: + // If the status is not pending, win, loss or error, then set the status to error + status = domain.OUTCOME_STATUS_ERROR + } + } + + if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR { + // If the status is pending or error, then we don't need to update the bet + s.logger.Info("bet not updated", "bet id", betID, "status", status) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("Error when processing bet outcomes") + } + + return status, nil + +} + +func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { + betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) + if err != nil { + return domain.BetOutcome{}, err + } + + return betOutcome, err + } func (s *Service) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/services/event/port.go b/internal/services/event/port.go index 05fd33b..94f4313 100644 --- a/internal/services/event/port.go +++ b/internal/services/event/port.go @@ -11,7 +11,7 @@ type Service interface { FetchUpcomingEvents(ctx context.Context) error GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) - GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) + GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) // GetAndStoreMatchResult(ctx context.Context, eventID string) error diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 70b4f98..fe51aa7 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -99,18 +99,17 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { } func (s *service) FetchUpcomingEvents(ctx context.Context) error { - sportIDs := []int{1, 18} - var totalPages int = 1 - var page int = 0 - var limit int = 100 - var count int = 0 - for _, sportID := range sportIDs { - for page != totalPages { - time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour + sportIDs := []int{1, 18, 17} + for _, sportID := range sportIDs { + var totalPages int = 1 + var page int = 0 + var limit int = 10 + var count int = 0 + for page <= totalPages { page = page + 1 url := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s&page=%d", sportID, s.token, page) - log.Printf("📡 Fetching data for event data page %d", page) + log.Printf("📡 Fetching data for sport %d event data page %d/%d", sportID, page, min(limit, totalPages)) resp, err := http.Get(url) if err != nil { log.Printf("❌ Failed to fetch event data for page %d: %v", page, err) @@ -145,9 +144,10 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } `json:"results"` } if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + log.Printf("❌ Failed to parse json data") continue } - skippedLeague := 0 + var skippedLeague []string for _, ev := range data.Results { startUnix, _ := strconv.ParseInt(ev.Time, 10, 64) // eventID, err := strconv.ParseInt(ev.ID, 10, 64) @@ -163,14 +163,15 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } if !slices.Contains(domain.SupportedLeagues, leagueID) { - skippedLeague++ + // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) + skippedLeague = append(skippedLeague, ev.League.Name) continue } event := domain.UpcomingEvent{ ID: ev.ID, SportID: ev.SportID, - MatchName: ev.Home.Name, + MatchName: "", HomeTeam: ev.Home.Name, AwayTeam: "", // handle nil safely HomeTeamID: ev.Home.ID, @@ -186,13 +187,23 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { if ev.Away != nil { event.AwayTeam = ev.Away.Name event.AwayTeamID = ev.Away.ID + event.MatchName = ev.Home.Name + " vs " + ev.Away.Name } - _ = s.store.SaveUpcomingEvent(ctx, event) + err = s.store.SaveUpcomingEvent(ctx, event) + if err != nil { + log.Printf("❌ Failed to save upcoming event %s", event.ID) + } } - totalPages = data.Pager.Total - if count > limit { + log.Printf("⚠️ Skipped leagues %v", len(skippedLeague)) + // log.Printf("⚠️ Total pages %v", data.Pager.Total) + totalPages = data.Pager.Total / data.Pager.PerPage + + if count >= limit { + break + } + if page > totalPages { break } count++ @@ -223,8 +234,8 @@ func (s *service) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcomi return s.store.GetExpiredUpcomingEvents(ctx) } -func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) { - return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID) +func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { + return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID, firstStartTime, lastStartTime) } func (s *service) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) { diff --git a/internal/services/notfication/port.go b/internal/services/notfication/port.go index 9fa2f72..ec82c03 100644 --- a/internal/services/notfication/port.go +++ b/internal/services/notfication/port.go @@ -4,7 +4,7 @@ import ( "context" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/gofiber/websocket/v2" + "github.com/gorilla/websocket" ) type NotificationStore interface { @@ -16,4 +16,6 @@ type NotificationStore interface { SendSMS(ctx context.Context, recipientID int64, message string) error SendEmail(ctx context.Context, recipientID int64, subject, message string) error ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) // New method + CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) + GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) } diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index e21f7da..5d5760c 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -11,12 +11,14 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" afro "github.com/amanuelabay/afrosms-go" - "github.com/gofiber/websocket/v2" + "github.com/gorilla/websocket" ) type Service struct { repo repository.NotificationRepository + Hub *ws.NotificationHub connections sync.Map notificationCh chan *domain.Notification stopCh chan struct{} @@ -24,9 +26,11 @@ type Service struct { logger *slog.Logger } -func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) NotificationStore { +func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service { + hub := ws.NewNotificationHub() svc := &Service{ repo: repo, + Hub: hub, logger: logger, connections: sync.Map{}, notificationCh: make(chan *domain.Notification, 1000), @@ -34,6 +38,7 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi config: cfg, } + go hub.Run() go svc.startWorker() go svc.startRetryWorker() @@ -63,22 +68,48 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not notification = created + if notification.DeliveryChannel == domain.DeliveryChannelInApp { + s.Hub.Broadcast <- map[string]interface{}{ + "type": "CREATED_NOTIFICATION", + "recipient_id": notification.RecipientID, + "payload": notification, + } + } + select { case s.notificationCh <- notification: default: - s.logger.Error("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID) + s.logger.Warn("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID) } return nil } -func (s *Service) MarkAsRead(ctx context.Context, notificationID string, recipientID int64) error { - _, err := s.repo.UpdateNotificationStatus(ctx, notificationID, string(domain.DeliveryStatusSent), true, nil) - if err != nil { - s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) - return err +func (s *Service) MarkAsRead(ctx context.Context, notificationIDs []string, recipientID int64) error { + for _, notificationID := range notificationIDs { + _, err := s.repo.UpdateNotificationStatus(ctx, notificationID, string(domain.DeliveryStatusSent), true, nil) + if err != nil { + s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) + return err + } + + // count, err := s.repo.CountUnreadNotifications(ctx, recipientID) + // if err != nil { + // s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err) + // return err + // } + + // s.Hub.Broadcast <- map[string]interface{}{ + // "type": "COUNT_NOT_OPENED_NOTIFICATION", + // "recipient_id": recipientID, + // "payload": map[string]int{ + // "not_opened_notifications_count": int(count), + // }, + // } + + s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) } - s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) + return nil } @@ -92,6 +123,16 @@ func (s *Service) ListNotifications(ctx context.Context, recipientID int64, limi return notifications, nil } +func (s *Service) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { + notifications, err := s.repo.GetAllNotifications(ctx, limit, offset) + if err != nil { + s.logger.Error("[NotificationSvc.ListNotifications] Failed to get all notifications") + return nil, err + } + s.logger.Info("[NotificationSvc.ListNotifications] Successfully retrieved all notifications", "count", len(notifications)) + return notifications, nil +} + func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { s.addConnection(ctx, recipientID, c) s.logger.Info("[NotificationSvc.ConnectWebSocket] WebSocket connection established", "recipientID", recipientID) @@ -99,7 +140,6 @@ func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *we } func (s *Service) DisconnectWebSocket(recipientID int64) { - s.connections.Delete(recipientID) if conn, loaded := s.connections.LoadAndDelete(recipientID); loaded { conn.(*websocket.Conn).Close() s.logger.Info("[NotificationSvc.DisconnectWebSocket] Disconnected WebSocket", "recipientID", recipientID) @@ -160,21 +200,26 @@ func (s *Service) ListRecipientIDs(ctx context.Context, receiver domain.Notifica func (s *Service) handleNotification(notification *domain.Notification) { ctx := context.Background() - if conn, ok := s.connections.Load(notification.RecipientID); ok { - data, err := notification.ToJSON() + switch notification.DeliveryChannel { + case domain.DeliveryChannelSMS: + err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message) if err != nil { - s.logger.Error("[NotificationSvc.HandleNotification] Failed to serialize notification", "id", notification.ID, "error", err) - return - } - if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err != nil { - s.logger.Error("[NotificationSvc.HandleNotification] Failed to send WebSocket message", "id", notification.ID, "error", err) notification.DeliveryStatus = domain.DeliveryStatusFailed } else { notification.DeliveryStatus = domain.DeliveryStatusSent } - } else { - s.logger.Warn("[NotificationSvc.HandleNotification] No WebSocket connection for recipient", "recipientID", notification.RecipientID) - notification.DeliveryStatus = domain.DeliveryStatusFailed + case domain.DeliveryChannelEmail: + err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message) + if err != nil { + notification.DeliveryStatus = domain.DeliveryStatusFailed + } else { + notification.DeliveryStatus = domain.DeliveryStatusSent + } + default: + if notification.DeliveryChannel != domain.DeliveryChannelInApp { + s.logger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", "channel", notification.DeliveryChannel) + notification.DeliveryStatus = domain.DeliveryStatusFailed + } } if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { @@ -210,13 +255,17 @@ func (s *Service) retryFailedNotifications() { go func(notification *domain.Notification) { for attempt := 0; attempt < 3; attempt++ { time.Sleep(time.Duration(attempt) * time.Second) - if conn, ok := s.connections.Load(notification.RecipientID); ok { - data, err := notification.ToJSON() - if err != nil { - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to serialize notification for retry", "id", notification.ID, "error", err) - continue + if notification.DeliveryChannel == domain.DeliveryChannelSMS { + if err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message); err == nil { + notification.DeliveryStatus = domain.DeliveryStatusSent + if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { + s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) + } + s.logger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", "id", notification.ID) + return } - if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err == nil { + } else if notification.DeliveryChannel == domain.DeliveryChannelEmail { + if err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message); err == nil { notification.DeliveryStatus = domain.DeliveryStatusSent if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) @@ -230,3 +279,7 @@ func (s *Service) retryFailedNotifications() { }(notification) } } + +func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { + return s.repo.CountUnreadNotifications(ctx, recipient_id) +} diff --git a/internal/services/odds/port.go b/internal/services/odds/port.go index 69fd5ee..50275b2 100644 --- a/internal/services/odds/port.go +++ b/internal/services/odds/port.go @@ -9,6 +9,8 @@ import ( type Service interface { FetchNonLiveOdds(ctx context.Context) error GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) + GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) + GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit domain.ValidInt64, offset domain.ValidInt64) ([]domain.Odd, error) GetALLPrematchOdds(ctx context.Context) ([]domain.Odd, error) GetRawOddsByMarketID(ctx context.Context, marketID string, upcomingID string) (domain.RawOddsByMarketID, error) } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index c60e8c6..36f3a8a 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -3,26 +3,37 @@ package odds import ( "context" "encoding/json" + "errors" + "fmt" "io" "log" + "log/slog" "net/http" "strconv" "time" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" ) type ServiceImpl struct { - token string - store *repository.Store + store *repository.Store + config *config.Config + logger *slog.Logger + client *http.Client } -func New(token string, store *repository.Store) *ServiceImpl { - return &ServiceImpl{token: token, store: store} +func New(store *repository.Store, cfg *config.Config, logger *slog.Logger) *ServiceImpl { + return &ServiceImpl{ + store: store, + config: cfg, + logger: logger, + client: &http.Client{Timeout: 10 * time.Second}, + } } -// TODO this is only getting the main odds, this must be fixed +// TODO Add the optimization to get 10 events at the same time func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { eventIDs, err := s.store.GetAllUpcomingEvents(ctx) if err != nil { @@ -30,86 +41,252 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { return err } - for _, event := range eventIDs { - // time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour + var errs []error - eventID := event.ID - prematchURL := "https://api.b365api.com/v3/bet365/prematch?token=" + s.token + "&FI=" + eventID - log.Printf("📡 Fetching prematch odds for event ID: %s", eventID) - resp, err := http.Get(prematchURL) + for index, event := range eventIDs { + + eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { - log.Printf("❌ Failed to fetch prematch odds for event %s: %v", eventID, err) + s.logger.Error("Failed to parse event id") + return err + } + + url := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%d", s.config.Bet365Token, eventID) + + log.Printf("📡 Fetching prematch odds for event ID: %d (%d/%d) ", eventID, index, len(eventIDs)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("❌ Failed to create request for event %d: %v", eventID, err) + continue + } + + resp, err := s.client.Do(req) + if err != nil { + log.Printf("❌ Failed to fetch prematch odds for event %d: %v", eventID, err) continue } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - var oddsData struct { - Success int `json:"success"` - Results []struct { - EventID string `json:"event_id"` - FI string `json:"FI"` - Main OddsSection `json:"main"` - } `json:"results"` + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("❌ Failed to read response body for event %d: %v", eventID, err) + continue } + var oddsData domain.BaseNonLiveOddResponse + if err := json.Unmarshal(body, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { - log.Printf("❌ Invalid prematch data for event %s", eventID) + log.Printf("❌ Invalid prematch data for event %d", eventID) continue } - result := oddsData.Results[0] - finalID := result.EventID - if finalID == "" { - finalID = result.FI + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + + switch sportID { + case domain.FOOTBALL: + if err := s.parseFootball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Error while inserting football odd") + errs = append(errs, err) + } + case domain.BASKETBALL: + if err := s.parseBasketball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Error while inserting basketball odd") + errs = append(errs, err) + } + case domain.ICE_HOCKEY: + if err := s.parseIceHockey(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Error while inserting ice hockey odd") + errs = append(errs, err) + } } - if finalID == "" { - log.Printf("⚠️ Skipping event %s with no valid ID", eventID) - continue - } - s.storeSection(ctx, finalID, result.FI, "main", result.Main) + + // result := oddsData.Results[0] + } return nil } -func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { +func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) error { + var footballRes domain.FootballOddsResponse + if err := json.Unmarshal(res, &footballRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if footballRes.EventID == "" && footballRes.FI == "" { + s.logger.Error("Skipping football result with no valid Event ID", "eventID", footballRes.EventID, "fi", footballRes.FI) + return fmt.Errorf("Skipping football result with no valid Event ID Event ID %v", footballRes.EventID) + } + sections := map[string]domain.OddsSection{ + "main": footballRes.Main, + "asian_lines": footballRes.AsianLines, + "goals": footballRes.Goals, + "half": footballRes.Half, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, footballRes.EventID, footballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Error storing football section", "eventID", footballRes.FI, "odd", oddCategory) + log.Printf("⚠️ Error when storing football %v", err) + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) parseBasketball(ctx context.Context, res json.RawMessage) error { + var basketballRes domain.BasketballOddsResponse + if err := json.Unmarshal(res, &basketballRes); err != nil { + s.logger.Error("Failed to unmarshal basketball result", "error", err) + return err + } + if basketballRes.EventID == "" && basketballRes.FI == "" { + s.logger.Error("Skipping basketball result with no valid Event ID") + return fmt.Errorf("Skipping basketball result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": basketballRes.Main, + "half_props": basketballRes.HalfProps, + "quarter_props": basketballRes.QuarterProps, + "team_props": basketballRes.TeamProps, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range basketballRes.Others { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} +func (s *ServiceImpl) parseIceHockey(ctx context.Context, res json.RawMessage) error { + var iceHockeyRes domain.IceHockeyOddsResponse + if err := json.Unmarshal(res, &iceHockeyRes); err != nil { + s.logger.Error("Failed to unmarshal ice hockey result", "error", err) + return err + } + if iceHockeyRes.EventID == "" && iceHockeyRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": iceHockeyRes.Main, + "main_2": iceHockeyRes.Main2, + "1st_period": iceHockeyRes.FirstPeriod, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range iceHockeyRes.Others { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section domain.OddsSection) error { if len(section.Sp) == 0 { - return + return nil } updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) updatedAt := time.Unix(updatedAtUnix, 0) + var errs []error for marketType, market := range section.Sp { if len(market.Odds) == 0 { continue } + + // Check if the market id is a string + var marketIDstr string + err := json.Unmarshal(market.ID, &marketIDstr) + if err != nil { + // check if its int + var marketIDint int + err := json.Unmarshal(market.ID, &marketIDint) + if err != nil { + s.logger.Error("Invalid market id", "marketID", marketIDstr, "marketName", market.Name) + errs = append(errs, err) + } + } + + marketIDint, err := strconv.ParseInt(marketIDstr, 10, 64) + if err != nil { + s.logger.Error("Invalid market id", "marketID", marketIDstr, "marketName", market.Name) + errs = append(errs, err) + continue + } + + isSupported, ok := domain.SupportedMarkets[marketIDint] + + if !ok || !isSupported { + // s.logger.Info("Unsupported market_id", "marketID", marketIDint, "marketName", market.Name) + continue + } + marketRecord := domain.Market{ EventID: eventID, FI: fi, MarketCategory: sectionName, MarketType: marketType, MarketName: market.Name, - MarketID: market.ID.String(), + MarketID: marketIDstr, UpdatedAt: updatedAt, Odds: market.Odds, } - _ = s.store.SaveNonLiveMarket(ctx, marketRecord) + err = s.store.SaveNonLiveMarket(ctx, marketRecord) + if err != nil { + s.logger.Error("failed to save market", "market_id", market.ID, "error", err) + errs = append(errs, fmt.Errorf("market %s: %w", market.ID, err)) + continue + } } -} -type OddsMarket struct { - ID json.Number `json:"id"` - Name string `json:"name"` - Odds []json.RawMessage `json:"odds"` - Header string `json:"header,omitempty"` - Handicap string `json:"handicap,omitempty"` -} - -type OddsSection struct { - UpdatedAt string `json:"updated_at"` - Sp map[string]OddsMarket `json:"sp"` + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil } func (s *ServiceImpl) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { @@ -129,6 +306,10 @@ func (s *ServiceImpl) GetRawOddsByMarketID(ctx context.Context, marketID string, return rows, nil } -func (s *ServiceImpl) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset int32) ([]domain.Odd, error) { - return s.store.GetPrematchOddsByUpcomingID(ctx, upcomingID, limit, offset) +func (s *ServiceImpl) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) { + return s.store.GetPrematchOddsByUpcomingID(ctx, upcomingID) +} + +func (s *ServiceImpl) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset domain.ValidInt64) ([]domain.Odd, error) { + return s.store.GetPaginatedPrematchOddsByUpcomingID(ctx, upcomingID, limit, offset) } diff --git a/internal/services/result/eval.go b/internal/services/result/eval.go index 8096e3a..bdf7530 100644 --- a/internal/services/result/eval.go +++ b/internal/services/result/eval.go @@ -9,6 +9,8 @@ import ( ) // Football evaluations + +// Full Time Result betting is a type of bet where the bettor predicts the outcome of a match at the end of the full 90 minutes of play. func evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "1": // Home win @@ -27,15 +29,16 @@ func evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Over/Under betting is a type of bet where the bettor predicts whether the total number of goals scored in a match will be over or under a specified number. func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := float64(score.Home + score.Away) threshold, err := strconv.ParseFloat(outcome.OddName, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } if outcome.OddHeader == "Over" { @@ -53,9 +56,10 @@ func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } +// Correct Score betting is a type of bet where the bettor predicts the exact final score of a match. func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { expectedScore := fmt.Sprintf("%d-%d", score.Home, score.Away) if outcome.OddName == expectedScore { @@ -64,6 +68,8 @@ func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away in return domain.OUTCOME_STATUS_LOSS, nil } +// Half Time Result betting is a type of bet where the bettor predicts the outcome of a match at the end of the first half. +// This is the same as the full time result but only for the first half of the game func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { return evaluateFullTimeResult(outcome, score) } @@ -71,43 +77,94 @@ func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away // This is a multiple outcome checker for the asian handicap and other kinds of bets // The only outcome that are allowed are "Both Bets win", "Both Bets Lose", "Half Win and Half Void" func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.OutcomeStatus) (domain.OutcomeStatus, error) { + if secondOutcome == domain.OUTCOME_STATUS_PENDING { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("cannot check pending outcome") + } + + if outcome == domain.OUTCOME_STATUS_ERROR || secondOutcome == domain.OUTCOME_STATUS_ERROR { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("❌ mutli outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + } + + // fmt.Printf("| Multi Outcome | %v -> %v \n", outcome.String(), secondOutcome.String()) + switch outcome { case domain.OUTCOME_STATUS_PENDING: return secondOutcome, nil case domain.OUTCOME_STATUS_WIN: if secondOutcome == domain.OUTCOME_STATUS_WIN { return domain.OUTCOME_STATUS_WIN, nil + } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { + return domain.OUTCOME_STATUS_LOSS, nil + } else if secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_VOID, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { return domain.OUTCOME_STATUS_HALF, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } case domain.OUTCOME_STATUS_LOSS: - if secondOutcome == domain.OUTCOME_STATUS_LOSS { + if secondOutcome == domain.OUTCOME_STATUS_LOSS || + secondOutcome == domain.OUTCOME_STATUS_WIN || + secondOutcome == domain.OUTCOME_STATUS_HALF { return domain.OUTCOME_STATUS_LOSS, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } case domain.OUTCOME_STATUS_VOID: if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_LOSS { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil + } else if secondOutcome == domain.OUTCOME_STATUS_VOID || secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_VOID, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") + } + case domain.OUTCOME_STATUS_HALF: + if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_VOID, nil + } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { + return domain.OUTCOME_STATUS_LOSS, nil + } else if secondOutcome == domain.OUTCOME_STATUS_VOID { + return domain.OUTCOME_STATUS_VOID, nil + } else { + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } } +// Asian Handicap betting is a type of betting that eliminates the possibility of a draw by giving one team a virtual advantage or disadvantage. +// When the handicap has two values like "+0.5, +1.0" or "-0.5, -1.0", then it a multi outcome bet +// . +// +// { +// "id": "548319135", +// "odds": "1.750", +// "header": "1", +// "handicap": "+0.5, +1.0" +// }, +// +// { +// "id": "548319139", +// "odds": "1.950", +// "header": "2", +// "handicap": "-0.5, -1.0" +// } func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { handicapList := strings.Split(outcome.OddHandicap, ",") newOutcome := domain.OUTCOME_STATUS_PENDING for _, handicapStr := range handicapList { + handicapStr = strings.TrimSpace(handicapStr) handicap, err := strconv.ParseFloat(handicapStr, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) } adjustedHomeScore := float64(score.Home) adjustedAwayScore := float64(score.Away) @@ -116,49 +173,123 @@ func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away i } else if outcome.OddHeader == "2" { // Away team adjustedAwayScore += handicap } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } if adjustedHomeScore > adjustedAwayScore { if outcome.OddHeader == "1" { newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } + continue } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } + continue } else if adjustedHomeScore < adjustedAwayScore { if outcome.OddHeader == "2" { newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } + continue } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } - } - newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) - if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + continue + } else if adjustedHomeScore == adjustedAwayScore { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + continue } } return newOutcome, nil } +// Goal Line betting, also known as Over/Under betting, +// involves predicting the total number of goals scored in a match, regardless of which team wins. +// +// { +// "id": "548319141", +// "odds": "1.800", +// "header": "Over", +// "name": "1.5, 2.0" +// }, +// +// { +// "id": "548319146", +// "odds": "1.900", +// "header": "Under", +// "name": "1.5, 2.0" +// } func evaluateGoalLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { - return evaluateGoalsOverUnder(outcome, score) + + totalGoals := float64(score.Home + score.Away) + thresholdList := strings.Split(outcome.OddName, ",") + + newOutcome := domain.OUTCOME_STATUS_PENDING + for _, thresholdStr := range thresholdList { + thresholdStr = strings.TrimSpace(thresholdStr) + threshold, err := strconv.ParseFloat(thresholdStr, 64) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: '%s', %v", thresholdStr, err) + } + + oddHeader := strings.TrimSpace(outcome.OddHeader) + if oddHeader == "Over" { + if totalGoals > threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + } else if totalGoals == threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + } else if oddHeader == "Under" { + if totalGoals < threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } else if totalGoals == threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: '%s'", oddHeader) + } + + } + + return newOutcome, nil } +// First Team To Score betting is a type of bet where the bettor predicts which team will score first in a match. +// We can get this from the "events" field on the result json func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]string) (domain.OutcomeStatus, error) { for _, event := range events { if strings.Contains(event["text"], "1st Goal") || strings.Contains(event["text"], "Goal 1") { @@ -173,6 +304,7 @@ func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]str return domain.OUTCOME_STATUS_VOID, nil // No goals scored } +// Goals Odd/Even betting is a type of bet where the bettor predicts whether the total number of goals scored in a match will be odd or even. func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := score.Home + score.Away isOdd := totalGoals%2 == 1 @@ -184,32 +316,70 @@ func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away in return domain.OUTCOME_STATUS_LOSS, nil } +func evaluateTeamOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + + switch outcome.OddHeader { + case "1": + if outcome.OddHandicap == "Odd" { + if score.Home%2 == 1 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHandicap == "Even" { + if score.Home%2 == 0 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd handicap: %s", outcome.OddHandicap) + } + case "2": + if outcome.OddHandicap == "Odd" { + if score.Away%2 == 1 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHandicap == "Even" { + if score.Away%2 == 0 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd handicap: %s", outcome.OddHandicap) + } + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + + } +} + +// Double Chance betting is a type of bet where the bettor predicts two of the three possible outcomes of a match. func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { isHomeWin := score.Home > score.Away isDraw := score.Home == score.Away isAwayWin := score.Away > score.Home - switch outcome.OddName { - case "1 or Draw", (outcome.HomeTeamName + " or " + "Draw"): + case "1 or Draw", (outcome.HomeTeamName + " or " + "Draw"), ("Draw" + " or " + outcome.HomeTeamName): if isHomeWin || isDraw { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil - case "Draw or 2", ("Draw" + " or " + outcome.AwayTeamName): + case "Draw or 2", ("Draw" + " or " + outcome.AwayTeamName), (outcome.AwayTeamName + " or " + "Draw"): if isDraw || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil - case "1 or 2", (outcome.HomeTeamName + " or " + outcome.AwayTeamName): + case "1 or 2", (outcome.HomeTeamName + " or " + outcome.AwayTeamName), (outcome.AwayTeamName + " or " + outcome.HomeTeamName): if isHomeWin || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Draw No Bet betting is a type of bet where the bettor predicts the outcome of a match, but if the match ends in a draw, the bet is voided. func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { if score.Home == score.Away { return domain.OUTCOME_STATUS_VOID, nil @@ -222,8 +392,37 @@ func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_LOSS, nil } -// basketball evaluations +func evaluateCorners(outcome domain.BetOutcome, corners struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalCorners := corners.Home + corners.Away + threshold, err := strconv.ParseFloat(outcome.OddName, 10) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + switch outcome.OddHeader { + case "Over": + if totalCorners > int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "Under": + if totalCorners < int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "Exactly": + if totalCorners == int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// Basketball evaluations + +// Game Lines is an aggregate of money line, spread and total betting markets in one func evaluateGameLines(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "Money Line": @@ -235,10 +434,11 @@ func evaluateGameLines(outcome domain.BetOutcome, score struct{ Home, Away int } case "Total": return evaluateTotalOverUnder(outcome, score) default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Money Line betting is a type of bet where the bettor predicts the outcome of a match without any point spread. func evaluateMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": @@ -258,21 +458,22 @@ func evaluateMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int } } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Total Over/Under betting is a type of bet where the bettor predicts whether the total number of points scored in a match will be over or under a specified number. func evaluateTotalOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -294,26 +495,28 @@ func evaluateTotalOverUnder(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } +// Result and Total betting is a type of bet where the bettor predicts +// the outcome of a match and whether the total number of points scored will be over or under a specified number. func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } overUnder := overUnderStr[0] if overUnder != "Over" && overUnder != "Under" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -321,6 +524,10 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away switch outcome.OddHeader { case "1": + if score.Home < score.Away { + return domain.OUTCOME_STATUS_LOSS, nil + } + if overUnder == "Over" && totalScore > threshold { return domain.OUTCOME_STATUS_WIN, nil } else if overUnder == "Under" && totalScore < threshold { @@ -328,6 +535,9 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil case "2": + if score.Away < score.Home { + return domain.OUTCOME_STATUS_LOSS, nil + } if overUnder == "Over" && totalScore > threshold { return domain.OUTCOME_STATUS_WIN, nil } else if overUnder == "Under" && totalScore < threshold { @@ -336,27 +546,29 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) } } +// Team Total betting is a type of bet where the bettor predicts the total number of points scored by a specific team in a match +// is over or under a specified number. func evaluateTeamTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) } overUnder := overUnderStr[0] if overUnder != "Over" && overUnder != "Under" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -380,11 +592,12 @@ func evaluateTeamTotal(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) } } -// Evaluate Result and Both Teams To Score X Points +// Result and Both Teams To Score X Points is a type of bet where the bettor predicts whether both teams will score a certain number of points +// and also the result fo the match func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The name parameter will hold value "name": "{team_name} and {Yes | No}" @@ -400,14 +613,14 @@ func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away } else if scoreCheckSplit == "No" { isScorePoints = false } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } teamName := strings.TrimSpace(strings.Join(oddNameSplit[:len(oddNameSplit)-2], "")) threshold, err := strconv.ParseInt(outcome.OddHeader, 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHeader) } switch teamName { @@ -428,18 +641,18 @@ func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away } } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("team name error: %s", teamName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("team name error: %s", teamName) } return domain.OUTCOME_STATUS_LOSS, nil } -// Both Teams To Score X Points +// Both Teams To Score X Points is a type of bet where the bettor predicts whether both teams will score a certain number of points. func evaluateBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { threshold, err := strconv.ParseInt(outcome.OddName, 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } switch outcome.OddHeader { @@ -453,12 +666,13 @@ func evaluateBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (d } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } return domain.OUTCOME_STATUS_LOSS, nil } +// Money Line 3 Way betting is a type of bet where the bettor predicts the outcome of a match with three possible outcomes: home win, away win, or draw. func evaluateMoneyLine3Way(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "1": // Home win @@ -477,23 +691,24 @@ func evaluateMoneyLine3Way(outcome domain.BetOutcome, score struct{ Home, Away i } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Double Result betting is a type of bet where the bettor predicts the outcome of a match at both half-time and full-time. func evaluateDoubleResult(outcome domain.BetOutcome, firstHalfScore struct{ Home, Away int }, secondHalfScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { halfWins := strings.Split(outcome.OddName, "-") if len(halfWins) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } firstHalfWinner := strings.TrimSpace(halfWins[0]) secondHalfWinner := strings.TrimSpace(halfWins[1]) if firstHalfWinner != outcome.HomeTeamName && firstHalfWinner != outcome.AwayTeamName && firstHalfWinner != "Tie" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", firstHalfWinner) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", firstHalfWinner) } if secondHalfWinner != outcome.HomeTeamName && secondHalfWinner != outcome.AwayTeamName && secondHalfWinner != "Tie" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", firstHalfWinner) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", firstHalfWinner) } switch { @@ -517,6 +732,7 @@ func evaluateDoubleResult(outcome domain.BetOutcome, firstHalfScore struct{ Home return domain.OUTCOME_STATUS_WIN, nil } +// Highest Scoring Half betting is a type of bet where the bettor predicts which half of the match will have the highest total score. func evaluateHighestScoringHalf(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstHalfTotal := firstScore.Home + firstScore.Away secondHalfTotal := secondScore.Home + secondScore.Away @@ -534,11 +750,12 @@ func evaluateHighestScoringHalf(outcome domain.BetOutcome, firstScore struct{ Ho return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Highest Scoring Quarter betting is a type of bet where the bettor predicts which quarter of the match will have the highest score. func evaluateHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }, fourthScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstQuarterTotal := firstScore.Home + firstScore.Away secondQuarterTotal := secondScore.Home + secondScore.Away @@ -567,18 +784,44 @@ func evaluateHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Team With Highest Scoring Quarter betting is a type of bet where the bettor predicts which team will have the highest score in a specific quarter. +func evaluateTeamWithHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }, fourthScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { + homeTeamHighestQuarter := max(firstScore.Home, secondScore.Home, thirdScore.Home, fourthScore.Home) + awayTeamHighestQuarter := max(firstScore.Away, secondScore.Away, thirdScore.Away, fourthScore.Away) + + switch outcome.OddName { + case "1": + if homeTeamHighestQuarter > awayTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + case "2": + if awayTeamHighestQuarter > homeTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + case "Tie": + if homeTeamHighestQuarter == awayTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) + } + return domain.OUTCOME_STATUS_LOSS, nil +} + +// Handicap and Total betting is a combination of spread betting and total points betting +// where the bettor predicts the outcome of a match with a point spread and the total number of points scored is over or under a specified number. func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { nameSplit := strings.Split(outcome.OddName, " ") // Evaluate from bottom to get the threshold and find out if its over or under threshold, err := strconv.ParseFloat(nameSplit[len(nameSplit)-1], 10) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing threshold: %s", outcome.OddName) } total := float64(score.Home + score.Away) overUnder := nameSplit[len(nameSplit)-2] @@ -591,12 +834,12 @@ func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Awa return domain.OUTCOME_STATUS_LOSS, nil } } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over and under: %s", outcome.OddName) } handicap, err := strconv.ParseFloat(nameSplit[len(nameSplit)-4], 10) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing handicap: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing handicap: %s", outcome.OddName) } teamName := strings.TrimSpace(strings.Join(nameSplit[:len(nameSplit)-4], "")) @@ -618,21 +861,22 @@ func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Awa } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing team name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing team name: %s", outcome.OddName) } } +// Winning Margin betting is a type of bet where the bettor predicts the margin of victory in a match. func evaluateWinningMargin(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { marginSplit := strings.Split(outcome.OddName, "") if len(marginSplit) < 1 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } margin, err := strconv.ParseInt(marginSplit[0], 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } isGtr := false @@ -656,9 +900,10 @@ func evaluateWinningMargin(outcome domain.BetOutcome, score struct{ Home, Away i return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddheader: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddheader: %s", outcome.OddHeader) } +// Highest Scoring Period betting is a type of bet where the bettor predicts which period of the match will have the highest total score. func evaluateHighestScoringPeriod(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstPeriodTotal := firstScore.Home + firstScore.Away secondPeriodTotal := secondScore.Home + secondScore.Away @@ -682,11 +927,12 @@ func evaluateHighestScoringPeriod(outcome domain.BetOutcome, firstScore struct{ return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Tied After Regulation is a type of bet where the bettor predicts whether the match will end in a tie after regulation time. func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalScore := struct{ Home, Away int }{0, 0} for _, score := range scores { @@ -706,6 +952,37 @@ func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Hom return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } +// evaluateRugbyOutcome evaluates the outcome of a Rugby bet +func evaluateRugbyOutcome(outcome domain.BetOutcome, result *domain.RugbyResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateRugbyMoneyLine(outcome, finalScore) + case "Spread": + return evaluateRugbySpread(outcome, finalScore) + case "Total Points": + return evaluateRugbyTotalPoints(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported rugby market: %s", outcome.MarketName) + } +} + +// evaluateBaseballOutcome evaluates the outcome of a Baseball bet +func evaluateBaseballOutcome(outcome domain.BetOutcome, result *domain.BaseballResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateBaseballMoneyLine(outcome, finalScore) + case "Spread": + return evaluateBaseballSpread(outcome, finalScore) + case "Total Runs": + return evaluateBaseballTotalRuns(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported baseball market: %s", outcome.MarketName) + } +} diff --git a/internal/services/result/football_test.go b/internal/services/result/football_test.go new file mode 100644 index 0000000..0130cf6 --- /dev/null +++ b/internal/services/result/football_test.go @@ -0,0 +1,30 @@ +package result + +import ( + "testing" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func TestEvaluateFullTimeResult(t *testing.T) { + tests := []struct { + name string + outcome domain.BetOutcome + score struct{ Home, Away int } + expected domain.OutcomeStatus + }{ + {"Home win", domain.BetOutcome{OddName: "1"}, struct{ Home, Away int }{2, 1}, domain.OUTCOME_STATUS_WIN}, + {"Away win", domain.BetOutcome{OddName: "2"}, struct{ Home, Away int }{1, 2}, domain.OUTCOME_STATUS_WIN}, + {"Draw", domain.BetOutcome{OddName: "Draw"}, struct{ Home, Away int }{1, 1}, domain.OUTCOME_STATUS_WIN}, + {"Home selected, but Draw", domain.BetOutcome{OddName: "1"}, struct{ Home, Away int }{1, 1}, domain.OUTCOME_STATUS_LOSS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, _ := evaluateFullTimeResult(tt.outcome, tt.score) + if status != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, status) + } + }) + } +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 74983cb..6ffce38 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -13,6 +13,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" ) type Service struct { @@ -20,19 +21,22 @@ type Service struct { config *config.Config logger *slog.Logger client *http.Client + betSvc bet.Service } -func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger) *Service { +func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger, betSvc bet.Service) *Service { return &Service{ repo: repo, config: cfg, logger: logger, client: &http.Client{Timeout: 10 * time.Second}, + betSvc: betSvc, } } -type ResultCheck struct { -} +var ( + ErrEventIsNotActive = fmt.Errorf("Event has been cancelled or postponed") +) func (s *Service) FetchAndProcessResults(ctx context.Context) error { // TODO: Optimize this because there could be many bet outcomes for the same odd @@ -42,9 +46,10 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { s.logger.Error("Failed to fetch events") return err } - fmt.Printf("Expired Events: %d \n", len(events)) + fmt.Printf("⚠️ Expired Events: %d \n", len(events)) + removed := 0 + for i, event := range events { - for _, event := range events { eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { s.logger.Error("Failed to parse event id") @@ -56,48 +61,102 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { return err } - for _, outcome := range outcomes { + if len(outcomes) == 0 { + fmt.Printf("🕛 No bets have been placed on event %v (%d/%d) \n", event.ID, i+1, len(events)) + } else { + fmt.Printf("✅ %d bets have been placed on event %v (%d/%d) \n", len(outcomes), event.ID, i+1, len(events)) + } + + isDeleted := true + for j, outcome := range outcomes { + fmt.Printf("⚙️ Processing 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + if outcome.Expires.After(time.Now()) { + isDeleted = false + s.logger.Warn("Outcome is not expired yet", "event_id", event.ID, "outcome_id", outcome.ID) continue } sportID, err := strconv.ParseInt(event.SportID, 10, 64) if err != nil { s.logger.Error("Sport ID is invalid", "event_id", outcome.EventID, "error", err) + isDeleted = false continue } - + // TODO: optimize this because the result is being fetched for each outcome which will have the same event id but different market id result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { - s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err) + if err == ErrEventIsNotActive { + s.logger.Warn("Event is not active", "event_id", outcome.EventID, "error", err) + continue + } + fmt.Printf("❌ failed to parse 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "outcome_id", outcome.ID, "market_id", outcome.MarketID, "market", outcome.MarketName, "error", err) + isDeleted = false continue } - // _, err = s.repo.CreateResult(ctx, domain.CreateResult{ - // BetOutcomeID: outcome.ID, - // EventID: outcome.EventID, - // OddID: outcome.OddID, - // MarketID: outcome.MarketID, - // Status: result.Status, - // Score: result.Score, - // }) - // if err != nil { - // s.logger.Error("Failed to store result", "bet_outcome_id", outcome.ID, "error", err) - // continue - // } - - err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) + outcome, err = s.betSvc.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) if err != nil { + isDeleted = false s.logger.Error("Failed to update bet outcome status", "bet_outcome_id", outcome.ID, "error", err) continue } + if outcome.Status == domain.OUTCOME_STATUS_ERROR || outcome.Status == domain.OUTCOME_STATUS_PENDING { + fmt.Printf("❌ Error while updating 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + + s.logger.Error("Outcome is pending or error", "event_id", outcome.EventID, "outcome_id", outcome.ID) + isDeleted = false + continue + } + + fmt.Printf("✅ Successfully updated 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + + status, err := s.betSvc.CheckBetOutcomeForBet(ctx, outcome.BetID) + if err != nil { + if err != bet.ErrOutcomesNotCompleted { + s.logger.Error("Failed to check bet outcome for bet", "event_id", outcome.EventID, "error", err) + } + isDeleted = false + continue + } + fmt.Printf("🧾 Updating bet %v - event %v (%d/%d) to %v\n", outcome.BetID, event.ID, j+1, len(outcomes), status.String()) + err = s.betSvc.UpdateStatus(ctx, outcome.BetID, status) + if err != nil { + s.logger.Error("Failed to update bet status", "event id", outcome.EventID, "error", err) + isDeleted = false + continue + } + fmt.Printf("✅ Successfully updated 🎫 Bet %v - event %v(%v) (%d/%d) \n", + outcome.BetID, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + } - err = s.repo.DeleteEvent(ctx, event.ID) - if err != nil { - s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) - return err + if isDeleted { + removed += 1 + fmt.Printf("⚠️ Removing Event %v \n", event.ID) + err = s.repo.DeleteEvent(ctx, event.ID) + if err != nil { + s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) + return err + } } + } + fmt.Printf("🗑️ Removed Events: %d \n", removed) return nil } @@ -202,29 +261,51 @@ func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, spo switch sportID { case domain.FOOTBALL: result, err = s.parseFootball(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse football", "event_id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } - case domain.BASKETBALL: result, err = s.parseBasketball(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse basketball", "event_id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } case domain.ICE_HOCKEY: result, err = s.parseIceHockey(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse ice hockey", "event id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } + case domain.AMERICAN_FOOTBALL: + result, err = s.parseNFL(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_UNION: + result, err = s.parseRugbyUnion(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_LEAGUE: + result, err = s.parseRugbyLeague(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.BASEBALL: + result, err = s.parseBaseball(resultResp.Results[0], eventID, oddID, marketID, outcome) default: s.logger.Error("Unsupported sport", "sport", sportID) return domain.CreateResult{}, fmt.Errorf("unsupported sport: %v", sportID) } return result, nil +} + +func (s *Service) parseTimeStatus(timeStatusStr string) (bool, error) { + timeStatusParsed, err := strconv.ParseInt(strings.TrimSpace(timeStatusStr), 10, 64) + if err != nil { + s.logger.Error("Failed to parse time status", "time_status", timeStatusStr, "error", err) + return false, fmt.Errorf("failed to parse time status: %w", err) + } + timeStatus := domain.TimeStatus(timeStatusParsed) + + switch timeStatus { + case domain.TIME_STATUS_NOT_STARTED, domain.TIME_STATUS_IN_PLAY, domain.TIME_STATUS_TO_BE_FIXED, domain.TIME_STATUS_ENDED: + return true, nil + case domain.TIME_STATUS_POSTPONED, + domain.TIME_STATUS_CANCELLED, + domain.TIME_STATUS_WALKOVER, + domain.TIME_STATUS_INTERRUPTED, + domain.TIME_STATUS_ABANDONED, + domain.TIME_STATUS_RETIRED, + domain.TIME_STATUS_SUSPENDED, + domain.TIME_STATUS_DECIDED_BY_FA, + domain.TIME_STATUS_REMOVED: + return false, nil + default: + s.logger.Error("Invalid time status", "time_status", timeStatus) + return false, fmt.Errorf("invalid time status: %d", timeStatus) + } } @@ -235,18 +316,26 @@ func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marke return domain.CreateResult{}, err } result := fbResp - if result.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + + isEventActive, err := s.parseTimeStatus(result.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } finalScore := parseSS(result.SS) - firstHalfScore := parseSS(fmt.Sprintf("%s-%s", result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away)) + firstHalfScore := parseScore(result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away) + secondHalfScore := parseScore(result.Scores.SecondHalf.Home, result.Scores.SecondHalf.Away) corners := parseStats(result.Stats.Corners) - status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, corners, result.Events) + halfTimeCorners := parseStats(result.Stats.HalfTimeCorners) + status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, secondHalfScore, corners, halfTimeCorners, result.Events) if err != nil { - s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + s.logger.Error("Failed to evaluate football outcome", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } @@ -264,12 +353,17 @@ func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marke func (s *Service) parseBasketball(response json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var basketBallRes domain.BasketballResultResponse if err := json.Unmarshal(response, &basketBallRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) + s.logger.Error("Failed to unmarshal basketball result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } - if basketBallRes.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + isEventActive, err := s.parseTimeStatus(basketBallRes.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } status, err := s.evaluateBasketballOutcome(outcome, basketBallRes) @@ -292,12 +386,17 @@ func (s *Service) parseBasketball(response json.RawMessage, eventID, oddID, mark func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var iceHockeyRes domain.IceHockeyResultResponse if err := json.Unmarshal(response, &iceHockeyRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) + s.logger.Error("Failed to unmarshal ice hockey result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } - if iceHockeyRes.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + isEventActive, err := s.parseTimeStatus(iceHockeyRes.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } status, err := s.evaluateIceHockeyOutcome(outcome, iceHockeyRes) @@ -317,6 +416,124 @@ func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marke } +func (s *Service) parseNFL(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var nflResp domain.NFLResultResponse + if err := json.Unmarshal(resultRes, &nflResp); err != nil { + s.logger.Error("Failed to unmarshal NFL result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + + if nflResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + + finalScore := parseSS(nflResp.SS) + + var status domain.OutcomeStatus + var err error + + switch outcome.MarketName { + case "Money Line": + status, err = evaluateNFLMoneyLine(outcome, finalScore) + case "Spread": + status, err = evaluateNFLSpread(outcome, finalScore) + case "Total Points": + status, err = evaluateNFLTotalPoints(outcome, finalScore) + default: + return domain.CreateResult{}, fmt.Errorf("unsupported market: %s", outcome.MarketName) + } + + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: nflResp.SS, + }, nil +} + +func (s *Service) parseRugbyUnion(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby Union result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseRugbyLeague(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby League result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseBaseball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var baseballResp domain.BaseballResultResponse + if err := json.Unmarshal(resultRes, &baseballResp); err != nil { + s.logger.Error("Failed to unmarshal Baseball result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if baseballResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateBaseballOutcome(outcome, &baseballResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: baseballResp.SS, + }, nil +} + func parseScore(home string, away string) struct{ Home, Away int } { homeVal, _ := strconv.Atoi(strings.TrimSpace(home)) awaVal, _ := strconv.Atoi(strings.TrimSpace(away)) @@ -343,7 +560,10 @@ func parseStats(stats []string) struct{ Home, Away int } { } // evaluateOutcome determines the outcome status based on market type and odd -func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, firstHalfScore struct{ Home, Away int }, corners struct{ Home, Away int }, events []map[string]string) (domain.OutcomeStatus, error) { +func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, + firstHalfScore struct{ Home, Away int }, secondHalfScore struct{ Home, Away int }, + corners struct{ Home, Away int }, halfTimeCorners struct{ Home, Away int }, + events []map[string]string) (domain.OutcomeStatus, error) { if !domain.SupportedMarkets[outcome.MarketID] { s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) @@ -375,6 +595,21 @@ func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, return evaluateDoubleChance(outcome, finalScore) case int64(domain.FOOTBALL_DRAW_NO_BET): return evaluateDrawNoBet(outcome, finalScore) + case int64(domain.FOOTBALL_CORNERS): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_CORNERS_TWO_WAY): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_FIRST_HALF_CORNERS): + return evaluateCorners(outcome, halfTimeCorners) + case int64(domain.FOOTBALL_ASIAN_TOTAL_CORNERS): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_FIRST_HALF_ASIAN_CORNERS): + return evaluateCorners(outcome, halfTimeCorners) + case int64(domain.FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN): + return evaluateGoalsOddEven(outcome, firstHalfScore) + case int64(domain.FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN): + return evaluateGoalsOddEven(outcome, secondHalfScore) + default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) @@ -411,7 +646,9 @@ func (s *Service) evaluateBasketballOutcome(outcome domain.BetOutcome, res domai case int64(domain.BASKETBALL_GAME_TOTAL_ODD_EVEN): return evaluateGoalsOddEven(outcome, finalScore) case int64(domain.BASKETBALL_TEAM_TOTALS): - return evaluateGoalsOddEven(outcome, finalScore) + return evaluateTeamTotal(outcome, finalScore) + case int64(domain.BASKETBALL_TEAM_TOTAL_ODD_EVEN): + return evaluateTeamOddEven(outcome, finalScore) case int64(domain.BASKETBALL_FIRST_HALF): return evaluateGameLines(outcome, firstHalfScore) @@ -442,6 +679,11 @@ func (s *Service) evaluateBasketballOutcome(outcome domain.BetOutcome, res domai return evaluateDoubleChance(outcome, firstQuarter) case int64(domain.BASKETBALL_HIGHEST_SCORING_QUARTER): return evaluateHighestScoringQuarter(outcome, firstQuarter, secondQuarter, thirdQuarter, fourthQuarter) + case int64(domain.BASKETBALL_FIRST_QUARTER_RESULT_AND_TOTAL): + return evaluateResultAndTotal(outcome, firstQuarter) + + case int64(domain.BASKETBALL_TEAM_WITH_HIGHEST_SCORING_QUARTER): + return evaluateTeamWithHighestScoringQuarter(outcome, firstQuarter, secondQuarter, thirdQuarter, fourthQuarter) default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) @@ -487,3 +729,22 @@ func (s *Service) evaluateIceHockeyOutcome(outcome domain.BetOutcome, res domain return domain.OUTCOME_STATUS_PENDING, nil } + +func (s *Service) evaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { + if !domain.SupportedMarkets[outcome.MarketID] { + s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName) + } + + switch outcome.MarketID { + case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE): + return evaluateNFLMoneyLine(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + return evaluateNFLSpread(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + return evaluateNFLTotalPoints(outcome, finalScore) + default: + s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) + } +} diff --git a/internal/services/result/service_test.go b/internal/services/result/service_test.go deleted file mode 100644 index 2705049..0000000 --- a/internal/services/result/service_test.go +++ /dev/null @@ -1 +0,0 @@ -package result diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go new file mode 100644 index 0000000..eeb23f7 --- /dev/null +++ b/internal/services/result/sports_eval.go @@ -0,0 +1,280 @@ +package result + +import ( + "fmt" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// NFL evaluations +func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +func evaluateNFLSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +func evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateRugbyMoneyLine evaluates Rugby money line bets +func evaluateRugbyMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateRugbySpread evaluates Rugby spread bets +func evaluateRugbySpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +// evaluateRugbyTotalPoints evaluates Rugby total points bets +func evaluateRugbyTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateBaseballMoneyLine evaluates Baseball money line bets +func evaluateBaseballMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballSpread evaluates Baseball spread bets +func evaluateBaseballSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +// evaluateBaseballTotalRuns evaluates Baseball total runs bets +func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalRuns := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalRuns > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalRuns < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateBaseballFirstInning evaluates Baseball first inning bets +func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets +func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} diff --git a/internal/services/result/sports_eval_test.go b/internal/services/result/sports_eval_test.go new file mode 100644 index 0000000..f300879 --- /dev/null +++ b/internal/services/result/sports_eval_test.go @@ -0,0 +1,303 @@ +package result + +import ( + "testing" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/stretchr/testify/assert" +) + +// TestNFLMarkets covers all American Football (NFL) market types defined in the domain. +// For each market (Money Line, Spread, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestNFLMarkets(t *testing.T) { + t.Log("Testing NFL (American Football) Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.AMERICAN_FOOTBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.AMERICAN_FOOTBALL_SPREAD), "SPREAD"}, + {int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS), "TOTAL_POINTS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given NFL market. + switch m.marketID { + case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 14, Away: 21}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 17, Away: 17}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-3.5"}, struct{ Home, Away int }{Home: 24, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+3.5"}, struct{ Home, Away int }{Home: 20, Away: 24}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "44.5"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "44.5"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "37"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestRugbyMarkets covers all Rugby (Union & League) market types defined in the domain. +// For each market (Money Line, Spread, Handicap, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestRugbyMarkets(t *testing.T) { + t.Log("Testing Rugby Markets (Union & League)") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.RUGBY_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.RUGBY_SPREAD), "SPREAD"}, + {int64(domain.RUGBY_TOTAL_POINTS), "TOTAL_POINTS"}, + {int64(domain.RUGBY_HANDICAP), "HANDICAP"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Rugby market. + switch m.marketID { + case int64(domain.RUGBY_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 20, Away: 30}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 25, Away: 25}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_SPREAD), int64(domain.RUGBY_HANDICAP): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-6.5"}, struct{ Home, Away int }{Home: 28, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+6.5"}, struct{ Home, Away int }{Home: 20, Away: 28}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "40.5"}, struct{ Home, Away int }{Home: 25, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "40.5"}, struct{ Home, Away int }{Home: 15, Away: 20}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "35"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestBaseballMarkets covers all Baseball market types defined in the domain. +// For each market (Money Line, Spread, Total Runs), it tests home/away win, draw, void, and invalid input scenarios. +func TestBaseballMarkets(t *testing.T) { + t.Log("Testing Baseball Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.BASEBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.BASEBALL_SPREAD), "SPREAD"}, + {int64(domain.BASEBALL_TOTAL_RUNS), "TOTAL_RUNS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Baseball market. + switch m.marketID { + case int64(domain.BASEBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 6, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 2, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-1.5"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+1.5"}, struct{ Home, Away int }{Home: 3, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_TOTAL_RUNS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7.5"}, struct{ Home, Away int }{Home: 5, Away: 4}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "7.5"}, struct{ Home, Away int }{Home: 2, Away: 3}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} diff --git a/internal/services/result_checker.go b/internal/services/result_checker.go new file mode 100644 index 0000000..59d4b1c --- /dev/null +++ b/internal/services/result_checker.go @@ -0,0 +1,189 @@ +package services + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// ResultCheckerService handles the checking of game results +type ResultCheckerService struct { + // Add any dependencies here (e.g., repositories, external APIs) +} + +// NewResultCheckerService creates a new instance of ResultCheckerService +func NewResultCheckerService() *ResultCheckerService { + return &ResultCheckerService{} +} + +// CheckNFLResult checks the result of an NFL game +func (s *ResultCheckerService) CheckNFLResult(data json.RawMessage) (*domain.Result, error) { + nflResult, err := domain.ParseNFLResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse NFL result: %w", err) + } + + winner, err := domain.GetNFLWinner(nflResult) + if err != nil { + return nil, fmt.Errorf("failed to determine NFL winner: %w", err) + } + + score := domain.FormatNFLScore(nflResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, nflResult.Home.Name, nflResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: nflResult.SS, + Scores: map[string]domain.Score{ + "1": nflResult.Scores.FirstQuarter, + "2": nflResult.Scores.SecondQuarter, + "3": nflResult.Scores.ThirdQuarter, + "4": nflResult.Scores.FourthQuarter, + "5": nflResult.Scores.Overtime, + "7": nflResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// determineOutcomeStatus determines the outcome status based on the winner and teams +func determineOutcomeStatus(winner, homeTeam, awayTeam string) domain.OutcomeStatus { + if winner == "Draw" { + return domain.OUTCOME_STATUS_VOID + } + if winner == homeTeam { + return domain.OUTCOME_STATUS_WIN + } + if winner == awayTeam { + return domain.OUTCOME_STATUS_LOSS + } + return domain.OUTCOME_STATUS_PENDING +} + +// CheckRugbyResult checks the result of a Rugby game +func (s *ResultCheckerService) CheckRugbyResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckBaseballResult checks the result of a Baseball game +func (s *ResultCheckerService) CheckBaseballResult(data json.RawMessage) (*domain.Result, error) { + baseballResult, err := domain.ParseBaseballResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Baseball result: %w", err) + } + + winner, err := domain.GetBaseballWinner(baseballResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Baseball winner: %w", err) + } + + score := domain.FormatBaseballScore(baseballResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, baseballResult.Home.Name, baseballResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: baseballResult.SS, + Scores: map[string]domain.Score{ + "1": baseballResult.Scores.FirstInning, + "2": baseballResult.Scores.SecondInning, + "3": baseballResult.Scores.ThirdInning, + "4": baseballResult.Scores.FourthInning, + "5": baseballResult.Scores.FifthInning, + "6": baseballResult.Scores.SixthInning, + "7": baseballResult.Scores.SeventhInning, + "8": baseballResult.Scores.EighthInning, + "9": baseballResult.Scores.NinthInning, + "10": baseballResult.Scores.ExtraInnings, + "T": baseballResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyUnionResult checks the result of a Rugby Union game +func (s *ResultCheckerService) CheckRugbyUnionResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyUnionResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby Union result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby Union winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyLeagueResult checks the result of a Rugby League game +func (s *ResultCheckerService) CheckRugbyLeagueResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyLeagueResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby League result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby League winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go index 1d86313..509f353 100644 --- a/internal/services/ticket/service.go +++ b/internal/services/ticket/service.go @@ -37,3 +37,7 @@ func (s *Service) UpdateTicketOutcomeStatus(ctx context.Context, id int64, statu func (s *Service) DeleteTicket(ctx context.Context, id int64) error { return s.ticketStore.DeleteTicket(ctx, id) } + +func (s *Service) DeleteOldTickets(ctx context.Context) error { + return s.ticketStore.DeleteOldTickets(ctx) +} diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 8181822..c61cd01 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -46,8 +46,8 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error { type Filter struct { Role string CompanyID domain.ValidInt64 - Page int - PageSize int + Page domain.ValidInt + PageSize domain.ValidInt } type ValidRole struct { Value domain.Role @@ -71,6 +71,10 @@ func (s *Service) GetCashiersByBranch(ctx context.Context, branchID int64) ([]do return s.userStore.GetCashiersByBranch(ctx, branchID) } -func (s *Service) GetAllCashiers(ctx context.Context) ([]domain.User, error) { +func (s *Service) GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) { return s.userStore.GetAllCashiers(ctx) } + +func (s *Service) GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) { + return s.userStore.GetCashierByID(ctx, cashierID) +} diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 3dfa77e..6a09597 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -11,15 +11,17 @@ type UserStore interface { CreateUserWithoutOtp(ctx context.Context, user domain.User, is_company bool) (domain.User, error) GetUserByID(ctx context.Context, id int64) (domain.User, error) GetAllUsers(ctx context.Context, filter Filter) ([]domain.User, int64, error) - GetAllCashiers(ctx context.Context) ([]domain.User, error) + GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) + GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error UpdateUserCompany(ctx context.Context, id int64, companyID int64) error + UpdateUserSuspend(ctx context.Context, id int64, status bool) error DeleteUser(ctx context.Context, id int64) error CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) GetUserByEmail(ctx context.Context, email string) (domain.User, error) GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) - SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) + SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error // identifier verified email or phone } type SmsGateway interface { diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 17a7820..cfa93fd 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -13,7 +13,6 @@ type Service struct { otpStore OtpStore smsGateway SmsGateway emailGateway EmailGateway - } func NewService( diff --git a/internal/services/user/user.go b/internal/services/user/user.go index 225ecc6..6529c16 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -6,9 +6,9 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" ) -func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) { +func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) { // Search user - return s.userStore.SearchUserByNameOrPhone(ctx, searchString) + return s.userStore.SearchUserByNameOrPhone(ctx, searchString, role, companyID) } func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { @@ -20,7 +20,11 @@ func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) err func (s *Service) UpdateUserCompany(ctx context.Context, id int64, companyID int64) error { // update user return s.userStore.UpdateUserCompany(ctx, id, companyID) +} +func (s *Service) UpdateUserSuspend(ctx context.Context, id int64, status bool) error { + // update user + return s.userStore.UpdateUserSuspend(ctx, id, status) } func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) diff --git a/internal/services/wallet/port.go b/internal/services/wallet/port.go index 9271039..9c3fcb9 100644 --- a/internal/services/wallet/port.go +++ b/internal/services/wallet/port.go @@ -12,7 +12,7 @@ type WalletStore interface { GetWalletByID(ctx context.Context, id int64) (domain.Wallet, error) GetAllWallets(ctx context.Context) ([]domain.Wallet, error) GetWalletsByUser(ctx context.Context, id int64) ([]domain.Wallet, error) - GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) + GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error) UpdateBalance(ctx context.Context, id int64, balance domain.Currency) error UpdateWalletActive(ctx context.Context, id int64, isActive bool) error diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index ced664d..feb29d0 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -15,7 +15,7 @@ func (s *Service) CreateWallet(ctx context.Context, wallet domain.CreateWallet) return s.walletStore.CreateWallet(ctx, wallet) } -func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.CustomerWallet, error) { +func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64) (domain.CustomerWallet, error) { regularWallet, err := s.CreateWallet(ctx, domain.CreateWallet{ IsWithdraw: true, @@ -39,7 +39,6 @@ func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64, co return s.walletStore.CreateCustomerWallet(ctx, domain.CreateCustomerWallet{ CustomerID: customerID, - CompanyID: companyID, RegularWalletID: regularWallet.ID, StaticWalletID: staticWallet.ID, }) @@ -57,8 +56,8 @@ func (s *Service) GetWalletsByUser(ctx context.Context, id int64) ([]domain.Wall return s.walletStore.GetWalletsByUser(ctx, id) } -func (s *Service) GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) { - return s.walletStore.GetCustomerWallet(ctx, customerID, companyID) +func (s *Service) GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) { + return s.walletStore.GetCustomerWallet(ctx, customerID) } func (s *Service) GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error) { @@ -91,8 +90,6 @@ func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain. return s.walletStore.UpdateBalance(ctx, id, wallet.Balance+amount) } - - func (s *Service) UpdateWalletActive(ctx context.Context, id int64, isActive bool) error { return s.walletStore.UpdateWalletActive(ctx, id, isActive) } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 2c7c9d2..5bbf4ae 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -31,16 +31,17 @@ import ( type App struct { fiber *fiber.App + aleaVirtualGameService alea.AleaVirtualGameService + veliVirtualGameService veli.VeliVirtualGameService + cfg *config.Config logger *slog.Logger - NotidicationStore notificationservice.NotificationStore + NotidicationStore *notificationservice.Service referralSvc referralservice.ReferralStore port int authSvc *authentication.Service userSvc *user.Service betSvc *bet.Service virtualGameSvc virtualgameservice.VirtualGameService - aleaVirtualGameService alea.AleaVirtualGameService - veliVirtualGameService veli.VeliVirtualGameService walletSvc *wallet.Service transactionSvc *transaction.Service ticketSvc *ticket.Service @@ -52,7 +53,6 @@ type App struct { prematchSvc *odds.ServiceImpl eventSvc event.Service resultSvc *result.Service - cfg *config.Config } func NewApp( @@ -67,7 +67,7 @@ func NewApp( transactionSvc *transaction.Service, branchSvc *branch.Service, companySvc *company.Service, - notidicationStore notificationservice.NotificationStore, + notidicationStore *notificationservice.Service, prematchSvc *odds.ServiceImpl, eventSvc event.Service, referralSvc referralservice.ReferralStore, @@ -85,9 +85,9 @@ func NewApp( }) app.Use(cors.New(cors.Config{ - AllowOrigins: "*", // Specify your frontend's origin - AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", // Specify the allowed HTTP methods - AllowHeaders: "Content-Type,Authorization,platform", // Specify the allowed headers + AllowOrigins: "*", + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", + AllowHeaders: "Content-Type,Authorization,platform", // AllowCredentials: true, })) diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 4dccd22..8e0bfdd 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -1,8 +1,8 @@ package httpserver import ( - // "context" "context" + "log" // "time" @@ -10,6 +10,7 @@ import ( eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" resultsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/robfig/cron/v3" ) @@ -20,53 +21,24 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - { - spec: "0 0 * * * *", // Every 1 hour - task: func() { - if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - log.Printf("FetchUpcomingEvents error: %v", err) - } - }, - }, - // { - // spec: "*/5 * * * * *", // Every 5 seconds - // task: func() { - // if err := eventService.FetchLiveEvents(context.Background()); err != nil { - // log.Printf("FetchLiveEvents error: %v", err) - // } - // }, - // }, - { - spec: "0 */15 * * * *", // Every 15 minutes - task: func() { - if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - log.Printf("FetchNonLiveOdds error: %v", err) - } - }, - }, - // { - // spec: "0 */15 * * * *", + // spec: "0 0 * * * *", // Every 1 hour // task: func() { - // log.Println("Fetching results for upcoming events...") - - // upcomingEvents, err := eventService.GetAllUpcomingEvents(context.Background()) - // if err != nil { - // log.Printf("Failed to fetch upcoming events: %v", err) - // return + // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + // log.Printf("FetchUpcomingEvents error: %v", err) // } - - // for _, event := range upcomingEvents { - // if err := resultService.FetchAndStoreResult(context.Background(), event.ID); err != nil { - // log.Printf(" Failed to fetch/store result for event %s: %v", event.ID, err) - // } else { - // log.Printf(" Successfully stored result for event %s", event.ID) - // } + // }, + // }, + // { + // spec: "0 */15 * * * *", // Every 15 minutes + // task: func() { + // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + // log.Printf("FetchNonLiveOdds error: %v", err) // } // }, // }, { - spec: "0 */15 * * * *", + spec: "0 */15 * * * *", // Every 15 Minutes task: func() { log.Println("Fetching results for upcoming events...") @@ -80,6 +52,7 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } for _, job := range schedule { + job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } @@ -88,3 +61,33 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S c.Start() log.Println("Cron jobs started for event and odds services") } + +func StartTicketCrons(ticketService ticket.Service) { + c := cron.New(cron.WithSeconds()) + + schedule := []struct { + spec string + task func() + }{ + { + spec: "0 0 * * * *", // Every hour + task: func() { + log.Println("Deleting old tickets...") + if err := ticketService.DeleteOldTickets(context.Background()); err != nil { + log.Printf("Failed to remove old ticket: %v", err) + } else { + log.Printf("Successfully deleted old tickets") + } + }, + }, + } + + for _, job := range schedule { + if _, err := c.AddFunc(job.spec, job.task); err != nil { + log.Fatalf("Failed to schedule cron job: %v", err) + } + } + + c.Start() + log.Println("Cron jobs started for ticket service") +} diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index 2b8cdf0..795a61f 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -2,6 +2,7 @@ package handlers import ( "log/slog" + "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -124,14 +125,21 @@ 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{ Value: int64(c.QueryInt("company_id")), + Valid: false, + }, + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), Valid: true, }, - Page: c.QueryInt("page", 1) - 1, - PageSize: c.QueryInt("page_size", 10), } valErrs, ok := h.validator.Validate(c, filter) if !ok { @@ -171,6 +179,155 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { } } - return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page, int(total)) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page.Value, int(total)) +} + +// GetAdminByID godoc +// @Summary Get admin by id +// @Description Get a single admin by id +// @Tags admin +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} AdminRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @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) + 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) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get admin", nil, nil) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + + lastLogin = &user.CreatedAt + } + + res := AdminRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + } + + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateAdminReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Suspended bool `json:"suspended" example:"false"` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` +} + +// UpdateAdmin godoc +// @Summary Update Admin +// @Description Update Admin +// @Tags admin +// @Accept json +// @Produce json +// @Param admin body updateAdminReq true "Update Admin" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/{id} [put] +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) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + + valErrs, ok := h.validator.Validate(c, req) + + if !ok { + 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) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Admin ID", nil, nil) + } + var companyID domain.ValidInt64 + if req.CompanyID != nil { + companyID = domain.ValidInt64{ + Value: *req.CompanyID, + Valid: true, + } + } + err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ + UserId: AdminID, + FirstName: domain.ValidString{ + Value: req.FirstName, + Valid: req.FirstName != "", + }, + LastName: domain.ValidString{ + Value: req.LastName, + Valid: req.LastName != "", + }, + Suspended: domain.ValidBool{ + Value: req.Suspended, + Valid: true, + }, + CompanyID: companyID, + }, + ) + if err != nil { + h.logger.Error("UpdateAdmin failed", "error", err) + 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) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil) + } + } + + return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil) } diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 268fbb3..b01fbd3 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -1,98 +1,24 @@ package handlers import ( - "encoding/json" - "log/slog" "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) -type CreateBetOutcomeReq struct { - EventID int64 `json:"event_id" example:"1"` - OddID int64 `json:"odd_id" example:"1"` - MarketID int64 `json:"market_id" example:"1"` -} - -type CreateBetReq struct { - Outcomes []CreateBetOutcomeReq `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID *int64 `json:"branch_id,omitempty" example:"1"` -} - -type CreateBetRes struct { - ID int64 `json:"id" example:"1"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CreatedNumber int64 `json:"created_number" example:"2"` - CashedID string `json:"cashed_id" example:"21234"` -} -type BetRes struct { - ID int64 `json:"id" example:"1"` - Outcomes []domain.BetOutcome `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CashedOut bool `json:"cashed_out" example:"false"` - CashedID string `json:"cashed_id" example:"21234"` -} - -func convertCreateBet(bet domain.Bet, createdNumber int64) CreateBetRes { - return CreateBetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - CreatedNumber: createdNumber, - CashedID: bet.CashoutID, - } -} - -func convertBet(bet domain.GetBet) BetRes { - return BetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - Outcomes: bet.Outcomes, - IsShopBet: bet.IsShopBet, - CashedOut: bet.CashedOut, - CashedID: bet.CashoutID, - } -} - // CreateBet godoc // @Summary Create a bet // @Description Creates a bet // @Tags bet // @Accept json // @Produce json -// @Param createBet body CreateBetReq true "Creates bet" -// @Success 200 {object} BetRes +// @Param createBet body domain.CreateBetReq true "Creates bet" +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet [post] @@ -102,7 +28,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) - var req CreateBetReq + var req domain.CreateBetReq if err := c.BodyParser(&req); err != nil { h.logger.Error("Failed to parse CreateBet request", "error", err) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -113,199 +39,102 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - // TODO Validate Outcomes Here and make sure they didn't expire - // Validation for creating tickets - if len(req.Outcomes) > 30 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Too many odds/outcomes selected", nil, nil) + res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role) + + if err != nil { + h.logger.Error("PlaceBet failed", "error", err) + switch err { + case bet.ErrEventHasBeenRemoved, bet.ErrEventHasNotEnded, bet.ErrRawOddInvalid, wallet.ErrBalanceInsufficient: + return fiber.NewError(fiber.StatusBadGateway, err.Error()) + } + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") } - var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) - var totalOdds float32 = 1 - for _, outcome := range req.Outcomes { - eventIDStr := strconv.FormatInt(outcome.EventID, 10) - marketIDStr := strconv.FormatInt(outcome.MarketID, 10) - oddIDStr := strconv.FormatInt(outcome.OddID, 10) - event, err := h.eventSvc.GetUpcomingEventByID(c.Context(), eventIDStr) + + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + +} + +// RandomBet godoc +// @Summary Generate a random bet +// @Description Generate a random bet +// @Tags bet +// @Accept json +// @Produce json +// @Param createBet body domain.RandomBetReq true "Create Random bet" +// @Success 200 {object} domain.BetRes +// @Failure 400 {object} response.APIResponse +// @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 := c.Query("league_id") + sportIDQuery := c.Query("sport_id") + firstStartTimeQuery := c.Query("first_start_time") + lastStartTimeQuery := c.Query("last_start_time") + + leagueID := domain.ValidString{ + Value: leagueIDQuery, + Valid: leagueIDQuery != "", + } + sportID := domain.ValidString{ + Value: sportIDQuery, + Valid: sportIDQuery != "", + } + + var firstStartTime domain.ValidTime + if firstStartTimeQuery != "" { + firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid event id", err, nil) + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) } + firstStartTime = domain.ValidTime{ + Value: firstStartTimeParsed, + 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) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + lastStartTime = domain.ValidTime{ + Value: lastStartTimeParsed, + Valid: true, + } + } - // Checking to make sure the event hasn't already started - // currentTime := time.Now() - // if event.StartTime.Before(currentTime) { - // return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - // } + var req domain.RandomBetReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse RandomBet request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } - odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) + valErrs, ok := h.validator.Validate(c, req) + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + var res domain.CreateBetRes + var err error + 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 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid market id", err, nil) - } - type rawOddType struct { - ID string - Name string - Odds string - Header string - Handicap string - } - var selectedOdd rawOddType - var isOddFound bool = false - for _, raw := range odds.RawOdds { - var rawOdd rawOddType - rawBytes, err := json.Marshal(raw) - err = json.Unmarshal(rawBytes, &rawOdd) - if err != nil { - h.logger.Error("Failed to unmarshal raw odd", "error", err) - continue - } - if rawOdd.ID == oddIDStr { - selectedOdd = rawOdd - isOddFound = true + h.logger.Error("Random Bet failed", "error", err) + switch err { + case bet.ErrNoEventsAvailable: + return fiber.NewError(fiber.StatusBadRequest, "No events found") } + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } - - if !isOddFound { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid odd id", nil, nil) - } - - parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) - totalOdds = totalOdds * float32(parsedOdd) - - sportID, err := strconv.ParseInt(event.SportID, 10, 64) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid sport id", nil, nil) - } - - h.logger.Info("Create Bet", slog.Int64("sportId", sportID)) - - outcomes = append(outcomes, domain.CreateBetOutcome{ - EventID: outcome.EventID, - OddID: outcome.OddID, - MarketID: outcome.MarketID, - SportID: sportID, - HomeTeamName: event.HomeTeam, - AwayTeamName: event.AwayTeam, - MarketName: odds.MarketName, - Odd: float32(parsedOdd), - OddName: selectedOdd.Name, - OddHeader: selectedOdd.Header, - OddHandicap: selectedOdd.Handicap, - Expires: event.StartTime, - }) } - - // Validating user by role - // Differentiating between offline and online bets - cashoutID, err := h.betSvc.GenerateCashoutID() - if err != nil { - h.logger.Error("CreateBetReq failed, unable to create cashout id") - return response.WriteJSON(c, fiber.StatusInternalServerError, "Invalid request", err, nil) - } - var bet domain.Bet - if role == domain.RoleCashier { - - // Get the branch from the branch ID - branch, err := h.branchSvc.GetBranchByCashier(c.Context(), userID) - if err != nil { - h.logger.Error("CreateBetReq failed, branch id invalid") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - // Deduct a percentage of the amount - // TODO move to service layer. Make it fetch dynamically from company - var deductedAmount = req.Amount / 10 - err = h.walletSvc.DeductFromWallet(c.Context(), branch.WalletID, domain.ToCurrency(deductedAmount)) - - if err != nil { - h.logger.Error("CreateBetReq failed, unable to deduct from WalletID") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: branch.ID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: false, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else if role == domain.RoleSuperAdmin || role == domain.RoleAdmin || role == domain.RoleBranchManager { - // If a non cashier wants to create a bet, they will need to provide the Branch ID - // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company - if req.BranchID == nil { - h.logger.Error("CreateBetReq failed, Branch ID is required for this type of user") - return response.WriteJSON(c, fiber.StatusBadRequest, "Branch ID is required for this type of user", nil, nil) - } - // h.logger.Info("Branch ID", slog.Int64("branch_id", *req.BranchID)) - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - BranchID: domain.ValidInt64{ - Value: *req.BranchID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else { - // TODO if user is customer, get id from the token then get the wallet id from there and reduce the amount - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: 0, - Valid: false, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: false, - CashoutID: cashoutID, - }) - } - - if err != nil { - h.logger.Error("CreateBetReq failed", "error", err) - return response.WriteJSON(c, fiber.StatusInternalServerError, "Internal Server Error", err, nil) - } - - // Updating the bet id for outcomes - for index := range outcomes { - outcomes[index].BetID = bet.ID - } - - rows, err := h.betSvc.CreateBetOutcome(c.Context(), outcomes) - - if err != nil { - h.logger.Error("CreateBetReq failed to create outcomes", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) - } - - res := convertCreateBet(bet, rows) - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } @@ -316,7 +145,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { // @Tags bet // @Accept json // @Produce json -// @Success 200 {array} BetRes +// @Success 200 {array} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet [get] @@ -327,9 +156,9 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bets") } - res := make([]BetRes, len(bets)) + res := make([]domain.BetRes, len(bets)) for i, bet := range bets { - res[i] = convertBet(bet) + res[i] = domain.ConvertBet(bet) } return response.WriteJSON(c, fiber.StatusOK, "All bets retrieved successfully", res, nil) @@ -342,7 +171,7 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "Bet ID" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet/{id} [get] @@ -356,11 +185,12 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { 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) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bet") + return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve bet") } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) @@ -373,7 +203,7 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path string true "cashout ID" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet/cashout/{id} [get] @@ -392,7 +222,7 @@ func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil) } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index f261c4f..395ba19 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -141,7 +142,8 @@ func (h *Handler) CreateBranch(c *fiber.Ctx) error { checkedCompanyID = *req.CompanyID } else { IsSelfOwned = false - checkedCompanyID = companyID.Value //the company id is always valid when its not a super admin + checkedCompanyID = companyID.Value + //TODO:check that the company id is always valid when its not a super admin } // Create Branch Wallet @@ -492,13 +494,71 @@ func (h *Handler) GetBranchOperations(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Branch Operations retrieved successfully", result, nil) } +// GetBranchCashiers godoc +// @Summary Gets branch cashiers +// @Description Gets branch cashiers +// @Tags branch +// @Accept json +// @Produce json +// @Param id path int true "Branch ID" +// @Success 200 {array} GetCashierRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /branch/{id}/cashier [get] +func (h *Handler) GetBranchCashiers(c *fiber.Ctx) error { + branchID := c.Params("id") + id, err := strconv.ParseInt(branchID, 10, 64) + if err != nil { + h.logger.Error("Invalid branch ID", "branchID", branchID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid branch ID", err, nil) + } + + cashiers, err := h.userSvc.GetCashiersByBranch(c.Context(), id) + + if err != nil { + h.logger.Error("Failed to get cashier by branch ID", "branchID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve cashier", err, nil) + } + + var result []GetCashierRes = make([]GetCashierRes, 0, len(cashiers)) + + for _, cashier := range cashiers { + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), cashier.ID) + if err != nil { + if err == authentication.ErrRefreshTokenNotFound { + lastLogin = &cashier.CreatedAt + } else { + h.logger.Error("Failed to get user last login", "userID", cashier.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + } + result = append(result, GetCashierRes{ + ID: cashier.ID, + FirstName: cashier.FirstName, + LastName: cashier.LastName, + Email: cashier.Email, + PhoneNumber: cashier.PhoneNumber, + Role: cashier.Role, + EmailVerified: cashier.EmailVerified, + PhoneVerified: cashier.PhoneVerified, + CreatedAt: cashier.CreatedAt, + UpdatedAt: cashier.UpdatedAt, + SuspendedAt: cashier.SuspendedAt, + Suspended: cashier.Suspended, + LastLogin: *lastLogin, + }) + } + + return response.WriteJSON(c, fiber.StatusOK, "Branch Cashiers retrieved successfully", result, nil) +} + // GetBetByBranchID godoc // @Summary Gets bets by its branch id // @Description Gets bets by its branch id // @Tags branch // @Accept json // @Produce json -// @Success 200 {array} BetRes +// @Success 200 {array} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /branch/{id}/bets [get] @@ -517,9 +577,9 @@ func (h *Handler) GetBetByBranchID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bets", err, nil) } - var res []BetRes = make([]BetRes, 0, len(bets)) + var res []domain.BetRes = make([]domain.BetRes, 0, len(bets)) for _, bet := range bets { - res = append(res, convertBet(bet)) + res = append(res, domain.ConvertBet(bet)) } return response.WriteJSON(c, fiber.StatusOK, "Branch Bets Retrieved", res, nil) diff --git a/internal/web_server/handlers/cashier.go b/internal/web_server/handlers/cashier.go index 8a99d3b..4fdebfc 100644 --- a/internal/web_server/handlers/cashier.go +++ b/internal/web_server/handlers/cashier.go @@ -7,6 +7,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -87,6 +88,7 @@ type GetCashierRes struct { SuspendedAt time.Time `json:"suspended_at"` Suspended bool `json:"suspended"` LastLogin time.Time `json:"last_login"` + BranchID int64 `json:"branch_id"` } // GetAllCashiers godoc @@ -103,22 +105,31 @@ type GetCashierRes struct { // @Failure 500 {object} response.APIResponse // @Router /cashiers [get] func (h *Handler) GetAllCashiers(c *fiber.Ctx) error { - // branchId := int64(12) //c.Locals("branch_id").(int64) - // filter := user.Filter{ - // Role: string(domain.RoleCashier), - // 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) - // } + role := c.Locals("role").(domain.Role) + companyId := c.Locals("company_id").(domain.ValidInt64) - cashiers, err := h.userSvc.GetAllCashiers(c.Context()) + if role != domain.RoleSuperAdmin && !companyId.Valid { + return fiber.NewError(fiber.StatusInternalServerError, "Cannot get company ID") + } + filter := user.Filter{ + Role: string(domain.RoleCashier), + CompanyID: companyId, + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), + Valid: true, + }, + } + + valErrs, ok := h.validator.Validate(c, filter) + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + cashiers, total, err := h.userSvc.GetAllUsers(c.Context(), filter) if err != nil { h.logger.Error("GetAllCashiers failed", "error", err) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", nil, nil) @@ -154,11 +165,80 @@ func (h *Handler) GetAllCashiers(c *fiber.Ctx) error { }) } - return response.WriteJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", result, nil) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", result, nil, filter.Page.Value, int(total)) } -type updateUserReq struct { +// GetCashierByID godoc +// @Summary Get cashier by id +// @Description Get a single cashier by id +// @Tags cashier +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} UserProfileRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /cashier/{id} [get] +func (h *Handler) GetCashierByID(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) + // } + + stringID := c.Params("id") + cashierID, err := strconv.ParseInt(stringID, 10, 64) + if err != nil { + h.logger.Error("failed to fetch user using UserID", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) + } + + user, err := h.userSvc.GetCashierByID(c.Context(), cashierID) + if err != nil { + h.logger.Error("Get User By ID failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", 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", "cashierID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + lastLogin = &user.CreatedAt + } + + res := GetCashierRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + BranchID: user.BranchID, + } + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateCashierReq struct { FirstName string `json:"first_name" example:"John"` LastName string `json:"last_name" example:"Doe"` Suspended bool `json:"suspended" example:"false"` @@ -171,7 +251,7 @@ type updateUserReq struct { // @Accept json // @Produce json // @Param id path int true "Cashier ID" -// @Param cashier body updateUserReq true "Update cashier" +// @Param cashier body updateCashierReq true "Update cashier" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse @@ -184,7 +264,7 @@ func (h *Handler) UpdateCashier(c *fiber.Ctx) error { h.logger.Error("UpdateCashier failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) } - var req updateUserReq + var req updateCashierReq if err := c.BodyParser(&req); err != nil { h.logger.Error("UpdateCashier failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) diff --git a/internal/web_server/handlers/company_handler.go b/internal/web_server/handlers/company_handler.go index 6e0f713..2555cdd 100644 --- a/internal/web_server/handlers/company_handler.go +++ b/internal/web_server/handlers/company_handler.go @@ -25,12 +25,15 @@ type CompanyRes struct { } type GetCompanyRes struct { - ID int64 `json:"id" example:"1"` - Name string `json:"name" example:"CompanyName"` - AdminID int64 `json:"admin_id" example:"1"` - WalletID int64 `json:"wallet_id" example:"1"` - WalletBalance float32 `json:"balance" example:"1"` - IsActive bool `json:"is_active" example:"false"` + ID int64 `json:"id" example:"1"` + Name string `json:"name" example:"CompanyName"` + AdminID int64 `json:"admin_id" example:"1"` + WalletID int64 `json:"wallet_id" example:"1"` + WalletBalance float32 `json:"balance" example:"1"` + IsActive bool `json:"is_active" example:"false"` + AdminFirstName string `json:"admin_first_name" example:"John"` + AdminLastName string `json:"admin_last_name" example:"Doe"` + AdminPhoneNumber string `json:"admin_phone_number" example:"1234567890"` } func convertCompany(company domain.Company) CompanyRes { @@ -44,12 +47,15 @@ func convertCompany(company domain.Company) CompanyRes { func convertGetCompany(company domain.GetCompany) GetCompanyRes { return GetCompanyRes{ - ID: company.ID, - Name: company.Name, - AdminID: company.AdminID, - WalletID: company.WalletID, - WalletBalance: company.WalletBalance.Float32(), - IsActive: company.IsWalletActive, + ID: company.ID, + Name: company.Name, + AdminID: company.AdminID, + WalletID: company.WalletID, + WalletBalance: company.WalletBalance.Float32(), + IsActive: company.IsWalletActive, + AdminFirstName: company.AdminFirstName, + AdminLastName: company.AdminLastName, + AdminPhoneNumber: company.AdminPhoneNumber, } } @@ -235,7 +241,7 @@ func (h *Handler) UpdateCompany(c *fiber.Ctx) error { var req UpdateCompanyReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("CreateCompanyReq failed", "error", err) + h.logger.Error("UpdateCompanyReq failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) } valErrs, ok := h.validator.Validate(c, req) diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index c81b43f..1089821 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -25,7 +25,7 @@ import ( type Handler struct { logger *slog.Logger - notificationSvc notificationservice.NotificationStore + notificationSvc *notificationservice.Service userSvc *user.Service referralSvc referralservice.ReferralStore walletSvc *wallet.Service @@ -47,7 +47,7 @@ type Handler struct { func New( logger *slog.Logger, - notificationSvc notificationservice.NotificationStore, + notificationSvc *notificationservice.Service, validator *customvalidator.CustomValidator, walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, diff --git a/internal/web_server/handlers/manager.go b/internal/web_server/handlers/manager.go index 9edfbb6..948ca05 100644 --- a/internal/web_server/handlers/manager.go +++ b/internal/web_server/handlers/manager.go @@ -109,14 +109,23 @@ type ManagersRes struct { // @Failure 500 {object} response.APIResponse // @Router /managers [get] func (h *Handler) GetAllManagers(c *fiber.Ctx) error { + role := c.Locals("role").(domain.Role) + companyId := c.Locals("company_id").(domain.ValidInt64) + + if role != domain.RoleSuperAdmin && !companyId.Valid { + return fiber.NewError(fiber.StatusInternalServerError, "Cannot get company ID") + } filter := user.Filter{ - Role: string(domain.RoleBranchManager), - CompanyID: domain.ValidInt64{ - Value: int64(c.QueryInt("company_id")), + Role: string(domain.RoleBranchManager), + CompanyID: companyId, + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), Valid: true, }, - Page: c.QueryInt("page", 1) - 1, - PageSize: c.QueryInt("page_size", 10), } valErrs, ok := h.validator.Validate(c, filter) if !ok { @@ -156,24 +165,101 @@ func (h *Handler) GetAllManagers(c *fiber.Ctx) error { } } - return response.WritePaginatedJSON(c, fiber.StatusOK, "Managers retrieved successfully", result, nil, filter.Page, int(total)) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Managers retrieved successfully", result, nil, filter.Page.Value, int(total)) } +// GetManagerByID godoc +// @Summary Get manager by id +// @Description Get a single manager by id +// @Tags manager +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} ManagersRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /managers/{id} [get] +func (h *Handler) GetManagerByID(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) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid managers ID", nil, nil) + } + + user, err := h.userSvc.GetUserByID(c.Context(), userID) + if err != nil { + h.logger.Error("Get User By ID failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get managers", nil, nil) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + + lastLogin = &user.CreatedAt + } + + res := ManagersRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + } + + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateManagerReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Suspended bool `json:"suspended" example:"false"` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` +} + // UpdateManagers godoc // @Summary Update Managers // @Description Update Managers -// @Tags Managers +// @Tags manager // @Accept json // @Produce json -// @Param Managers body updateUserReq true "Update Managers" +// @Param Managers body updateManagerReq true "Update Managers" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /managers/{id} [put] func (h *Handler) UpdateManagers(c *fiber.Ctx) error { - var req updateUserReq + var req updateManagerReq if err := c.BodyParser(&req); err != nil { h.logger.Error("UpdateManagers failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) @@ -190,6 +276,19 @@ func (h *Handler) UpdateManagers(c *fiber.Ctx) error { h.logger.Error("UpdateManagers failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Managers ID", nil, nil) } + var companyID domain.ValidInt64 + role := c.Locals("role").(domain.Role) + if req.CompanyID != nil { + if role != domain.RoleSuperAdmin { + h.logger.Error("UpdateManagers failed", "error", err) + return response.WriteJSON(c, fiber.StatusUnauthorized, "This user role cannot modify company ID", nil, nil) + } + companyID = domain.ValidInt64{ + Value: *req.CompanyID, + Valid: true, + } + } + err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ UserId: ManagersId, FirstName: domain.ValidString{ @@ -204,6 +303,7 @@ func (h *Handler) UpdateManagers(c *fiber.Ctx) error { Value: req.Suspended, Valid: true, }, + CompanyID: companyID, }, ) if err != nil { diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 9d8ca1a..3f6f6b8 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -3,58 +3,107 @@ package handlers import ( "context" "encoding/json" + "fmt" + "net" + "net/http" + "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" "github.com/gofiber/fiber/v2" - "github.com/gofiber/websocket/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gorilla/websocket" + "github.com/valyala/fasthttp/fasthttpadaptor" ) -func (h *Handler) ConnectSocket(c *fiber.Ctx) error { - if !websocket.IsWebSocketUpgrade(c) { - h.logger.Warn("WebSocket upgrade required") - return fiber.ErrUpgradeRequired - } +func hijackHTTP(c *fiber.Ctx) (net.Conn, http.ResponseWriter, error) { + var rw http.ResponseWriter + var conn net.Conn + // This is a trick: fasthttpadaptor gives us the HTTP interfaces + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + return + } + var err error + conn, _, err = hj.Hijack() + if err != nil { + return + } + rw = w + }) + + fasthttpadaptor.NewFastHTTPHandler(handler)(c.Context()) + + if conn == nil || rw == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection") + } + return conn, rw, nil +} + +func (h *Handler) ConnectSocket(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int64) if !ok || userID == 0 { h.logger.Error("Invalid user ID in context") - return fiber.NewError(fiber.StatusUnauthorized, "invalid user identification") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") } - c.Locals("allowed", true) + // Convert *fiber.Ctx to *http.Request + req, err := adaptor.ConvertRequest(c, false) + if err != nil { + h.logger.Error("Failed to convert request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert request") + } - return websocket.New(func(conn *websocket.Conn) { - ctx := context.Background() - logger := h.logger.With("userID", userID, "remoteAddr", conn.RemoteAddr()) + // Create a net.Conn hijacked from the fasthttp context + netConn, rw, err := hijackHTTP(c) + if err != nil { + h.logger.Error("Failed to hijack connection", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection") + } - if err := h.notificationSvc.ConnectWebSocket(ctx, userID, conn); err != nil { - logger.Error("Failed to connect WebSocket", "error", err) - _ = conn.Close() - return - } + // Upgrade the connection using Gorilla's Upgrader + conn, err := ws.Upgrader.Upgrade(rw, req, nil) + if err != nil { + h.logger.Error("WebSocket upgrade failed", "error", err) + netConn.Close() + return fiber.NewError(fiber.StatusInternalServerError, "WebSocket upgrade failed") + } - logger.Info("WebSocket connection established") + client := &ws.Client{ + Conn: conn, + RecipientID: userID, + } - defer func() { - h.notificationSvc.DisconnectWebSocket(userID) - logger.Info("WebSocket connection closed") - _ = conn.Close() - }() + h.notificationSvc.Hub.Register <- client + h.logger.Info("WebSocket connection established", "userID", userID) - for { - if _, _, err := conn.ReadMessage(); err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - logger.Warn("WebSocket unexpected close", "error", err) - } - break + defer func() { + h.notificationSvc.Hub.Unregister <- client + h.logger.Info("WebSocket connection closed", "userID", userID) + conn.Close() + }() + + for { + _, _, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + h.logger.Info("WebSocket closed normally", "userID", userID) + } else { + h.logger.Warn("Unexpected WebSocket closure", "userID", userID, "error", err) } + break } - })(c) + } + + return nil } func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { type Request struct { - NotificationID string `json:"notification_id" validate:"required"` + NotificationIDs []string `json:"notification_ids" validate:"required"` } var req Request @@ -63,14 +112,15 @@ func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - userID, ok := c.Locals("userID").(int64) + userID, ok := c.Locals("user_id").(int64) if !ok || userID == 0 { h.logger.Error("Invalid user ID in context") return fiber.NewError(fiber.StatusUnauthorized, "invalid user identification") } - if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationID, userID); err != nil { - h.logger.Error("Failed to mark notification as read", "notificationID", req.NotificationID, "error", err) + fmt.Printf("Notification IDs: %v \n", req.NotificationIDs) + if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationIDs, userID); err != nil { + h.logger.Error("Failed to mark notifications as read", "notificationID", req.NotificationIDs, "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update notification status") } @@ -97,18 +147,18 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - userID, ok := c.Locals("userID").(int64) - if !ok || userID == 0 { - h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context") - return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") - } + // userID, ok := c.Locals("userID").(int64) + // if !ok || userID == 0 { + // h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context") + // return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + // } switch req.DeliveryScheme { case domain.NotificationDeliverySchemeSingle: - if req.Reciever == domain.NotificationRecieverSideCustomer && req.RecipientID != userID { - h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "userID", userID, "recipientID", req.RecipientID) - return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient") - } + // if req.Reciever == domain.NotificationRecieverSideCustomer { + // h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "recipientID", req.RecipientID) + // return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient") + // } notification := &domain.Notification{ ID: "", @@ -134,17 +184,21 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "Single notification sent successfully", "notification_id": notification.ID}) case domain.NotificationDeliverySchemeBulk: - recipients, err := h.getAllRecipientIDs(context.Background(), req.Reciever) + recipients, _, err := h.userSvc.GetAllUsers(context.Background(), user.Filter{ + Role: string(req.Reciever), + }) if err != nil { h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to fetch recipients for bulk notification", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch recipients") } + fmt.Printf("Number of Recipients %d \n", len(recipients)) + notificationIDs := make([]string, 0, len(recipients)) - for _, recipientID := range recipients { + for _, user := range recipients { notification := &domain.Notification{ ID: "", - RecipientID: recipientID, + RecipientID: user.ID, Type: req.Type, Level: req.Level, ErrorSeverity: req.ErrorSeverity, @@ -158,7 +212,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { } if err := h.notificationSvc.SendNotification(context.Background(), notification); err != nil { - h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to send bulk notification", "recipientID", recipientID, "error", err) + h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to send bulk notification", "UserID", user.ID, "error", err) continue } notificationIDs = append(notificationIDs, notification.ID) @@ -177,6 +231,94 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { } } -func (h *Handler) getAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) { +func (h *Handler) GetNotifications(c *fiber.Ctx) error { + limitStr := c.Query("limit", "10") + offsetStr := c.Query("offset", "0") + + // Convert limit and offset to integers + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid limit value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value") + } + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid offset value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid offset value") + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid user ID in context") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + notifications, err := h.notificationSvc.ListNotifications(context.Background(), userID, limit, offset) + if err != nil { + h.logger.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "notifications": notifications, + "total_count": len(notifications), + "limit": limit, + "offset": offset, + }) + +} + +func (h *Handler) GetAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) { return h.notificationSvc.ListRecipientIDs(ctx, receiver) } + +func (h *Handler) CountUnreadNotifications(c *fiber.Ctx) error { + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid user ID in context") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + total, err := h.notificationSvc.CountUnreadNotifications(c.Context(), userID) + + if err != nil { + h.logger.Error("[NotificationSvc.CountUnreadNotifications] Failed to fetch unread notification count", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "unread": total, + }) +} + +func (h *Handler) GetAllNotifications(c *fiber.Ctx) error { + limitStr := c.Query("limit", "10") + pageStr := c.Query("page", "1") + + // Convert limit and offset to integers + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid limit value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value") + } + page, err := strconv.Atoi(pageStr) + if err != nil || page <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid page value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid page value") + } + + notifications, err := h.notificationSvc.GetAllNotifications(context.Background(), limit, ((page - 1) * limit)) + if err != nil { + h.logger.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "notifications": notifications, + "total_count": len(notifications), + "limit": limit, + "page": page, + }) + +} diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go index e4790e8..b8d3778 100644 --- a/internal/web_server/handlers/prematch.go +++ b/internal/web_server/handlers/prematch.go @@ -2,6 +2,7 @@ package handlers import ( "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" @@ -98,6 +99,8 @@ func (h *Handler) GetRawOddsByMarketID(c *fiber.Ctx) error { // @Param page_size query int false "Page size" // @Param league_id query string false "League ID Filter" // @Param sport_id query string false "Sport ID Filter" +// @Param first_start_time query string false "Start Time" +// @Param last_start_time query string false "End Time" // @Success 200 {array} domain.UpcomingEvent // @Failure 500 {object} response.APIResponse // @Router /prematch/events [get] @@ -106,6 +109,8 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { pageSize := c.QueryInt("page_size", 10) leagueIDQuery := c.Query("league_id") sportIDQuery := c.Query("sport_id") + firstStartTimeQuery := c.Query("first_start_time") + lastStartTimeQuery := c.Query("last_start_time") leagueID := domain.ValidString{ Value: leagueIDQuery, @@ -116,7 +121,42 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { Valid: sportIDQuery != "", } - events, total, err := h.eventSvc.GetPaginatedUpcomingEvents(c.Context(), int32(pageSize), int32(page)-1, leagueID, sportID) + var firstStartTime domain.ValidTime + if firstStartTimeQuery != "" { + firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) + if err != nil { + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + firstStartTime = domain.ValidTime{ + Value: firstStartTimeParsed, + 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) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + lastStartTime = domain.ValidTime{ + Value: lastStartTimeParsed, + Valid: true, + } + } + + limit := domain.ValidInt64{ + Value: int64(pageSize), + Valid: true, + } + offset := domain.ValidInt64{ + Value: int64(page - 1), + Valid: true, + } + + events, total, err := h.eventSvc.GetPaginatedUpcomingEvents( + c.Context(), limit, offset, leagueID, sportID, firstStartTime, lastStartTime) // fmt.Printf("League ID: %v", leagueID) if err != nil { @@ -183,7 +223,7 @@ func (h *Handler) GetPrematchOddsByUpcomingID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid offset value", nil, nil) } - odds, err := h.prematchSvc.GetPrematchOddsByUpcomingID(c.Context(), upcomingID, int32(limit), int32(offset)) + odds, err := h.prematchSvc.GetPrematchOddsByUpcomingID(c.Context(), upcomingID) if err != nil { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve prematch odds", nil, nil) } diff --git a/internal/web_server/handlers/ticket_handler.go b/internal/web_server/handlers/ticket_handler.go index f91d0f2..de3eeb2 100644 --- a/internal/web_server/handlers/ticket_handler.go +++ b/internal/web_server/handlers/ticket_handler.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" @@ -76,10 +77,10 @@ func (h *Handler) CreateTicket(c *fiber.Ctx) error { } // Checking to make sure the event hasn't already started - // currentTime := time.Now() - // if event.StartTime.Before(currentTime) { - // return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - // } + currentTime := time.Now() + if event.StartTime.Before(currentTime) { + return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) + } odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) @@ -182,7 +183,7 @@ func (h *Handler) GetTicketByID(c *fiber.Ctx) error { ticket, err := h.ticketSvc.GetTicketByID(c.Context(), id) if err != nil { - // h.logger.Error("Failed to get ticket by ID", "ticketID", id, "error", err) + h.logger.Error("Failed to get ticket by ID", "ticketID", id, "error", err) return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve ticket") } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 09fd436..a0121f9 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -150,9 +150,7 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { medium, err := getMedium(req.Email, req.PhoneNumber) if err != nil { h.logger.Error("RegisterUser failed", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": err.Error(), - }) + return fiber.NewError(fiber.StatusBadRequest, err.Error()) } user.OtpMedium = medium @@ -160,24 +158,22 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { newUser, err := h.userSvc.RegisterUser(c.Context(), user) if err != nil { if errors.Is(err, domain.ErrOtpAlreadyUsed) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Otp already used", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Otp already used") } if errors.Is(err, domain.ErrOtpExpired) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Otp expired", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Otp expired") } if errors.Is(err, domain.ErrInvalidOtp) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid otp", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Invalid otp") } if errors.Is(err, domain.ErrOtpNotFound) { - return response.WriteJSON(c, fiber.StatusBadRequest, "User already exist", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "User already exist") } h.logger.Error("RegisterUser failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) + return fiber.NewError(fiber.StatusInternalServerError, "Unknown Error") } - _, err = h.walletSvc.CreateWallet(c.Context(), domain.CreateWallet{ + newWallet, err := h.walletSvc.CreateWallet(c.Context(), domain.CreateWallet{ UserID: newUser.ID, IsWithdraw: true, IsBettable: true, @@ -194,6 +190,14 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { } } + // TODO: Remove later + err = h.walletSvc.AddToWallet(c.Context(), newWallet.ID, domain.ToCurrency(100.0)) + + if err != nil { + h.logger.Error("Failed to update wallet for user", "userID", newUser.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update user wallet") + } + return response.WriteJSON(c, fiber.StatusOK, "Registration successful", nil, nil) } @@ -377,7 +381,8 @@ func getMedium(email, phoneNumber string) (domain.OtpMedium, error) { } type SearchUserByNameOrPhoneReq struct { - SearchString string + SearchString string `json:"query"` + Role *domain.Role `json:"role,omitempty"` } // SearchUserByNameOrPhone godoc @@ -405,7 +410,8 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error { response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) return nil } - users, err := h.userSvc.SearchUserByNameOrPhone(c.Context(), req.SearchString) + companyID := c.Locals("company_id").(domain.ValidInt64) + users, err := h.userSvc.SearchUserByNameOrPhone(c.Context(), req.SearchString, req.Role, companyID) if err != nil { h.logger.Error("SearchUserByNameOrPhone failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -450,7 +456,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "User ID" -// @Success 200 {object} response.APIResponse +// @Success 200 {object} UserProfileRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -474,13 +480,13 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { userIDstr := c.Params("id") userID, err := strconv.ParseInt(userIDstr, 10, 64) if err != nil { - h.logger.Error("UpdateCashier failed", "error", err) + h.logger.Error("failed to fetch user using UserID", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) } user, err := h.userSvc.GetUserByID(c.Context(), userID) if err != nil { - h.logger.Error("GetAllCashiers failed", "error", err) + h.logger.Error("Get User By ID failed", "error", err) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", nil, nil) } @@ -510,6 +516,78 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { LastLogin: *lastLogin, } - return response.WriteJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", res, nil) + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) } + +// DeleteUser godoc +// @Summary Delete user by ID +// @Description Delete a user by their ID +// @Tags user +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/delete/{id} [delete] +func (h *Handler) DeleteUser(c *fiber.Ctx) error { + userIDstr := c.Params("id") + userID, err := strconv.ParseInt(userIDstr, 10, 64) + if err != nil { + h.logger.Error("DeleteUser failed", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid user ID", nil, nil) + } + + err = h.userSvc.DeleteUser(c.Context(), userID) + if err != nil { + h.logger.Error("Failed to delete user", "userID", userID, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to delete user", nil, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil) +} + +type UpdateUserSuspendReq struct { + UserID int64 `json:"user_id" validate:"required" example:"123"` + Suspended bool `json:"suspended" validate:"required" example:"true"` +} +type UpdateUserSuspendRes struct { + UserID int64 `json:"user_id"` + Suspended bool `json:"suspended"` +} + +// UpdateUserSuspend godoc +// @Summary Suspend or unsuspend a user +// @Description Suspend or unsuspend a user +// @Tags user +// @Accept json +// @Produce json +// @Param updateUserSuspend body UpdateUserSuspendReq true "Suspend or unsuspend a user" +// @Success 200 {object} UpdateUserSuspendRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/suspend [post] +func (h *Handler) UpdateUserSuspend(c *fiber.Ctx) error { + var req UpdateUserSuspendReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse UpdateUserSuspend request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + err := h.userSvc.UpdateUserSuspend(c.Context(), req.UserID, req.Suspended) + if err != nil { + h.logger.Error("Failed to update user suspend status", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update user suspend status") + } + + res := UpdateUserSuspendRes{ + UserID: req.UserID, + Suspended: req.Suspended, + } + return response.WriteJSON(c, fiber.StatusOK, "User suspend status updated successfully", res, nil) +} diff --git a/internal/web_server/handlers/wallet_handler.go b/internal/web_server/handlers/wallet_handler.go index cf1bce6..a7aa599 100644 --- a/internal/web_server/handlers/wallet_handler.go +++ b/internal/web_server/handlers/wallet_handler.go @@ -45,13 +45,12 @@ type CustomerWalletRes struct { StaticID int64 `json:"static_id" example:"1"` StaticBalance float32 `json:"static_balance" example:"100.0"` CustomerID int64 `json:"customer_id" example:"1"` - CompanyID int64 `json:"company_id" example:"1"` RegularUpdatedAt time.Time `json:"regular_updated_at"` StaticUpdatedAt time.Time `json:"static_updated_at"` CreatedAt time.Time `json:"created_at"` } -func convertCustomerWallet(wallet domain.GetCustomerWallet) CustomerWalletRes { +func ConvertCustomerWallet(wallet domain.GetCustomerWallet) CustomerWalletRes { return CustomerWalletRes{ ID: wallet.ID, RegularID: wallet.RegularID, @@ -59,7 +58,6 @@ func convertCustomerWallet(wallet domain.GetCustomerWallet) CustomerWalletRes { StaticID: wallet.StaticID, StaticBalance: wallet.StaticBalance.Float32(), CustomerID: wallet.CustomerID, - CompanyID: wallet.CompanyID, RegularUpdatedAt: wallet.RegularUpdatedAt, StaticUpdatedAt: wallet.StaticUpdatedAt, CreatedAt: wallet.CreatedAt, @@ -249,21 +247,21 @@ func (h *Handler) GetCustomerWallet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized access") } - companyID, err := strconv.ParseInt(c.Get("company_id"), 10, 64) - if err != nil { - h.logger.Error("Invalid company_id header", "value", c.Get("company_id"), "error", err) - return fiber.NewError(fiber.StatusBadRequest, "Invalid company_id") - } + // companyID, err := strconv.ParseInt(c.Get("company_id"), 10, 64) + // if err != nil { + // h.logger.Error("Invalid company_id header", "value", c.Get("company_id"), "error", err) + // return fiber.NewError(fiber.StatusBadRequest, "Invalid company_id") + // } - h.logger.Info("Fetching customer wallet", "userID", userID, "companyID", companyID) + h.logger.Info("Fetching customer wallet", "userID", userID) - wallet, err := h.walletSvc.GetCustomerWallet(c.Context(), userID, companyID) + wallet, err := h.walletSvc.GetWalletsByUser(c.Context(), userID) if err != nil { - h.logger.Error("Failed to get customer wallet", "userID", userID, "companyID", companyID, "error", err) + h.logger.Error("Failed to get customer wallet", "userID", userID, "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve wallet") } - res := convertCustomerWallet(wallet) + res := convertWallet(wallet[0]) return response.WriteJSON(c, fiber.StatusOK, "Wallet retrieved successfully", res, nil) } diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index c7c90d5..8e550cb 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -44,7 +44,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error { } // Asserting to make sure that there is no company role without a valid company id - if claim.Role != domain.RoleSuperAdmin && !claim.CompanyID.Valid { + if claim.Role != domain.RoleSuperAdmin && claim.Role != domain.RoleCustomer && !claim.CompanyID.Valid { fmt.Println("Company Role without Company ID") return fiber.NewError(fiber.StatusInternalServerError, "Company Role without Company ID") } @@ -71,3 +71,31 @@ func (a *App) CompanyOnly(c *fiber.Ctx) error { } return c.Next() } + +func (a *App) WebsocketAuthMiddleware(c *fiber.Ctx) error { + tokenStr := c.Query("token") + if tokenStr == "" { + a.logger.Error("Missing token in query parameter") + return fiber.NewError(fiber.StatusUnauthorized, "Missing token") + } + + claim, err := jwtutil.ParseJwt(tokenStr, a.JwtConfig.JwtAccessKey) + if err != nil { + if errors.Is(err, jwtutil.ErrExpiredToken) { + a.logger.Error("Token expired") + return fiber.NewError(fiber.StatusUnauthorized, "Token expired") + } + a.logger.Error("Invalid token", "error", err) + return fiber.NewError(fiber.StatusUnauthorized, "Invalid token") + } + + userID := claim.UserId + if userID == 0 { + a.logger.Error("Invalid user ID in token claims") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID") + } + + c.Locals("userID", userID) + a.logger.Info("Authenticated WebSocket connection", "userID", userID) + return c.Next() +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 3172a06..b9024ec 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -39,7 +39,7 @@ func (a *App) initAppRoutes() { a.fiber.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "Welcome to the FortuneBet API", - "version": "1.0.1", + "version": "1.0dev2", }) }) @@ -81,6 +81,8 @@ func (a *App) initAppRoutes() { a.fiber.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) a.fiber.Get("/user/profile", a.authMiddleware, h.UserProfile) a.fiber.Get("/user/single/:id", a.authMiddleware, h.GetUserByID) + a.fiber.Delete("/user/delete/:id", a.authMiddleware, h.DeleteUser) + a.fiber.Post("/user/suspend", a.authMiddleware, h.UpdateUserSuspend) a.fiber.Get("/user/wallet", a.authMiddleware, h.GetCustomerWallet) a.fiber.Post("/user/search", a.authMiddleware, h.SearchUserByNameOrPhone) @@ -92,13 +94,17 @@ func (a *App) initAppRoutes() { a.fiber.Patch("/referral/settings", a.authMiddleware, h.UpdateReferralSettings) a.fiber.Get("/cashiers", a.authMiddleware, h.GetAllCashiers) + a.fiber.Get("/cashiers/:id", a.authMiddleware, h.GetCashierByID) a.fiber.Post("/cashiers", a.authMiddleware, h.CreateCashier) a.fiber.Put("/cashiers/:id", a.authMiddleware, h.UpdateCashier) a.fiber.Get("/admin", a.authMiddleware, h.GetAllAdmins) + a.fiber.Get("/admin/:id", a.authMiddleware, h.GetAdminByID) a.fiber.Post("/admin", a.authMiddleware, h.CreateAdmin) + a.fiber.Put("/admin/:id", a.authMiddleware, h.UpdateAdmin) a.fiber.Get("/managers", a.authMiddleware, h.GetAllManagers) + a.fiber.Get("/managers/:id", a.authMiddleware, h.GetManagerByID) a.fiber.Post("/managers", a.authMiddleware, h.CreateManager) a.fiber.Put("/managers/:id", a.authMiddleware, h.UpdateManagers) a.fiber.Get("/manager/:id/branch", a.authMiddleware, h.GetBranchByManagerID) @@ -124,12 +130,14 @@ func (a *App) initAppRoutes() { a.fiber.Get("/search/branch", a.authMiddleware, h.SearchBranch) // /branch/search // branch/wallet + a.fiber.Get("/branch/:id/cashiers", a.authMiddleware, h.GetBranchCashiers) // Branch Operation a.fiber.Get("/supportedOperation", a.authMiddleware, h.GetAllSupportedOperations) a.fiber.Post("/supportedOperation", a.authMiddleware, h.CreateSupportedOperation) a.fiber.Post("/operation", a.authMiddleware, h.CreateBranchOperation) a.fiber.Get("/branch/:id/operation", a.authMiddleware, h.GetBranchOperations) + a.fiber.Delete("/branch/:id/operation/:opID", a.authMiddleware, h.DeleteBranchOperation) // Company @@ -149,11 +157,13 @@ func (a *App) initAppRoutes() { // Bet Routes a.fiber.Post("/bet", a.authMiddleware, h.CreateBet) a.fiber.Get("/bet", a.authMiddleware, h.GetAllBet) - a.fiber.Get("/bet/:id", a.authMiddleware, h.GetBetByID) + a.fiber.Get("/bet/:id", h.GetBetByID) a.fiber.Get("/bet/cashout/:id", a.authMiddleware, h.GetBetByCashoutID) a.fiber.Patch("/bet/:id", a.authMiddleware, h.UpdateCashOut) a.fiber.Delete("/bet/:id", a.authMiddleware, h.DeleteBet) + a.fiber.Post("/random/bet", a.authMiddleware, h.RandomBet) + // Wallet a.fiber.Get("/wallet", h.GetAllWallets) a.fiber.Get("/wallet/:id", h.GetWalletByID) @@ -191,13 +201,17 @@ func (a *App) initAppRoutes() { a.fiber.Put("/transaction/:id", a.authMiddleware, h.UpdateTransactionVerified) // Notification Routes - a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket) - a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) + a.fiber.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) + a.fiber.Get("/notifications", a.authMiddleware, h.GetNotifications) + a.fiber.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications) + a.fiber.Post("/notifications/mark-as-read", a.authMiddleware, h.MarkNotificationAsRead) + a.fiber.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications) a.fiber.Post("/notifications/create", h.CreateAndSendNotification) // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + } ///user/profile get diff --git a/internal/web_server/ws/ws.go b/internal/web_server/ws/ws.go new file mode 100644 index 0000000..28fb860 --- /dev/null +++ b/internal/web_server/ws/ws.go @@ -0,0 +1,73 @@ +package ws + +import ( + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" +) + +type Client struct { + Conn *websocket.Conn + RecipientID int64 +} + +type NotificationHub struct { + Clients map[*Client]bool + Broadcast chan interface{} + Register chan *Client + Unregister chan *Client + mu sync.Mutex +} + +func NewNotificationHub() *NotificationHub { + return &NotificationHub{ + Clients: make(map[*Client]bool), + Broadcast: make(chan interface{}, 1000), + Register: make(chan *Client), + Unregister: make(chan *Client), + } +} + +func (h *NotificationHub) Run() { + for { + select { + case client := <-h.Register: + h.mu.Lock() + h.Clients[client] = true + h.mu.Unlock() + log.Printf("Client registered: %d", client.RecipientID) + case client := <-h.Unregister: + h.mu.Lock() + if _, ok := h.Clients[client]; ok { + delete(h.Clients, client) + client.Conn.Close() + } + h.mu.Unlock() + log.Printf("Client unregistered: %d", client.RecipientID) + case message := <-h.Broadcast: + h.mu.Lock() + for client := range h.Clients { + if payload, ok := message.(map[string]interface{}); ok { + if recipient, ok := payload["recipient_id"].(int64); ok && recipient == client.RecipientID { + err := client.Conn.WriteJSON(payload) + if err != nil { + delete(h.Clients, client) + client.Conn.Close() + } + } + } + } + h.mu.Unlock() + } + } +} + +var Upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} diff --git a/makefile b/makefile index ebf6b14..5c688d8 100644 --- a/makefile +++ b/makefile @@ -1,41 +1,58 @@ include .env .PHONY: test test: - @go test ./app + @docker compose up -d test + @docker compose exec test go test ./... + @docker compose stop test + .PHONY: coverage coverage: @mkdir -p coverage - @go test -coverprofile=coverage.out ./internal/... - @go tool cover -func=coverage.out -o coverage/coverage.txt + @docker compose up -d test + @docker compose exec test sh -c "go test -coverprofile=coverage.out ./internal/... && go tool cover -func=coverage.out -o coverage/coverage.txt" + @docker cp $(shell docker ps -q -f "name=fortunebet-test-1"):/app/coverage ./ || true + @docker compose stop test + .PHONY: build build: - @go build -ldflags="-s" -o ./bin/web ./cmd/main.go + @docker compose build app + .PHONY: run run: - @echo "Running Go application" - @go run ./cmd/main.go + @docker compose up -d + +.PHONY: stop +stop: + @docker compose down + .PHONY: air air: - @echo "Running air" + @echo "Running air locally (not in Docker)" @air -c .air.toml -.PHONY: migrations/new +.PHONY: migrations/up migrations/new: @echo 'Creating migration files for DB_URL' @migrate create -seq -ext=.sql -dir=./db/migrations $(name) + .PHONY: migrations/up migrations/up: @echo 'Running up migrations...' - @migrate -path ./db/migrations -database $(DB_URL) up + @docker compose up migrate .PHONY: swagger swagger: @swag init -g cmd/main.go + .PHONY: db-up db-up: - docker compose -f compose.db.yaml up + @docker compose up -d postgres migrate + .PHONY: db-down db-down: - docker compose -f compose.db.yaml down + @docker compose down +postgres: + @docker exec -it fortunebet-backend-postgres-1 psql -U root -d gh + .PHONY: sqlc-gen sqlc-gen: @sqlc generate \ No newline at end of file