From 27924aeb3c5fceadec2ab43afc78343d06d78e80 Mon Sep 17 00:00:00 2001 From: Meninjar Date: Mon, 18 Aug 2025 12:23:14 +0700 Subject: [PATCH] perbaikan generete tool --- docs/docs.go | 526 +++++++ docs/swagger.json | 526 +++++++ docs/swagger.yaml | 348 +++++ internal/handlers/order/order.go | 683 +++++++++ internal/handlers/product/product.go | 683 +++++++++ internal/models/order/order.go | 195 +++ internal/models/product/product.go | 195 +++ internal/routes/v1/routes.go | 10 + internal/services/product/product_service.go | 141 -- tools/HANDLER.md | 3 +- tools/diagnostic.go | 2 +- tools/generate-handler.go | 1296 ++++++++++++++---- 12 files changed, 4215 insertions(+), 393 deletions(-) create mode 100644 internal/handlers/order/order.go create mode 100644 internal/handlers/product/product.go create mode 100644 internal/models/order/order.go create mode 100644 internal/models/product/product.go delete mode 100644 internal/services/product/product_service.go diff --git a/docs/docs.go b/docs/docs.go index ce68b76..b560b2e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,6 +24,56 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/Order/{id}": { + "get": { + "description": "Returns a single order by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Get Order by ID", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success response", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderGetByIDResponse" + } + }, + "400": { + "description": "Invalid ID format", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, "/api/v1/auth/login": { "post": { "description": "Authenticate user with username and password to receive JWT token", @@ -214,6 +264,264 @@ const docTemplate = `{ } } }, + "/api/v1/order/{id}": { + "put": { + "description": "Updates an existing order record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Update order", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Order update request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Order updated successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderUpdateResponse" + } + }, + "400": { + "description": "Bad request or validation error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "Order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Soft deletes a order by setting status to 'deleted'", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Delete order", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Order deleted successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderDeleteResponse" + } + }, + "400": { + "description": "Invalid ID format", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "Order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, + "/api/v1/orders": { + "get": { + "description": "Returns a paginated list of orders with optional summary statistics", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Get order with pagination and optional aggregation", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "description": "Include aggregation summary", + "name": "include_summary", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search in multiple fields", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success response", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderGetResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new order record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Create order", + "parameters": [ + { + "description": "Order creation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Order created successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderCreateResponse" + } + }, + "400": { + "description": "Bad request or validation error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, + "/api/v1/orders/stats": { + "get": { + "description": "Returns comprehensive statistics about order data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Get order statistics", + "parameters": [ + { + "type": "string", + "description": "Filter statistics by status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Statistics data", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.AggregateData" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, "/api/v1/retribusi/{id}": { "get": { "description": "Returns a single retribusi by ID", @@ -678,6 +986,224 @@ const docTemplate = `{ } } }, + "api-service_internal_models_order.AggregateData": { + "type": "object", + "properties": { + "by_status": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "created_today": { + "type": "integer" + }, + "last_updated": { + "type": "string" + }, + "total_active": { + "type": "integer" + }, + "total_draft": { + "type": "integer" + }, + "total_inactive": { + "type": "integer" + }, + "updated_today": { + "type": "integer" + } + } + }, + "api-service_internal_models_order.ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "api-service_internal_models_order.MetaResponse": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "api-service_internal_models_order.NullableInt32": { + "type": "object", + "properties": { + "int32": { + "type": "integer" + }, + "valid": { + "type": "boolean" + } + } + }, + "api-service_internal_models_order.Order": { + "type": "object", + "properties": { + "date_created": { + "$ref": "#/definitions/sql.NullTime" + }, + "date_updated": { + "$ref": "#/definitions/sql.NullTime" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/sql.NullString" + }, + "sort": { + "$ref": "#/definitions/api-service_internal_models_order.NullableInt32" + }, + "status": { + "type": "string" + }, + "user_created": { + "$ref": "#/definitions/sql.NullString" + }, + "user_updated": { + "$ref": "#/definitions/sql.NullString" + } + } + }, + "api-service_internal_models_order.OrderCreateRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "inactive" + ] + } + } + }, + "api-service_internal_models_order.OrderCreateResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderDeleteResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderGetByIDResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderGetResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + } + }, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/api-service_internal_models_order.MetaResponse" + }, + "summary": { + "$ref": "#/definitions/api-service_internal_models_order.AggregateData" + } + } + }, + "api-service_internal_models_order.OrderUpdateRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "inactive" + ] + } + } + }, + "api-service_internal_models_order.OrderUpdateResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, "api-service_internal_models_retribusi.AggregateData": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 7126783..8056014 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -22,6 +22,56 @@ "host": "localhost:8080", "basePath": "/api/v1", "paths": { + "/api/v1/Order/{id}": { + "get": { + "description": "Returns a single order by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Order" + ], + "summary": "Get Order by ID", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success response", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderGetByIDResponse" + } + }, + "400": { + "description": "Invalid ID format", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, "/api/v1/auth/login": { "post": { "description": "Authenticate user with username and password to receive JWT token", @@ -212,6 +262,264 @@ } } }, + "/api/v1/order/{id}": { + "put": { + "description": "Updates an existing order record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Update order", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Order update request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "Order updated successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderUpdateResponse" + } + }, + "400": { + "description": "Bad request or validation error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "Order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Soft deletes a order by setting status to 'deleted'", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Delete order", + "parameters": [ + { + "type": "string", + "description": "Order ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Order deleted successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderDeleteResponse" + } + }, + "400": { + "description": "Invalid ID format", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "404": { + "description": "Order not found", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, + "/api/v1/orders": { + "get": { + "description": "Returns a paginated list of orders with optional summary statistics", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Get order with pagination and optional aggregation", + "parameters": [ + { + "type": "integer", + "default": 10, + "description": "Limit (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "description": "Include aggregation summary", + "name": "include_summary", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Search in multiple fields", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success response", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderGetResponse" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new order record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Create order", + "parameters": [ + { + "description": "Order creation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Order created successfully", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.OrderCreateResponse" + } + }, + "400": { + "description": "Bad request or validation error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, + "/api/v1/orders/stats": { + "get": { + "description": "Returns comprehensive statistics about order data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "order" + ], + "summary": "Get order statistics", + "parameters": [ + { + "type": "string", + "description": "Filter statistics by status", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Statistics data", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.AggregateData" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/api-service_internal_models_order.ErrorResponse" + } + } + } + } + }, "/api/v1/retribusi/{id}": { "get": { "description": "Returns a single retribusi by ID", @@ -676,6 +984,224 @@ } } }, + "api-service_internal_models_order.AggregateData": { + "type": "object", + "properties": { + "by_status": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "created_today": { + "type": "integer" + }, + "last_updated": { + "type": "string" + }, + "total_active": { + "type": "integer" + }, + "total_draft": { + "type": "integer" + }, + "total_inactive": { + "type": "integer" + }, + "updated_today": { + "type": "integer" + } + } + }, + "api-service_internal_models_order.ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "api-service_internal_models_order.MetaResponse": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "api-service_internal_models_order.NullableInt32": { + "type": "object", + "properties": { + "int32": { + "type": "integer" + }, + "valid": { + "type": "boolean" + } + } + }, + "api-service_internal_models_order.Order": { + "type": "object", + "properties": { + "date_created": { + "$ref": "#/definitions/sql.NullTime" + }, + "date_updated": { + "$ref": "#/definitions/sql.NullTime" + }, + "id": { + "type": "string" + }, + "name": { + "$ref": "#/definitions/sql.NullString" + }, + "sort": { + "$ref": "#/definitions/api-service_internal_models_order.NullableInt32" + }, + "status": { + "type": "string" + }, + "user_created": { + "$ref": "#/definitions/sql.NullString" + }, + "user_updated": { + "$ref": "#/definitions/sql.NullString" + } + } + }, + "api-service_internal_models_order.OrderCreateRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "inactive" + ] + } + } + }, + "api-service_internal_models_order.OrderCreateResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderDeleteResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderGetByIDResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, + "api-service_internal_models_order.OrderGetResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + } + }, + "message": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/api-service_internal_models_order.MetaResponse" + }, + "summary": { + "$ref": "#/definitions/api-service_internal_models_order.AggregateData" + } + } + }, + "api-service_internal_models_order.OrderUpdateRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "inactive" + ] + } + } + }, + "api-service_internal_models_order.OrderUpdateResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api-service_internal_models_order.Order" + }, + "message": { + "type": "string" + } + } + }, "api-service_internal_models_retribusi.AggregateData": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d32ff42..850f0fd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -30,6 +30,150 @@ definitions: username: type: string type: object + api-service_internal_models_order.AggregateData: + properties: + by_status: + additionalProperties: + type: integer + type: object + created_today: + type: integer + last_updated: + type: string + total_active: + type: integer + total_draft: + type: integer + total_inactive: + type: integer + updated_today: + type: integer + type: object + api-service_internal_models_order.ErrorResponse: + properties: + code: + type: integer + error: + type: string + message: + type: string + timestamp: + type: string + type: object + api-service_internal_models_order.MetaResponse: + properties: + current_page: + type: integer + has_next: + type: boolean + has_prev: + type: boolean + limit: + type: integer + offset: + type: integer + total: + type: integer + total_pages: + type: integer + type: object + api-service_internal_models_order.NullableInt32: + properties: + int32: + type: integer + valid: + type: boolean + type: object + api-service_internal_models_order.Order: + properties: + date_created: + $ref: '#/definitions/sql.NullTime' + date_updated: + $ref: '#/definitions/sql.NullTime' + id: + type: string + name: + $ref: '#/definitions/sql.NullString' + sort: + $ref: '#/definitions/api-service_internal_models_order.NullableInt32' + status: + type: string + user_created: + $ref: '#/definitions/sql.NullString' + user_updated: + $ref: '#/definitions/sql.NullString' + type: object + api-service_internal_models_order.OrderCreateRequest: + properties: + name: + maxLength: 255 + minLength: 1 + type: string + status: + enum: + - draft + - active + - inactive + type: string + required: + - status + type: object + api-service_internal_models_order.OrderCreateResponse: + properties: + data: + $ref: '#/definitions/api-service_internal_models_order.Order' + message: + type: string + type: object + api-service_internal_models_order.OrderDeleteResponse: + properties: + id: + type: string + message: + type: string + type: object + api-service_internal_models_order.OrderGetByIDResponse: + properties: + data: + $ref: '#/definitions/api-service_internal_models_order.Order' + message: + type: string + type: object + api-service_internal_models_order.OrderGetResponse: + properties: + data: + items: + $ref: '#/definitions/api-service_internal_models_order.Order' + type: array + message: + type: string + meta: + $ref: '#/definitions/api-service_internal_models_order.MetaResponse' + summary: + $ref: '#/definitions/api-service_internal_models_order.AggregateData' + type: object + api-service_internal_models_order.OrderUpdateRequest: + properties: + name: + maxLength: 255 + minLength: 1 + type: string + status: + enum: + - draft + - active + - inactive + type: string + required: + - status + type: object + api-service_internal_models_order.OrderUpdateResponse: + properties: + data: + $ref: '#/definitions/api-service_internal_models_order.Order' + message: + type: string + type: object api-service_internal_models_retribusi.AggregateData: properties: by_dinas: @@ -322,6 +466,39 @@ info: title: API Service version: 1.0.0 paths: + /api/v1/Order/{id}: + get: + consumes: + - application/json + description: Returns a single order by ID + parameters: + - description: Order ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Success response + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderGetByIDResponse' + "400": + description: Invalid ID format + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "404": + description: order not found + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Get Order by ID + tags: + - Order /api/v1/auth/login: post: consumes: @@ -445,6 +622,177 @@ paths: summary: Register new user tags: - Authentication + /api/v1/order/{id}: + delete: + consumes: + - application/json + description: Soft deletes a order by setting status to 'deleted' + parameters: + - description: Order ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Order deleted successfully + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderDeleteResponse' + "400": + description: Invalid ID format + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "404": + description: Order not found + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Delete order + tags: + - order + put: + consumes: + - application/json + description: Updates an existing order record + parameters: + - description: Order ID (UUID) + in: path + name: id + required: true + type: string + - description: Order update request + in: body + name: request + required: true + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderUpdateRequest' + produces: + - application/json + responses: + "200": + description: Order updated successfully + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderUpdateResponse' + "400": + description: Bad request or validation error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "404": + description: Order not found + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Update order + tags: + - order + /api/v1/orders: + get: + consumes: + - application/json + description: Returns a paginated list of orders with optional summary statistics + parameters: + - default: 10 + description: Limit (max 100) + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer + - default: false + description: Include aggregation summary + in: query + name: include_summary + type: boolean + - description: Filter by status + in: query + name: status + type: string + - description: Search in multiple fields + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: Success response + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderGetResponse' + "400": + description: Bad request + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Get order with pagination and optional aggregation + tags: + - order + post: + consumes: + - application/json + description: Creates a new order record + parameters: + - description: Order creation request + in: body + name: request + required: true + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderCreateRequest' + produces: + - application/json + responses: + "201": + description: Order created successfully + schema: + $ref: '#/definitions/api-service_internal_models_order.OrderCreateResponse' + "400": + description: Bad request or validation error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Create order + tags: + - order + /api/v1/orders/stats: + get: + consumes: + - application/json + description: Returns comprehensive statistics about order data + parameters: + - description: Filter statistics by status + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: Statistics data + schema: + $ref: '#/definitions/api-service_internal_models_order.AggregateData' + "500": + description: Internal server error + schema: + $ref: '#/definitions/api-service_internal_models_order.ErrorResponse' + summary: Get order statistics + tags: + - order /api/v1/retribusi/{id}: delete: consumes: diff --git a/internal/handlers/order/order.go b/internal/handlers/order/order.go new file mode 100644 index 0000000..7679e6c --- /dev/null +++ b/internal/handlers/order/order.go @@ -0,0 +1,683 @@ +package handlers + +import ( + "api-service/internal/config" + "api-service/internal/database" + models "api-service/internal/models/order" + "context" + "database/sql" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +// Initialize the database connection and validator +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + // Register custom validations if needed + validate.RegisterValidation("order_status", validateOrderStatus) + if db == nil { + log.Fatal("Failed to initialize database connection") + } + }) +} + +// Custom validation for order status +func validateOrderStatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +// OrderHandler handles order services +type OrderHandler struct { + db database.Service +} + +// NewOrderHandler creates a new OrderHandler +func NewOrderHandler() *OrderHandler { + return &OrderHandler{ + db: db, + } +} + +// GetOrder godoc +// @Summary Get order with pagination and optional aggregation +// @Description Returns a paginated list of orders with optional summary statistics +// @Tags order +// @Accept json +// @Produce json +// @Param limit query int false "Limit (max 100)" default(10) +// @Param offset query int false "Offset" default(0) +// @Param include_summary query bool false "Include aggregation summary" default(false) +// @Param status query string false "Filter by status" +// @Param search query string false "Search in multiple fields" +// @Success 200 {object} models.OrderGetResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Bad request" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/orders [get] +func (h *OrderHandler) GetOrder(c *gin.Context) { + // Parse pagination parameters + limit, offset, err := h.parsePaginationParams(c) + if err != nil { + h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) + return + } + + // Parse filter parameters + filter := h.parseFilterParams(c) + includeAggregation := c.Query("include_summary") == "true" + + // Get database connection + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute concurrent operations + var ( + items []models.Order + total int + aggregateData *models.AggregateData + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { + mu.Lock() + errChan <- fmt.Errorf("failed to get total count: %w", err) + mu.Unlock() + } + }() + + // Fetch main data - FIXED: Proper method name + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.fetchOrders(ctx, dbConn, filter, limit, offset) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to fetch data: %w", err) + } else { + items = result + } + mu.Unlock() + }() + + // Fetch aggregation data if requested + if includeAggregation { + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.getAggregateData(ctx, dbConn, filter) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to get aggregate data: %w", err) + } else { + aggregateData = result + } + mu.Unlock() + }() + } + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) + return + } + } + + // Build response + meta := h.calculateMeta(limit, offset, total) + response := models.OrderGetResponse{ + Message: "Data order berhasil diambil", + Data: items, + Meta: meta, + } + + if includeAggregation && aggregateData != nil { + response.Summary = aggregateData + } + + c.JSON(http.StatusOK, response) +} + +// GetOrderByID godoc +// @Summary Get Order by ID +// @Description Returns a single order by ID +// @Tags order +// @Accept json +// @Produce json +// @Param id path string true "Order ID (UUID)" +// @Success 200 {object} models.OrderGetByIDResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Order not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/order/{id} [get] +func (h *OrderHandler) GetOrderByID(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.getOrderByID(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Order not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to get order", err, http.StatusInternalServerError) + } + return + } + + response := models.OrderGetByIDResponse{ + Message: "Order details retrieved successfully", + Data: item, + } + + c.JSON(http.StatusOK, response) +} + +// CreateOrder godoc +// @Summary Create order +// @Description Creates a new order record +// @Tags order +// @Accept json +// @Produce json +// @Param request body models.OrderCreateRequest true "Order creation request" +// @Success 201 {object} models.OrderCreateResponse "Order created successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/orders [post] +func (h *OrderHandler) CreateOrder(c *gin.Context) { + var req models.OrderCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.createOrder(ctx, dbConn, &req) + if err != nil { + h.logAndRespondError(c, "Failed to create order", err, http.StatusInternalServerError) + return + } + + response := models.OrderCreateResponse{ + Message: "Order berhasil dibuat", + Data: item, + } + + c.JSON(http.StatusCreated, response) +} + +// UpdateOrder godoc +// @Summary Update order +// @Description Updates an existing order record +// @Tags order +// @Accept json +// @Produce json +// @Param id path string true "Order ID (UUID)" +// @Param request body models.OrderUpdateRequest true "Order update request" +// @Success 200 {object} models.OrderUpdateResponse "Order updated successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} models.ErrorResponse "Order not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/order/{id} [put] +func (h *OrderHandler) UpdateOrder(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + var req models.OrderUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Set ID from path parameter + req.ID = id + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.updateOrder(ctx, dbConn, &req) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Order not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to update order", err, http.StatusInternalServerError) + } + return + } + + response := models.OrderUpdateResponse{ + Message: "Order berhasil diperbarui", + Data: item, + } + + c.JSON(http.StatusOK, response) +} + +// DeleteOrder godoc +// @Summary Delete order +// @Description Soft deletes a order by setting status to 'deleted' +// @Tags order +// @Accept json +// @Produce json +// @Param id path string true "Order ID (UUID)" +// @Success 200 {object} models.OrderDeleteResponse "Order deleted successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Order not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/order/{id} [delete] +func (h *OrderHandler) DeleteOrder(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + err = h.deleteOrder(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Order not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to delete order", err, http.StatusInternalServerError) + } + return + } + + response := models.OrderDeleteResponse{ + Message: "Order berhasil dihapus", + ID: id, + } + + c.JSON(http.StatusOK, response) +} + +// GetOrderStats godoc +// @Summary Get order statistics +// @Description Returns comprehensive statistics about order data +// @Tags order +// @Accept json +// @Produce json +// @Param status query string false "Filter statistics by status" +// @Success 200 {object} models.AggregateData "Statistics data" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/orders/stats [get] +func (h *OrderHandler) GetOrderStats(c *gin.Context) { + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + filter := h.parseFilterParams(c) + aggregateData, err := h.getAggregateData(ctx, dbConn, filter) + if err != nil { + h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Statistik order berhasil diambil", + "data": aggregateData, + }) +} + +// Database operations +func (h *OrderHandler) getOrderByID(ctx context.Context, dbConn *sql.DB, id string) (*models.Order, error) { + query := "SELECT id, status, date_created, date_updated, name FROM data_order WHERE id = $1 AND status != 'deleted'" + row := dbConn.QueryRowContext(ctx, query, id) + + var item models.Order + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, err + } + + return &item, nil +} + +func (h *OrderHandler) createOrder(ctx context.Context, dbConn *sql.DB, req *models.OrderCreateRequest) (*models.Order, error) { + id := uuid.New().String() + now := time.Now() + + query := "INSERT INTO data_order (id, status, date_created, date_updated, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, id, req.Status, now, now, req.Name) + + var item models.Order + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to create order: %w", err) + } + + return &item, nil +} + +func (h *OrderHandler) updateOrder(ctx context.Context, dbConn *sql.DB, req *models.OrderUpdateRequest) (*models.Order, error) { + now := time.Now() + + query := "UPDATE data_order SET status = $2, date_updated = $3, name = $4 WHERE id = $1 AND status != 'deleted' RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, req.ID, req.Status, now, req.Name) + + var item models.Order + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to update order: %w", err) + } + + return &item, nil +} + +func (h *OrderHandler) deleteOrder(ctx context.Context, dbConn *sql.DB, id string) error { + now := time.Now() + query := "UPDATE data_order SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'" + result, err := dbConn.ExecContext(ctx, query, id, now) + if err != nil { + return fmt.Errorf("failed to delete order: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (h *OrderHandler) fetchOrders(ctx context.Context, dbConn *sql.DB, filter models.OrderFilter, limit, offset int) ([]models.Order, error) { + whereClause, args := h.buildWhereClause(filter) + query := fmt.Sprintf("SELECT id, status, date_created, date_updated, name FROM data_order WHERE %s ORDER BY date_created DESC NULLS LAST LIMIT $%d OFFSET $%d", whereClause, len(args)+1, len(args)+2) + + args = append(args, limit, offset) + rows, err := dbConn.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("fetch orders query failed: %w", err) + } + defer rows.Close() + + items := make([]models.Order, 0, limit) + for rows.Next() { + var item models.Order + err := rows.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("scan Order failed: %w", err) + } + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return items, nil +} + +func (h *OrderHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models.OrderFilter, total *int) error { + whereClause, args := h.buildWhereClause(filter) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM data_order WHERE %s", whereClause) + if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { + return fmt.Errorf("total count query failed: %w", err) + } + return nil +} + +func (h *OrderHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models.OrderFilter) (*models.AggregateData, error) { + aggregate := &models.AggregateData{ + ByStatus: make(map[string]int), + } + + whereClause, args := h.buildWhereClause(filter) + statusQuery := fmt.Sprintf("SELECT status, COUNT(*) FROM data_order WHERE %s GROUP BY status ORDER BY status", whereClause) + + rows, err := dbConn.QueryContext(ctx, statusQuery, args...) + if err != nil { + return nil, fmt.Errorf("status query failed: %w", err) + } + defer rows.Close() + + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, fmt.Errorf("status scan failed: %w", err) + } + aggregate.ByStatus[status] = count + + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + + return aggregate, nil +} + +// Helper methods +func (h *OrderHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { + log.Printf("[ERROR] %s: %v", message, err) + h.respondError(c, message, err, statusCode) +} + +func (h *OrderHandler) respondError(c *gin.Context, message string, err error, statusCode int) { + errorMessage := message + if gin.Mode() == gin.ReleaseMode { + errorMessage = "Internal server error" + } + + c.JSON(statusCode, models.ErrorResponse{ + Error: errorMessage, + Code: statusCode, + Message: err.Error(), + Timestamp: time.Now(), + }) +} + +func (h *OrderHandler) parsePaginationParams(c *gin.Context) (int, int, error) { + limit := 10 // Default limit + offset := 0 // Default offset + + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) + } + if parsedLimit <= 0 { + return 0, 0, fmt.Errorf("limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fmt.Errorf("limit cannot exceed 100") + } + limit = parsedLimit + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + parsedOffset, err := strconv.Atoi(offsetStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) + } + if parsedOffset < 0 { + return 0, 0, fmt.Errorf("offset cannot be negative") + } + offset = parsedOffset + } + + return limit, offset, nil +} + +func (h *OrderHandler) parseFilterParams(c *gin.Context) models.OrderFilter { + filter := models.OrderFilter{} + + if status := c.Query("status"); status != "" { + if models.IsValidStatus(status) { + filter.Status = &status + } + } + + if search := c.Query("search"); search != "" { + filter.Search = &search + } + + // Parse date filters + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + filter.DateFrom = &dateFrom + } + } + + if dateToStr := c.Query("date_to"); dateToStr != "" { + if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + filter.DateTo = &dateTo + } + } + + return filter +} + +func (h *OrderHandler) buildWhereClause(filter models.OrderFilter) (string, []interface{}) { + conditions := []string{"status != 'deleted'"} + args := []interface{}{} + paramCount := 1 + + if filter.Status != nil { + conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) + args = append(args, *filter.Status) + paramCount++ + } + + if filter.Search != nil { + searchCondition := fmt.Sprintf("name ILIKE $%d", paramCount) + conditions = append(conditions, searchCondition) + searchTerm := "%" + *filter.Search + "%" + args = append(args, searchTerm) + paramCount++ + } + + if filter.DateFrom != nil { + conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) + args = append(args, *filter.DateFrom) + paramCount++ + } + + if filter.DateTo != nil { + conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) + args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) + paramCount++ + } + + return strings.Join(conditions, " AND "), args +} + +func (h *OrderHandler) calculateMeta(limit, offset, total int) models.MetaResponse { + totalPages := 0 + currentPage := 1 + if limit > 0 { + totalPages = (total + limit - 1) / limit // Ceiling division + currentPage = (offset / limit) + 1 + } + + return models.MetaResponse{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + CurrentPage: currentPage, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} diff --git a/internal/handlers/product/product.go b/internal/handlers/product/product.go new file mode 100644 index 0000000..b521a68 --- /dev/null +++ b/internal/handlers/product/product.go @@ -0,0 +1,683 @@ +package handlers + +import ( + "api-service/internal/config" + "api-service/internal/database" + models "api-service/internal/models/product" + "context" + "database/sql" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +// Initialize the database connection and validator +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + // Register custom validations if needed + validate.RegisterValidation("product_status", validateProductStatus) + if db == nil { + log.Fatal("Failed to initialize database connection") + } + }) +} + +// Custom validation for product status +func validateProductStatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +// ProductHandler handles product services +type ProductHandler struct { + db database.Service +} + +// NewProductHandler creates a new ProductHandler +func NewProductHandler() *ProductHandler { + return &ProductHandler{ + db: db, + } +} + +// GetProduct godoc +// @Summary Get product with pagination and optional aggregation +// @Description Returns a paginated list of products with optional summary statistics +// @Tags product +// @Accept json +// @Produce json +// @Param limit query int false "Limit (max 100)" default(10) +// @Param offset query int false "Offset" default(0) +// @Param include_summary query bool false "Include aggregation summary" default(false) +// @Param status query string false "Filter by status" +// @Param search query string false "Search in multiple fields" +// @Success 200 {object} models.ProductGetResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Bad request" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/products [get] +func (h *ProductHandler) GetProduct(c *gin.Context) { + // Parse pagination parameters + limit, offset, err := h.parsePaginationParams(c) + if err != nil { + h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) + return + } + + // Parse filter parameters + filter := h.parseFilterParams(c) + includeAggregation := c.Query("include_summary") == "true" + + // Get database connection + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute concurrent operations + var ( + items []models.Product + total int + aggregateData *models.AggregateData + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { + mu.Lock() + errChan <- fmt.Errorf("failed to get total count: %w", err) + mu.Unlock() + } + }() + + // Fetch main data - FIXED: Proper method name + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.fetchProducts(ctx, dbConn, filter, limit, offset) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to fetch data: %w", err) + } else { + items = result + } + mu.Unlock() + }() + + // Fetch aggregation data if requested + if includeAggregation { + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.getAggregateData(ctx, dbConn, filter) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to get aggregate data: %w", err) + } else { + aggregateData = result + } + mu.Unlock() + }() + } + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) + return + } + } + + // Build response + meta := h.calculateMeta(limit, offset, total) + response := models.ProductGetResponse{ + Message: "Data product berhasil diambil", + Data: items, + Meta: meta, + } + + if includeAggregation && aggregateData != nil { + response.Summary = aggregateData + } + + c.JSON(http.StatusOK, response) +} + +// GetProductByID godoc +// @Summary Get Product by ID +// @Description Returns a single product by ID +// @Tags product +// @Accept json +// @Produce json +// @Param id path string true "Product ID (UUID)" +// @Success 200 {object} models.ProductGetByIDResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Product not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/product/{id} [get] +func (h *ProductHandler) GetProductByID(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.getProductByID(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Product not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to get product", err, http.StatusInternalServerError) + } + return + } + + response := models.ProductGetByIDResponse{ + Message: "Product details retrieved successfully", + Data: item, + } + + c.JSON(http.StatusOK, response) +} + +// CreateProduct godoc +// @Summary Create product +// @Description Creates a new product record +// @Tags product +// @Accept json +// @Produce json +// @Param request body models.ProductCreateRequest true "Product creation request" +// @Success 201 {object} models.ProductCreateResponse "Product created successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/products [post] +func (h *ProductHandler) CreateProduct(c *gin.Context) { + var req models.ProductCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.createProduct(ctx, dbConn, &req) + if err != nil { + h.logAndRespondError(c, "Failed to create product", err, http.StatusInternalServerError) + return + } + + response := models.ProductCreateResponse{ + Message: "Product berhasil dibuat", + Data: item, + } + + c.JSON(http.StatusCreated, response) +} + +// UpdateProduct godoc +// @Summary Update product +// @Description Updates an existing product record +// @Tags product +// @Accept json +// @Produce json +// @Param id path string true "Product ID (UUID)" +// @Param request body models.ProductUpdateRequest true "Product update request" +// @Success 200 {object} models.ProductUpdateResponse "Product updated successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} models.ErrorResponse "Product not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/product/{id} [put] +func (h *ProductHandler) UpdateProduct(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + var req models.ProductUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Set ID from path parameter + req.ID = id + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.updateProduct(ctx, dbConn, &req) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Product not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to update product", err, http.StatusInternalServerError) + } + return + } + + response := models.ProductUpdateResponse{ + Message: "Product berhasil diperbarui", + Data: item, + } + + c.JSON(http.StatusOK, response) +} + +// DeleteProduct godoc +// @Summary Delete product +// @Description Soft deletes a product by setting status to 'deleted' +// @Tags product +// @Accept json +// @Produce json +// @Param id path string true "Product ID (UUID)" +// @Success 200 {object} models.ProductDeleteResponse "Product deleted successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Product not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/product/{id} [delete] +func (h *ProductHandler) DeleteProduct(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + err = h.deleteProduct(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Product not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to delete product", err, http.StatusInternalServerError) + } + return + } + + response := models.ProductDeleteResponse{ + Message: "Product berhasil dihapus", + ID: id, + } + + c.JSON(http.StatusOK, response) +} + +// GetProductStats godoc +// @Summary Get product statistics +// @Description Returns comprehensive statistics about product data +// @Tags product +// @Accept json +// @Produce json +// @Param status query string false "Filter statistics by status" +// @Success 200 {object} models.AggregateData "Statistics data" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/products/stats [get] +func (h *ProductHandler) GetProductStats(c *gin.Context) { + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + filter := h.parseFilterParams(c) + aggregateData, err := h.getAggregateData(ctx, dbConn, filter) + if err != nil { + h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Statistik product berhasil diambil", + "data": aggregateData, + }) +} + +// Database operations +func (h *ProductHandler) getProductByID(ctx context.Context, dbConn *sql.DB, id string) (*models.Product, error) { + query := "SELECT id, status, date_created, date_updated, name FROM data_product WHERE id = $1 AND status != 'deleted'" + row := dbConn.QueryRowContext(ctx, query, id) + + var item models.Product + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, err + } + + return &item, nil +} + +func (h *ProductHandler) createProduct(ctx context.Context, dbConn *sql.DB, req *models.ProductCreateRequest) (*models.Product, error) { + id := uuid.New().String() + now := time.Now() + + query := "INSERT INTO data_product (id, status, date_created, date_updated, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, id, req.Status, now, now, req.Name) + + var item models.Product + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to create product: %w", err) + } + + return &item, nil +} + +func (h *ProductHandler) updateProduct(ctx context.Context, dbConn *sql.DB, req *models.ProductUpdateRequest) (*models.Product, error) { + now := time.Now() + + query := "UPDATE data_product SET status = $2, date_updated = $3, name = $4 WHERE id = $1 AND status != 'deleted' RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, req.ID, req.Status, now, req.Name) + + var item models.Product + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to update product: %w", err) + } + + return &item, nil +} + +func (h *ProductHandler) deleteProduct(ctx context.Context, dbConn *sql.DB, id string) error { + now := time.Now() + query := "UPDATE data_product SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'" + result, err := dbConn.ExecContext(ctx, query, id, now) + if err != nil { + return fmt.Errorf("failed to delete product: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (h *ProductHandler) fetchProducts(ctx context.Context, dbConn *sql.DB, filter models.ProductFilter, limit, offset int) ([]models.Product, error) { + whereClause, args := h.buildWhereClause(filter) + query := fmt.Sprintf("SELECT id, status, date_created, date_updated, name FROM data_product WHERE %s ORDER BY date_created DESC NULLS LAST LIMIT $%d OFFSET $%d", whereClause, len(args)+1, len(args)+2) + + args = append(args, limit, offset) + rows, err := dbConn.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("fetch products query failed: %w", err) + } + defer rows.Close() + + items := make([]models.Product, 0, limit) + for rows.Next() { + var item models.Product + err := rows.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("scan Product failed: %w", err) + } + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return items, nil +} + +func (h *ProductHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models.ProductFilter, total *int) error { + whereClause, args := h.buildWhereClause(filter) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM data_product WHERE %s", whereClause) + if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { + return fmt.Errorf("total count query failed: %w", err) + } + return nil +} + +func (h *ProductHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models.ProductFilter) (*models.AggregateData, error) { + aggregate := &models.AggregateData{ + ByStatus: make(map[string]int), + } + + whereClause, args := h.buildWhereClause(filter) + statusQuery := fmt.Sprintf("SELECT status, COUNT(*) FROM data_product WHERE %s GROUP BY status ORDER BY status", whereClause) + + rows, err := dbConn.QueryContext(ctx, statusQuery, args...) + if err != nil { + return nil, fmt.Errorf("status query failed: %w", err) + } + defer rows.Close() + + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, fmt.Errorf("status scan failed: %w", err) + } + aggregate.ByStatus[status] = count + + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + + return aggregate, nil +} + +// Helper methods +func (h *ProductHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { + log.Printf("[ERROR] %s: %v", message, err) + h.respondError(c, message, err, statusCode) +} + +func (h *ProductHandler) respondError(c *gin.Context, message string, err error, statusCode int) { + errorMessage := message + if gin.Mode() == gin.ReleaseMode { + errorMessage = "Internal server error" + } + + c.JSON(statusCode, models.ErrorResponse{ + Error: errorMessage, + Code: statusCode, + Message: err.Error(), + Timestamp: time.Now(), + }) +} + +func (h *ProductHandler) parsePaginationParams(c *gin.Context) (int, int, error) { + limit := 10 // Default limit + offset := 0 // Default offset + + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) + } + if parsedLimit <= 0 { + return 0, 0, fmt.Errorf("limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fmt.Errorf("limit cannot exceed 100") + } + limit = parsedLimit + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + parsedOffset, err := strconv.Atoi(offsetStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) + } + if parsedOffset < 0 { + return 0, 0, fmt.Errorf("offset cannot be negative") + } + offset = parsedOffset + } + + return limit, offset, nil +} + +func (h *ProductHandler) parseFilterParams(c *gin.Context) models.ProductFilter { + filter := models.ProductFilter{} + + if status := c.Query("status"); status != "" { + if models.IsValidStatus(status) { + filter.Status = &status + } + } + + if search := c.Query("search"); search != "" { + filter.Search = &search + } + + // Parse date filters + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + filter.DateFrom = &dateFrom + } + } + + if dateToStr := c.Query("date_to"); dateToStr != "" { + if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + filter.DateTo = &dateTo + } + } + + return filter +} + +func (h *ProductHandler) buildWhereClause(filter models.ProductFilter) (string, []interface{}) { + conditions := []string{"status != 'deleted'"} + args := []interface{}{} + paramCount := 1 + + if filter.Status != nil { + conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) + args = append(args, *filter.Status) + paramCount++ + } + + if filter.Search != nil { + searchCondition := fmt.Sprintf("name ILIKE $%d", paramCount) + conditions = append(conditions, searchCondition) + searchTerm := "%" + *filter.Search + "%" + args = append(args, searchTerm) + paramCount++ + } + + if filter.DateFrom != nil { + conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) + args = append(args, *filter.DateFrom) + paramCount++ + } + + if filter.DateTo != nil { + conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) + args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) + paramCount++ + } + + return strings.Join(conditions, " AND "), args +} + +func (h *ProductHandler) calculateMeta(limit, offset, total int) models.MetaResponse { + totalPages := 0 + currentPage := 1 + if limit > 0 { + totalPages = (total + limit - 1) / limit // Ceiling division + currentPage = (offset / limit) + 1 + } + + return models.MetaResponse{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + CurrentPage: currentPage, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} diff --git a/internal/models/order/order.go b/internal/models/order/order.go new file mode 100644 index 0000000..7bc1866 --- /dev/null +++ b/internal/models/order/order.go @@ -0,0 +1,195 @@ +package models + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "time" +) + +// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility +type NullableInt32 struct { + Int32 int32 `json:"int32,omitempty"` + Valid bool `json:"valid"` +} + +// Scan implements the sql.Scanner interface for NullableInt32 +func (n *NullableInt32) Scan(value interface{}) error { + var ni sql.NullInt32 + if err := ni.Scan(value); err != nil { + return err + } + n.Int32 = ni.Int32 + n.Valid = ni.Valid + return nil +} + +// Value implements the driver.Valuer interface for NullableInt32 +func (n NullableInt32) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + return n.Int32, nil +} + +// Order represents the data structure for the order table +type Order struct { + ID string `json:"id" db:"id"` + Status string `json:"status" db:"status"` + Sort NullableInt32 `json:"sort,omitempty" db:"sort"` + UserCreated sql.NullString `json:"user_created,omitempty" db:"user_created"` + DateCreated sql.NullTime `json:"date_created,omitempty" db:"date_created"` + UserUpdated sql.NullString `json:"user_updated,omitempty" db:"user_updated"` + DateUpdated sql.NullTime `json:"date_updated,omitempty" db:"date_updated"` + Name sql.NullString `json:"name,omitempty" db:"name"` +} + +// Custom JSON marshaling for Order +func (r Order) MarshalJSON() ([]byte, error) { + type Alias Order + aux := &struct { + Sort *int `json:"sort,omitempty"` + UserCreated *string `json:"user_created,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + UserUpdated *string `json:"user_updated,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Name *string `json:"name,omitempty"` + *Alias + }{ + Alias: (*Alias)(&r), + } + + if r.Sort.Valid { + sort := int(r.Sort.Int32) + aux.Sort = &sort + } + if r.UserCreated.Valid { + aux.UserCreated = &r.UserCreated.String + } + if r.DateCreated.Valid { + aux.DateCreated = &r.DateCreated.Time + } + if r.UserUpdated.Valid { + aux.UserUpdated = &r.UserUpdated.String + } + if r.DateUpdated.Valid { + aux.DateUpdated = &r.DateUpdated.Time + } + if r.Name.Valid { + aux.Name = &r.Name.String + } + + return json.Marshal(aux) +} + +// Helper methods +func (r *Order) GetName() string { + if r.Name.Valid { + return r.Name.String + } + return "" +} + +// Response struct for GET by ID +type OrderGetByIDResponse struct { + Message string `json:"message"` + Data *Order `json:"data"` +} + +// Enhanced GET response with pagination and aggregation +type OrderGetResponse struct { + Message string `json:"message"` + Data []Order `json:"data"` + Meta MetaResponse `json:"meta"` + Summary *AggregateData `json:"summary,omitempty"` +} + +// Request struct for create +type OrderCreateRequest struct { + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` +} + +// Response struct for create +type OrderCreateResponse struct { + Message string `json:"message"` + Data *Order `json:"data"` +} + +// Update request +type OrderUpdateRequest struct { + ID string `json:"-" validate:"required,uuid4"` // ID dari URL path + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` +} + +// Response struct for update +type OrderUpdateResponse struct { + Message string `json:"message"` + Data *Order `json:"data"` +} + +// Response struct for delete +type OrderDeleteResponse struct { + Message string `json:"message"` + ID string `json:"id"` +} + +// Metadata for pagination +type MetaResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// Aggregate data for summary +type AggregateData struct { + TotalActive int `json:"total_active"` + TotalDraft int `json:"total_draft"` + TotalInactive int `json:"total_inactive"` + ByStatus map[string]int `json:"by_status"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + CreatedToday int `json:"created_today"` + UpdatedToday int `json:"updated_today"` +} + +// Error response +type ErrorResponse struct { + Error string `json:"error"` + Code int `json:"code"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// Filter struct for query parameters +type OrderFilter struct { + Status *string `json:"status,omitempty" form:"status"` + Search *string `json:"search,omitempty" form:"search"` + DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"` + DateTo *time.Time `json:"date_to,omitempty" form:"date_to"` +} + +// Validation constants +const ( + StatusDraft = "draft" + StatusActive = "active" + StatusInactive = "inactive" + StatusDeleted = "deleted" +) + +// ValidStatuses for validation +var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} + +// IsValidStatus helper function +func IsValidStatus(status string) bool { + for _, validStatus := range ValidStatuses { + if status == validStatus { + return true + } + } + return false +} diff --git a/internal/models/product/product.go b/internal/models/product/product.go new file mode 100644 index 0000000..923b613 --- /dev/null +++ b/internal/models/product/product.go @@ -0,0 +1,195 @@ +package models + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "time" +) + +// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility +type NullableInt32 struct { + Int32 int32 `json:"int32,omitempty"` + Valid bool `json:"valid"` +} + +// Scan implements the sql.Scanner interface for NullableInt32 +func (n *NullableInt32) Scan(value interface{}) error { + var ni sql.NullInt32 + if err := ni.Scan(value); err != nil { + return err + } + n.Int32 = ni.Int32 + n.Valid = ni.Valid + return nil +} + +// Value implements the driver.Valuer interface for NullableInt32 +func (n NullableInt32) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + return n.Int32, nil +} + +// Product represents the data structure for the product table +type Product struct { + ID string `json:"id" db:"id"` + Status string `json:"status" db:"status"` + Sort NullableInt32 `json:"sort,omitempty" db:"sort"` + UserCreated sql.NullString `json:"user_created,omitempty" db:"user_created"` + DateCreated sql.NullTime `json:"date_created,omitempty" db:"date_created"` + UserUpdated sql.NullString `json:"user_updated,omitempty" db:"user_updated"` + DateUpdated sql.NullTime `json:"date_updated,omitempty" db:"date_updated"` + Name sql.NullString `json:"name,omitempty" db:"name"` +} + +// Custom JSON marshaling for Product +func (r Product) MarshalJSON() ([]byte, error) { + type Alias Product + aux := &struct { + Sort *int `json:"sort,omitempty"` + UserCreated *string `json:"user_created,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + UserUpdated *string `json:"user_updated,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Name *string `json:"name,omitempty"` + *Alias + }{ + Alias: (*Alias)(&r), + } + + if r.Sort.Valid { + sort := int(r.Sort.Int32) + aux.Sort = &sort + } + if r.UserCreated.Valid { + aux.UserCreated = &r.UserCreated.String + } + if r.DateCreated.Valid { + aux.DateCreated = &r.DateCreated.Time + } + if r.UserUpdated.Valid { + aux.UserUpdated = &r.UserUpdated.String + } + if r.DateUpdated.Valid { + aux.DateUpdated = &r.DateUpdated.Time + } + if r.Name.Valid { + aux.Name = &r.Name.String + } + + return json.Marshal(aux) +} + +// Helper methods +func (r *Product) GetName() string { + if r.Name.Valid { + return r.Name.String + } + return "" +} + +// Response struct for GET by ID +type ProductGetByIDResponse struct { + Message string `json:"message"` + Data *Product `json:"data"` +} + +// Enhanced GET response with pagination and aggregation +type ProductGetResponse struct { + Message string `json:"message"` + Data []Product `json:"data"` + Meta MetaResponse `json:"meta"` + Summary *AggregateData `json:"summary,omitempty"` +} + +// Request struct for create +type ProductCreateRequest struct { + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` +} + +// Response struct for create +type ProductCreateResponse struct { + Message string `json:"message"` + Data *Product `json:"data"` +} + +// Update request +type ProductUpdateRequest struct { + ID string `json:"-" validate:"required,uuid4"` // ID dari URL path + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` +} + +// Response struct for update +type ProductUpdateResponse struct { + Message string `json:"message"` + Data *Product `json:"data"` +} + +// Response struct for delete +type ProductDeleteResponse struct { + Message string `json:"message"` + ID string `json:"id"` +} + +// Metadata for pagination +type MetaResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// Aggregate data for summary +type AggregateData struct { + TotalActive int `json:"total_active"` + TotalDraft int `json:"total_draft"` + TotalInactive int `json:"total_inactive"` + ByStatus map[string]int `json:"by_status"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + CreatedToday int `json:"created_today"` + UpdatedToday int `json:"updated_today"` +} + +// Error response +type ErrorResponse struct { + Error string `json:"error"` + Code int `json:"code"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// Filter struct for query parameters +type ProductFilter struct { + Status *string `json:"status,omitempty" form:"status"` + Search *string `json:"search,omitempty" form:"search"` + DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"` + DateTo *time.Time `json:"date_to,omitempty" form:"date_to"` +} + +// Validation constants +const ( + StatusDraft = "draft" + StatusActive = "active" + StatusInactive = "inactive" + StatusDeleted = "deleted" +) + +// ValidStatuses for validation +var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} + +// IsValidStatus helper function +func IsValidStatus(status string) bool { + for _, validStatus := range ValidStatuses { + if status == validStatus { + return true + } + } + return false +} diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 4420192..9dac361 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -1,6 +1,7 @@ package v1 import ( + orderHandlers "api-service/internal/handlers/order" retribusiHandlers "api-service/internal/handlers/retribusi" "net/http" @@ -59,6 +60,15 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { v1.DELETE("/retribusi/:id", retribusiHandler.DeleteRetribusi) // Protected routes (require authentication) + + // Order endpoints + orderHandler := orderHandlers.NewOrderHandler() + v1.GET("/orders", orderHandler.GetOrder) + v1.GET("/order/:id", orderHandler.GetOrderByID) + v1.POST("/orders", orderHandler.CreateOrder) + v1.PUT("/order/:id", orderHandler.UpdateOrder) + v1.DELETE("/order/:id", orderHandler.DeleteOrder) + protected := v1.Group("/") protected.Use(middleware.JWTAuthMiddleware(authService)) { diff --git a/internal/services/product/product_service.go b/internal/services/product/product_service.go deleted file mode 100644 index b168953..0000000 --- a/internal/services/product/product_service.go +++ /dev/null @@ -1,141 +0,0 @@ -package product - -import ( - "context" - "errors" - "time" - - model "api-service/internal/models/product" - "api-service/internal/repository/product" -) - -// Service defines the interface for product business logic -type Service interface { - CreateProduct(ctx context.Context, req *model.ProductCreateRequest) (*model.ProductResponse, error) - GetProduct(ctx context.Context, id string) (*model.ProductResponse, error) - GetAllProducts(ctx context.Context) (*model.ProductsResponse, error) - UpdateProduct(ctx context.Context, id string, req *model.ProductUpdateRequest) (*model.ProductResponse, error) - DeleteProduct(ctx context.Context, id string) error -} - -// service implements the Service interface -type service struct { - repo product.Repository -} - -// NewService creates a new product service -func NewService(repo product.Repository) Service { - return &service{repo: repo} -} - -// CreateProduct creates a new product -func (s *service) CreateProduct(ctx context.Context, req *model.ProductCreateRequest) (*model.ProductResponse, error) { - if req.Name == "" { - return nil, errors.New("product name is required") - } - - if req.Price <= 0 { - return nil, errors.New("product price must be greater than 0") - } - - product := &model.Product{ - ID: generateID(), - Name: req.Name, - Description: req.Description, - Price: req.Price, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - err := s.repo.Create(ctx, product) - if err != nil { - return nil, err - } - - return &model.ProductResponse{ - ID: product.ID, - Name: product.Name, - Description: product.Description, - Price: product.Price, - CreatedAt: product.CreatedAt, - UpdatedAt: product.UpdatedAt, - }, nil -} - -// GetProduct retrieves a product by ID -func (s *service) GetProduct(ctx context.Context, id string) (*model.ProductResponse, error) { - if id == "" { - return nil, errors.New("product ID is required") - } - - product, err := s.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - return &model.ProductResponse{ - ID: product.ID, - Name: product.Name, - Description: product.Description, - Price: product.Price, - CreatedAt: product.CreatedAt, - UpdatedAt: product.UpdatedAt, - }, nil -} - -// GetAllProducts retrieves all products -func (s *service) GetAllProducts(ctx context.Context) (*model.ProductsResponse, error) { - products, err := s.repo.GetAll(ctx) - if err != nil { - return nil, err - } - - return &model.ProductsResponse{ - Data: products, - }, nil -} - -// UpdateProduct updates an existing product -func (s *service) UpdateProduct(ctx context.Context, id string, req *model.ProductUpdateRequest) (*model.ProductResponse, error) { - if id == "" { - return nil, errors.New("product ID is required") - } - - existingProduct, err := s.repo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - existingProduct.Name = req.Name - existingProduct.Description = req.Description - existingProduct.Price = req.Price - existingProduct.UpdatedAt = time.Now() - - err = s.repo.Update(ctx, existingProduct) - if err != nil { - return nil, err - } - - return &model.ProductResponse{ - ID: existingProduct.ID, - Name: existingProduct.Name, - Description: existingProduct.Description, - Price: existingProduct.Price, - CreatedAt: existingProduct.CreatedAt, - UpdatedAt: existingProduct.UpdatedAt, - }, nil -} - -// DeleteProduct deletes a product -func (s *service) DeleteProduct(ctx context.Context, id string) error { - if id == "" { - return errors.New("product ID is required") - } - - return s.repo.Delete(ctx, id) -} - -// Helper functions -func generateID() string { - return "prod_" + time.Now().Format("20060102150405") -} diff --git a/tools/HANDLER.md b/tools/HANDLER.md index 61866ea..584b35a 100644 --- a/tools/HANDLER.md +++ b/tools/HANDLER.md @@ -32,6 +32,7 @@ go run tools/generate-handler.go [methods] # Contoh: go run tools/generate-handler.go user get post put delete go run tools/generate-handler.go product get post +go run tools/ generate-handler.go order get post put delete stats ``` ## Method yang Tersedia @@ -69,7 +70,7 @@ go run tools/generate-handler.go product get post 1. Jalankan swagger generator: ```bash -swag init -g cmd/api/main.go +swag init -g cmd/api/main.go --parseDependency --parseInternal ``` 2. Jalankan aplikasi: diff --git a/tools/diagnostic.go b/tools/diagnostic.go index 5c0caa8..cb56404 100644 --- a/tools/diagnostic.go +++ b/tools/diagnostic.go @@ -10,7 +10,7 @@ import ( "github.com/joho/godotenv" ) -func main() { +func maindiagnostic() { fmt.Println("=== Database Connection Diagnostic Tool ===") // Load environment variables from .env file diff --git a/tools/generate-handler.go b/tools/generate-handler.go index a183695..242373f 100644 --- a/tools/generate-handler.go +++ b/tools/generate-handler.go @@ -10,44 +10,53 @@ import ( // HandlerData contains template data for handler generation type HandlerData struct { - Name string - NameLower string - NamePlural string - ModuleName string - HasGet bool - HasPost bool - HasPut bool - HasDelete bool - HasParam bool - HasRequest bool - HasResponse bool - Timestamp string + Name string + NameLower string + NamePlural string + ModuleName string + TableName string + HasGet bool + HasPost bool + HasPut bool + HasDelete bool + HasStats bool + HasFilter bool + HasPagination bool + Timestamp string } func main() { if len(os.Args) < 2 { fmt.Println("Usage: go run generate-handler.go entity [methods]") + fmt.Println("Example: go run generate-handler.go order get post put delete stats") os.Exit(1) } - handlerName := strings.Title(os.Args[1]) // PascalCase entity name + entityName := strings.Title(os.Args[1]) // PascalCase entity name methods := []string{} if len(os.Args) > 2 { methods = os.Args[2:] + } else { + // Default methods if none specified + methods = []string{"get", "post", "put", "delete"} } - handlerLower := strings.ToLower(handlerName) - handlerPlural := handlerLower + "s" + entityLower := strings.ToLower(entityName) + entityPlural := entityLower + "s" + tableName := "data_" + entityLower data := HandlerData{ - Name: handlerName, - NameLower: handlerLower, - NamePlural: handlerPlural, - ModuleName: "api-service", - Timestamp: time.Now().Format("2006-01-02 15:04:05"), - HasResponse: true, + Name: entityName, + NameLower: entityLower, + NamePlural: entityPlural, + ModuleName: "api-service", + TableName: tableName, + HasPagination: true, + HasFilter: true, + Timestamp: time.Now().Format("2006-01-02 15:04:05"), } + // Set methods based on arguments for _, m := range methods { switch strings.ToLower(m) { case "get": @@ -58,271 +67,1032 @@ func main() { data.HasPut = true case "delete": data.HasDelete = true + case "stats": + data.HasStats = true } } - data.HasRequest = data.HasPost || data.HasPut - data.HasParam = data.HasGet || data.HasPut || data.HasDelete - handlerDir := filepath.Join("internal", "handlers", handlerLower) - modelDir := filepath.Join("internal", "models", handlerLower) + // Always add stats if we have get + if data.HasGet { + data.HasStats = true + } + + // Create directories + handlerDir := filepath.Join("internal", "handlers", entityLower) + modelDir := filepath.Join("internal", "models", entityLower) + for _, d := range []string{handlerDir, modelDir} { if err := os.MkdirAll(d, 0755); err != nil { panic(err) } } + // Generate files generateHandlerFile(data, handlerDir) generateModelFile(data, modelDir) updateRoutesFile(data) - fmt.Printf("✅ Successfully generated handler: %s\n", handlerName) + fmt.Printf("✅ Successfully generated handler: %s\n", entityName) + fmt.Printf("📁 Handler: %s\n", filepath.Join(handlerDir, entityLower+".go")) + fmt.Printf("📁 Model: %s\n", filepath.Join(modelDir, entityLower+".go")) } -// ================= HANDLER ===================== +// ================= HANDLER GENERATION ===================== + func generateHandlerFile(data HandlerData, handlerDir string) { - handlerContent := fmt.Sprintf(`package handlers + // FIXED: Proper string formatting + handlerContent := `package handlers import ( - models "%s/internal/models/%s" + "` + data.ModuleName + `/internal/config" + "` + data.ModuleName + `/internal/database" + models "` + data.ModuleName + `/internal/models/` + data.NameLower + `" + "context" + "database/sql" + "fmt" + "log" "net/http" + "strconv" + "strings" + "sync" + "time" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" "github.com/google/uuid" ) -// %sHandler handles %s services -type %sHandler struct{} +var ( + db database.Service + once sync.Once + validate *validator.Validate +) -// New%sHandler creates a new %sHandler -func New%sHandler() *%sHandler { - return &%sHandler{} -} -`, data.ModuleName, data.NameLower, - data.Name, data.NameLower, - data.Name, - data.Name, data.Name, - data.Name, data.Name, data.Name) - - var methodsContent string - - // GET - if data.HasGet { - methodsContent += fmt.Sprintf(` -// Get%s godoc -// @Summary Get %s -// @Description Returns a list of %s -// @Tags %s -// @Accept json -// @Produce json -// @Success 200 {object} models.%sGetResponse "%s GET response" -// @Router /api/v1/%s [get] -func (h *%sHandler) Get%s(c *gin.Context) { - response := models.%sGetResponse{ - Message: "List of %s", - Data: []string{"%s 1", "%s 2"}, - } - c.JSON(http.StatusOK, response) +// Initialize the database connection and validator +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + // Register custom validations if needed + validate.RegisterValidation("` + data.NameLower + `_status", validate` + data.Name + `Status) + if db == nil { + log.Fatal("Failed to initialize database connection") + } + }) } -// Get%sByID godoc -// @Summary Get %s by ID -// @Description Returns a single %s by ID -// @Tags %s -// @Accept json -// @Produce json -// @Param id path string true "%s ID" -// @Success 200 {object} models.%sGetByIDResponse "%s GET by ID response" -// @Failure 404 {object} models.ErrorResponse "%s not found" -// @Router /api/v1/%s/{id} [get] -func (h *%sHandler) Get%sByID(c *gin.Context) { - id := c.Param("id") - response := models.%sGetByIDResponse{ - ID: id, - Message: "%s details", - } - c.JSON(http.StatusOK, response) -} -`, data.Name, data.NameLower, data.NamePlural, data.NameLower, - data.Name, data.Name, data.NamePlural, - data.Name, data.Name, - data.Name, data.NamePlural, data.Name, data.Name, - data.Name, - data.Name, data.NameLower, data.NameLower, data.NameLower, - data.Name, data.Name, data.Name, data.NameLower, - data.Name, data.Name, data.Name) - } - - // POST - if data.HasPost { - methodsContent += fmt.Sprintf(` -// Create%s godoc -// @Summary Create %s -// @Description Creates a new %s -// @Tags %s -// @Accept json -// @Produce json -// @Param request body models.%sCreateRequest true "%s creation request" -// @Success 201 {object} models.%sCreateResponse "%s created successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Router /api/v1/%s [post] -func (h *%sHandler) Create%s(c *gin.Context) { - var req models.%sCreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - response := models.%sCreateResponse{ - ID: uuid.NewString(), - Message: "%s created successfully", - Data: req, - } - c.JSON(http.StatusCreated, response) -} -`, data.Name, data.NameLower, data.NameLower, data.NameLower, - data.Name, data.Name, data.Name, data.Name, data.NamePlural, - data.Name, data.Name, data.Name, - data.Name, data.Name) - } - - // PUT - if data.HasPut { - methodsContent += fmt.Sprintf(` -// Update%s godoc -// @Summary Update %s -// @Description Updates an existing %s -// @Tags %s -// @Accept json -// @Produce json -// @Param id path string true "%s ID" -// @Param request body models.%sUpdateRequest true "%s update request" -// @Success 200 {object} models.%sUpdateResponse "%s updated successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Failure 404 {object} models.ErrorResponse "%s not found" -// @Router /api/v1/%s/{id} [put] -func (h *%sHandler) Update%s(c *gin.Context) { - id := c.Param("id") - var req models.%sUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - response := models.%sUpdateResponse{ - ID: id, - Message: "%s updated successfully", - Data: req, - } - c.JSON(http.StatusOK, response) -} -`, data.Name, data.NameLower, data.NameLower, data.NameLower, - data.Name, data.Name, data.Name, data.Name, data.Name, - data.Name, data.Name, data.NameLower, - data.Name, data.Name, data.Name, - data.Name) - } - - // DELETE - if data.HasDelete { - methodsContent += fmt.Sprintf(` -// Delete%s godoc -// @Summary Delete %s -// @Description Deletes a %s by ID -// @Tags %s -// @Accept json -// @Produce json -// @Param id path string true "%s ID" -// @Success 200 {object} models.%sDeleteResponse "%s deleted successfully" -// @Failure 404 {object} models.ErrorResponse "%s not found" -// @Router /api/v1/%s/{id} [delete] -func (h *%sHandler) Delete%s(c *gin.Context) { - id := c.Param("id") - response := models.%sDeleteResponse{ - ID: id, - Message: "%s deleted successfully", - } - c.JSON(http.StatusOK, response) -} -`, data.Name, data.NameLower, data.NameLower, data.NameLower, - data.Name, data.Name, data.Name, data.Name, data.NameLower, - data.Name, data.Name, data.Name) - } - - writeFile(filepath.Join(handlerDir, data.NameLower+".go"), handlerContent+methodsContent) +// Custom validation for ` + data.NameLower + ` status +func validate` + data.Name + `Status(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) } -// ================= MODELS ===================== -func generateModelFile(data HandlerData, modelDir string) { - modelContent := "package models\n\n" - - if data.HasGet { - modelContent += fmt.Sprintf(`// %sGetResponse response for GET %s -type %sGetResponse struct { - Message string `+"`json:\"message\"`"+` - Data interface{} `+"`json:\"data\"`"+` +// ` + data.Name + `Handler handles ` + data.NameLower + ` services +type ` + data.Name + `Handler struct { + db database.Service } -// %sGetByIDResponse response for GET %s by ID -type %sGetByIDResponse struct { - ID string `+"`json:\"id\"`"+` - Message string `+"`json:\"message\"`"+` -} -`, data.Name, data.NamePlural, data.Name, - data.Name, data.NameLower, data.Name) +// New` + data.Name + `Handler creates a new ` + data.Name + `Handler +func New` + data.Name + `Handler() *` + data.Name + `Handler { + return &` + data.Name + `Handler{ + db: db, } - - if data.HasPost { - modelContent += fmt.Sprintf(`// %sCreateRequest request for creating %s -type %sCreateRequest struct { - Name string `+"`json:\"name\" binding:\"required\"`"+` -} - -// %sCreateResponse response for creating %s -type %sCreateResponse struct { - ID string `+"`json:\"id\"`"+` - Message string `+"`json:\"message\"`"+` - Data interface{} `+"`json:\"data\"`"+` -} -`, data.Name, data.NameLower, data.Name, - data.Name, data.NameLower, data.Name) - } - - if data.HasPut { - modelContent += fmt.Sprintf(`// %sUpdateRequest request for updating %s -type %sUpdateRequest struct { - Name string `+"`json:\"name\" binding:\"required\"`"+` -} - -// %sUpdateResponse response for updating %s -type %sUpdateResponse struct { - ID string `+"`json:\"id\"`"+` - Message string `+"`json:\"message\"`"+` - Data interface{} `+"`json:\"data\"`"+` -} -`, data.Name, data.NameLower, data.Name, - data.Name, data.NameLower, data.Name) - } - - if data.HasDelete { - modelContent += fmt.Sprintf(`// %sDeleteResponse response for deleting %s -type %sDeleteResponse struct { - ID string `+"`json:\"id\"`"+` - Message string `+"`json:\"message\"`"+` -} -`, data.Name, data.NameLower, data.Name) - } - - modelContent += `// ErrorResponse common error response -type ErrorResponse struct { - Error string ` + "`json:\"error\"`" + ` } ` + + // Add methods + if data.HasGet { + handlerContent += generateGetMethods(data) + } + if data.HasPost { + handlerContent += generateCreateMethod(data) + } + if data.HasPut { + handlerContent += generateUpdateMethod(data) + } + if data.HasDelete { + handlerContent += generateDeleteMethod(data) + } + if data.HasStats { + handlerContent += generateStatsMethod(data) + } + + // Add helper methods + handlerContent += generateHelperMethods(data) + + writeFile(filepath.Join(handlerDir, data.NameLower+".go"), handlerContent) +} + +func generateGetMethods(data HandlerData) string { + // FIXED: Proper formatting without printf placeholders + return ` +// Get` + data.Name + ` godoc +// @Summary Get ` + data.NameLower + ` with pagination and optional aggregation +// @Description Returns a paginated list of ` + data.NamePlural + ` with optional summary statistics +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param limit query int false "Limit (max 100)" default(10) +// @Param offset query int false "Offset" default(0) +// @Param include_summary query bool false "Include aggregation summary" default(false) +// @Param status query string false "Filter by status" +// @Param search query string false "Search in multiple fields" +// @Success 200 {object} models.` + data.Name + `GetResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Bad request" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + ` [get] +func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { + // Parse pagination parameters + limit, offset, err := h.parsePaginationParams(c) + if err != nil { + h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) + return + } + + // Parse filter parameters + filter := h.parseFilterParams(c) + includeAggregation := c.Query("include_summary") == "true" + + // Get database connection + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute concurrent operations + var ( + items []models.` + data.Name + ` + total int + aggregateData *models.AggregateData + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { + mu.Lock() + errChan <- fmt.Errorf("failed to get total count: %w", err) + mu.Unlock() + } + }() + + // Fetch main data - FIXED: Proper method name + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.fetch` + data.Name + `s(ctx, dbConn, filter, limit, offset) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to fetch data: %w", err) + } else { + items = result + } + mu.Unlock() + }() + + // Fetch aggregation data if requested + if includeAggregation { + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.getAggregateData(ctx, dbConn, filter) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to get aggregate data: %w", err) + } else { + aggregateData = result + } + mu.Unlock() + }() + } + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) + return + } + } + + // Build response + meta := h.calculateMeta(limit, offset, total) + response := models.` + data.Name + `GetResponse{ + Message: "Data ` + data.NameLower + ` berhasil diambil", + Data: items, + Meta: meta, + } + + if includeAggregation && aggregateData != nil { + response.Summary = aggregateData + } + + c.JSON(http.StatusOK, response) +} + +// Get` + data.Name + `ByID godoc +// @Summary Get ` + data.Name + ` by ID +// @Description Returns a single ` + data.NameLower + ` by ID +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param id path string true "` + data.Name + ` ID (UUID)" +// @Success 200 {object} models.` + data.Name + `GetByIDResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [get] +func (h *` + data.Name + `Handler) Get` + data.Name + `ByID(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.get` + data.Name + `ByID(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "` + data.Name + ` not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to get ` + data.NameLower + `", err, http.StatusInternalServerError) + } + return + } + + response := models.` + data.Name + `GetByIDResponse{ + Message: "` + data.Name + ` details retrieved successfully", + Data: item, + } + + c.JSON(http.StatusOK, response) +} +` +} + +func generateCreateMethod(data HandlerData) string { + return ` +// Create` + data.Name + ` godoc +// @Summary Create ` + data.NameLower + ` +// @Description Creates a new ` + data.NameLower + ` record +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param request body models.` + data.Name + `CreateRequest true "` + data.Name + ` creation request" +// @Success 201 {object} models.` + data.Name + `CreateResponse "` + data.Name + ` created successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + ` [post] +func (h *` + data.Name + `Handler) Create` + data.Name + `(c *gin.Context) { + var req models.` + data.Name + `CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.create` + data.Name + `(ctx, dbConn, &req) + if err != nil { + h.logAndRespondError(c, "Failed to create ` + data.NameLower + `", err, http.StatusInternalServerError) + return + } + + response := models.` + data.Name + `CreateResponse{ + Message: "` + data.Name + ` berhasil dibuat", + Data: item, + } + + c.JSON(http.StatusCreated, response) +} +` +} + +func generateUpdateMethod(data HandlerData) string { + return ` +// Update` + data.Name + ` godoc +// @Summary Update ` + data.NameLower + ` +// @Description Updates an existing ` + data.NameLower + ` record +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param id path string true "` + data.Name + ` ID (UUID)" +// @Param request body models.` + data.Name + `UpdateRequest true "` + data.Name + ` update request" +// @Success 200 {object} models.` + data.Name + `UpdateResponse "` + data.Name + ` updated successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [put] +func (h *` + data.Name + `Handler) Update` + data.Name + `(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + var req models.` + data.Name + `UpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Set ID from path parameter + req.ID = id + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + item, err := h.update` + data.Name + `(ctx, dbConn, &req) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "` + data.Name + ` not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to update ` + data.NameLower + `", err, http.StatusInternalServerError) + } + return + } + + response := models.` + data.Name + `UpdateResponse{ + Message: "` + data.Name + ` berhasil diperbarui", + Data: item, + } + + c.JSON(http.StatusOK, response) +} +` +} + +func generateDeleteMethod(data HandlerData) string { + return ` +// Delete` + data.Name + ` godoc +// @Summary Delete ` + data.NameLower + ` +// @Description Soft deletes a ` + data.NameLower + ` by setting status to 'deleted' +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param id path string true "` + data.Name + ` ID (UUID)" +// @Success 200 {object} models.` + data.Name + `DeleteResponse "` + data.Name + ` deleted successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [delete] +func (h *` + data.Name + `Handler) Delete` + data.Name + `(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + err = h.delete` + data.Name + `(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "` + data.Name + ` not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to delete ` + data.NameLower + `", err, http.StatusInternalServerError) + } + return + } + + response := models.` + data.Name + `DeleteResponse{ + Message: "` + data.Name + ` berhasil dihapus", + ID: id, + } + + c.JSON(http.StatusOK, response) +} +` +} + +func generateStatsMethod(data HandlerData) string { + return ` +// Get` + data.Name + `Stats godoc +// @Summary Get ` + data.NameLower + ` statistics +// @Description Returns comprehensive statistics about ` + data.NameLower + ` data +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param status query string false "Filter statistics by status" +// @Success 200 {object} models.AggregateData "Statistics data" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + `/stats [get] +func (h *` + data.Name + `Handler) Get` + data.Name + `Stats(c *gin.Context) { + dbConn, err := h.db.GetDB("satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + filter := h.parseFilterParams(c) + aggregateData, err := h.getAggregateData(ctx, dbConn, filter) + if err != nil { + h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Statistik ` + data.NameLower + ` berhasil diambil", + "data": aggregateData, + }) +} +` +} + +func generateHelperMethods(data HandlerData) string { + // FIXED: All method names and types properly formatted + return ` +// Database operations +func (h *` + data.Name + `Handler) get` + data.Name + `ByID(ctx context.Context, dbConn *sql.DB, id string) (*models.` + data.Name + `, error) { + query := "SELECT id, status, date_created, date_updated, name FROM ` + data.TableName + ` WHERE id = $1 AND status != 'deleted'" + row := dbConn.QueryRowContext(ctx, query, id) + + var item models.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, err + } + + return &item, nil +} + +func (h *` + data.Name + `Handler) create` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models.` + data.Name + `CreateRequest) (*models.` + data.Name + `, error) { + id := uuid.New().String() + now := time.Now() + + query := "INSERT INTO ` + data.TableName + ` (id, status, date_created, date_updated, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, id, req.Status, now, now, req.Name) + + var item models.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to create ` + data.NameLower + `: %w", err) + } + + return &item, nil +} + +func (h *` + data.Name + `Handler) update` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models.` + data.Name + `UpdateRequest) (*models.` + data.Name + `, error) { + now := time.Now() + + query := "UPDATE ` + data.TableName + ` SET status = $2, date_updated = $3, name = $4 WHERE id = $1 AND status != 'deleted' RETURNING id, status, date_created, date_updated, name" + row := dbConn.QueryRowContext(ctx, query, req.ID, req.Status, now, req.Name) + + var item models.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("failed to update ` + data.NameLower + `: %w", err) + } + + return &item, nil +} + +func (h *` + data.Name + `Handler) delete` + data.Name + `(ctx context.Context, dbConn *sql.DB, id string) error { + now := time.Now() + query := "UPDATE ` + data.TableName + ` SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'" + result, err := dbConn.ExecContext(ctx, query, id, now) + if err != nil { + return fmt.Errorf("failed to delete ` + data.NameLower + `: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func (h *` + data.Name + `Handler) fetch` + data.Name + `s(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter, limit, offset int) ([]models.` + data.Name + `, error) { + whereClause, args := h.buildWhereClause(filter) + query := fmt.Sprintf("SELECT id, status, date_created, date_updated, name FROM ` + data.TableName + ` WHERE %s ORDER BY date_created DESC NULLS LAST LIMIT $%d OFFSET $%d", whereClause, len(args)+1, len(args)+2) + + args = append(args, limit, offset) + rows, err := dbConn.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("fetch ` + data.NamePlural + ` query failed: %w", err) + } + defer rows.Close() + + items := make([]models.` + data.Name + `, 0, limit) + for rows.Next() { + var item models.` + data.Name + ` + err := rows.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + if err != nil { + return nil, fmt.Errorf("scan ` + data.Name + ` failed: %w", err) + } + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return items, nil +} + +func (h *` + data.Name + `Handler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter, total *int) error { + whereClause, args := h.buildWhereClause(filter) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM ` + data.TableName + ` WHERE %s", whereClause) + if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { + return fmt.Errorf("total count query failed: %w", err) + } + return nil +} + +func (h *` + data.Name + `Handler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter) (*models.AggregateData, error) { + aggregate := &models.AggregateData{ + ByStatus: make(map[string]int), + } + + whereClause, args := h.buildWhereClause(filter) + statusQuery := fmt.Sprintf("SELECT status, COUNT(*) FROM ` + data.TableName + ` WHERE %s GROUP BY status ORDER BY status", whereClause) + + rows, err := dbConn.QueryContext(ctx, statusQuery, args...) + if err != nil { + return nil, fmt.Errorf("status query failed: %w", err) + } + defer rows.Close() + + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, fmt.Errorf("status scan failed: %w", err) + } + aggregate.ByStatus[status] = count + + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + + return aggregate, nil +} + +// Helper methods +func (h *` + data.Name + `Handler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { + log.Printf("[ERROR] %s: %v", message, err) + h.respondError(c, message, err, statusCode) +} + +func (h *` + data.Name + `Handler) respondError(c *gin.Context, message string, err error, statusCode int) { + errorMessage := message + if gin.Mode() == gin.ReleaseMode { + errorMessage = "Internal server error" + } + + c.JSON(statusCode, models.ErrorResponse{ + Error: errorMessage, + Code: statusCode, + Message: err.Error(), + Timestamp: time.Now(), + }) +} + +func (h *` + data.Name + `Handler) parsePaginationParams(c *gin.Context) (int, int, error) { + limit := 10 // Default limit + offset := 0 // Default offset + + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) + } + if parsedLimit <= 0 { + return 0, 0, fmt.Errorf("limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fmt.Errorf("limit cannot exceed 100") + } + limit = parsedLimit + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + parsedOffset, err := strconv.Atoi(offsetStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) + } + if parsedOffset < 0 { + return 0, 0, fmt.Errorf("offset cannot be negative") + } + offset = parsedOffset + } + + return limit, offset, nil +} + +func (h *` + data.Name + `Handler) parseFilterParams(c *gin.Context) models.` + data.Name + `Filter { + filter := models.` + data.Name + `Filter{} + + if status := c.Query("status"); status != "" { + if models.IsValidStatus(status) { + filter.Status = &status + } + } + + if search := c.Query("search"); search != "" { + filter.Search = &search + } + + // Parse date filters + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + filter.DateFrom = &dateFrom + } + } + + if dateToStr := c.Query("date_to"); dateToStr != "" { + if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + filter.DateTo = &dateTo + } + } + + return filter +} + +func (h *` + data.Name + `Handler) buildWhereClause(filter models.` + data.Name + `Filter) (string, []interface{}) { + conditions := []string{"status != 'deleted'"} + args := []interface{}{} + paramCount := 1 + + if filter.Status != nil { + conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) + args = append(args, *filter.Status) + paramCount++ + } + + if filter.Search != nil { + searchCondition := fmt.Sprintf("name ILIKE $%d", paramCount) + conditions = append(conditions, searchCondition) + searchTerm := "%" + *filter.Search + "%" + args = append(args, searchTerm) + paramCount++ + } + + if filter.DateFrom != nil { + conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) + args = append(args, *filter.DateFrom) + paramCount++ + } + + if filter.DateTo != nil { + conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) + args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) + paramCount++ + } + + return strings.Join(conditions, " AND "), args +} + +func (h *` + data.Name + `Handler) calculateMeta(limit, offset, total int) models.MetaResponse { + totalPages := 0 + currentPage := 1 + if limit > 0 { + totalPages = (total + limit - 1) / limit // Ceiling division + currentPage = (offset / limit) + 1 + } + + return models.MetaResponse{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + CurrentPage: currentPage, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} +` +} + +// ================= MODEL GENERATION ===================== + +func generateModelFile(data HandlerData, modelDir string) { + modelContent := `package models + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "time" +) + +// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility +type NullableInt32 struct { + Int32 int32 ` + "`json:\"int32,omitempty\"`" + ` + Valid bool ` + "`json:\"valid\"`" + ` +} + +// Scan implements the sql.Scanner interface for NullableInt32 +func (n *NullableInt32) Scan(value interface{}) error { + var ni sql.NullInt32 + if err := ni.Scan(value); err != nil { + return err + } + n.Int32 = ni.Int32 + n.Valid = ni.Valid + return nil +} + +// Value implements the driver.Valuer interface for NullableInt32 +func (n NullableInt32) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + return n.Int32, nil +} + +// ` + data.Name + ` represents the data structure for the ` + data.NameLower + ` table +type ` + data.Name + ` struct { + ID string ` + "`json:\"id\" db:\"id\"`" + ` + Status string ` + "`json:\"status\" db:\"status\"`" + ` + Sort NullableInt32 ` + "`json:\"sort,omitempty\" db:\"sort\"`" + ` + UserCreated sql.NullString ` + "`json:\"user_created,omitempty\" db:\"user_created\"`" + ` + DateCreated sql.NullTime ` + "`json:\"date_created,omitempty\" db:\"date_created\"`" + ` + UserUpdated sql.NullString ` + "`json:\"user_updated,omitempty\" db:\"user_updated\"`" + ` + DateUpdated sql.NullTime ` + "`json:\"date_updated,omitempty\" db:\"date_updated\"`" + ` + Name sql.NullString ` + "`json:\"name,omitempty\" db:\"name\"`" + ` +} + +// Custom JSON marshaling for ` + data.Name + ` +func (r ` + data.Name + `) MarshalJSON() ([]byte, error) { + type Alias ` + data.Name + ` + aux := &struct { + Sort *int ` + "`json:\"sort,omitempty\"`" + ` + UserCreated *string ` + "`json:\"user_created,omitempty\"`" + ` + DateCreated *time.Time ` + "`json:\"date_created,omitempty\"`" + ` + UserUpdated *string ` + "`json:\"user_updated,omitempty\"`" + ` + DateUpdated *time.Time ` + "`json:\"date_updated,omitempty\"`" + ` + Name *string ` + "`json:\"name,omitempty\"`" + ` + *Alias + }{ + Alias: (*Alias)(&r), + } + + if r.Sort.Valid { + sort := int(r.Sort.Int32) + aux.Sort = &sort + } + if r.UserCreated.Valid { + aux.UserCreated = &r.UserCreated.String + } + if r.DateCreated.Valid { + aux.DateCreated = &r.DateCreated.Time + } + if r.UserUpdated.Valid { + aux.UserUpdated = &r.UserUpdated.String + } + if r.DateUpdated.Valid { + aux.DateUpdated = &r.DateUpdated.Time + } + if r.Name.Valid { + aux.Name = &r.Name.String + } + + return json.Marshal(aux) +} + +// Helper methods +func (r *` + data.Name + `) GetName() string { + if r.Name.Valid { + return r.Name.String + } + return "" +} +` + + // Add request/response structs + if data.HasGet { + modelContent += ` +// Response struct for GET by ID +type ` + data.Name + `GetByIDResponse struct { + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` +} + +// Enhanced GET response with pagination and aggregation +type ` + data.Name + `GetResponse struct { + Message string ` + "`json:\"message\"`" + ` + Data []` + data.Name + ` ` + "`json:\"data\"`" + ` + Meta MetaResponse ` + "`json:\"meta\"`" + ` + Summary *AggregateData ` + "`json:\"summary,omitempty\"`" + ` +} +` + } + + if data.HasPost { + modelContent += ` +// Request struct for create +type ` + data.Name + `CreateRequest struct { + Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` + Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` +} + +// Response struct for create +type ` + data.Name + `CreateResponse struct { + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` +} +` + } + + if data.HasPut { + modelContent += ` +// Update request +type ` + data.Name + `UpdateRequest struct { + ID string ` + "`json:\"-\" validate:\"required,uuid4\"`" + ` // ID dari URL path + Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` + Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` +} + +// Response struct for update +type ` + data.Name + `UpdateResponse struct { + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` +} +` + } + + if data.HasDelete { + modelContent += ` +// Response struct for delete +type ` + data.Name + `DeleteResponse struct { + Message string ` + "`json:\"message\"`" + ` + ID string ` + "`json:\"id\"`" + ` +} +` + } + + // Add common structs + modelContent += ` +// Metadata for pagination +type MetaResponse struct { + Limit int ` + "`json:\"limit\"`" + ` + Offset int ` + "`json:\"offset\"`" + ` + Total int ` + "`json:\"total\"`" + ` + TotalPages int ` + "`json:\"total_pages\"`" + ` + CurrentPage int ` + "`json:\"current_page\"`" + ` + HasNext bool ` + "`json:\"has_next\"`" + ` + HasPrev bool ` + "`json:\"has_prev\"`" + ` +} + +// Aggregate data for summary +type AggregateData struct { + TotalActive int ` + "`json:\"total_active\"`" + ` + TotalDraft int ` + "`json:\"total_draft\"`" + ` + TotalInactive int ` + "`json:\"total_inactive\"`" + ` + ByStatus map[string]int ` + "`json:\"by_status\"`" + ` + LastUpdated *time.Time ` + "`json:\"last_updated,omitempty\"`" + ` + CreatedToday int ` + "`json:\"created_today\"`" + ` + UpdatedToday int ` + "`json:\"updated_today\"`" + ` +} + +// Error response +type ErrorResponse struct { + Error string ` + "`json:\"error\"`" + ` + Code int ` + "`json:\"code\"`" + ` + Message string ` + "`json:\"message\"`" + ` + Timestamp time.Time ` + "`json:\"timestamp\"`" + ` +} + +// Filter struct for query parameters +type ` + data.Name + `Filter struct { + Status *string ` + "`json:\"status,omitempty\" form:\"status\"`" + ` + Search *string ` + "`json:\"search,omitempty\" form:\"search\"`" + ` + DateFrom *time.Time ` + "`json:\"date_from,omitempty\" form:\"date_from\"`" + ` + DateTo *time.Time ` + "`json:\"date_to,omitempty\" form:\"date_to\"`" + ` +} + +// Validation constants +const ( + StatusDraft = "draft" + StatusActive = "active" + StatusInactive = "inactive" + StatusDeleted = "deleted" +) + +// ValidStatuses for validation +var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} + +// IsValidStatus helper function +func IsValidStatus(status string) bool { + for _, validStatus := range ValidStatuses { + if status == validStatus { + return true + } + } + return false +} +` + writeFile(filepath.Join(modelDir, data.NameLower+".go"), modelContent) } -// ================= ROUTES ===================== +// ================= ROUTES GENERATION ===================== + func updateRoutesFile(data HandlerData) { + // routesFile := "internal/routes/v1/routes.go" + // content, err := os.ReadFile(routesFile) + // if err != nil { + // fmt.Printf("⚠️ Could not read routes.go: %v\n", err) + // fmt.Printf("📝 Please manually add these routes to your routes.go file:\n") + // printRoutesSample(data) + // return + // } + + // routesContent := string(content) + + // // Add import + // importPattern := data.NameLower + `Handlers "` + data.ModuleName + `/internal/handlers/` + data.NameLower + `"` + // if !strings.Contains(routesContent, importPattern) { + // fmt.Printf("⚠️ Please add this import to your routes.go file:\n") + // fmt.Printf("import %sHandlers \"%s/internal/handlers/%s\"\n\n", data.NameLower, data.ModuleName, data.NameLower) + // } + + // // Check if routes already exist + // if strings.Contains(routesContent, fmt.Sprintf("New%sHandler", data.Name)) { + // fmt.Printf("⚠️ Routes for %s already exist, skipping...\n", data.Name) + // return + // } + + // fmt.Printf("📝 Please manually add these routes to your routes.go file:\n") + // printRoutesSample(data) routesFile := "internal/routes/v1/routes.go" content, err := os.ReadFile(routesFile) if err != nil { - fmt.Printf("Error reading routes.go: %v\n", err) + fmt.Printf("⚠️ Could not read routes.go: %v\n", err) + fmt.Printf("📝 Please manually add these routes to your routes.go file:\n") + printRoutesSample(data) return } routesContent := string(content) @@ -370,7 +1140,7 @@ func updateRoutesFile(data HandlerData) { routesContent = strings.Replace(routesContent, insertMarker, newRoutes+insertMarker, 1) } else { - fmt.Printf("⚠️ Routes for %s already exist, skipping...\n", data.Name) + fmt.Printf("✅ Routes for %s already exist, skipping...\n", data.Name) } } @@ -381,11 +1151,37 @@ func updateRoutesFile(data HandlerData) { fmt.Printf("✅ Updated routes.go with %s endpoints\n", data.Name) } -// ================= UTIL ===================== +func printRoutesSample(data HandlerData) { + fmt.Printf(` + // %s endpoints + %sHandler := %sHandlers.New%sHandler() +`, data.Name, data.NameLower, data.NameLower, data.Name) + + if data.HasGet { + fmt.Printf("\tv1.GET(\"/%s\", %sHandler.Get%s)\n", data.NamePlural, data.NameLower, data.Name) + fmt.Printf("\tv1.GET(\"/%s/:id\", %sHandler.Get%sByID)\n", data.NameLower, data.NameLower, data.Name) + } + if data.HasPost { + fmt.Printf("\tv1.POST(\"/%s\", %sHandler.Create%s)\n", data.NamePlural, data.NameLower, data.Name) + } + if data.HasPut { + fmt.Printf("\tv1.PUT(\"/%s/:id\", %sHandler.Update%s)\n", data.NameLower, data.NameLower, data.Name) + } + if data.HasDelete { + fmt.Printf("\tv1.DELETE(\"/%s/:id\", %sHandler.Delete%s)\n", data.NameLower, data.NameLower, data.Name) + } + if data.HasStats { + fmt.Printf("\tv1.GET(\"/%s/stats\", %sHandler.Get%sStats)\n", data.NamePlural, data.NameLower, data.Name) + } + fmt.Println() +} + +// ================= UTILITY FUNCTIONS ===================== + func writeFile(filename, content string) { if err := os.WriteFile(filename, []byte(content), 0644); err != nil { - fmt.Printf("Error creating file %s: %v\n", filename, err) + fmt.Printf("❌ Error creating file %s: %v\n", filename, err) return } - fmt.Printf("Generated: %s\n", filename) + fmt.Printf("✅ Generated: %s\n", filename) }