diff --git a/cmd/api/server.go b/cmd/api/server.go index 50e9af5..6b0e78b 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -97,6 +97,7 @@ func (s *FiberServer) SetupServer( raguRepo := repositories.NewRagRepository(poolPg, redis) usageRepo := repositories.NewUsageRepository(redis) statisticRepo := repositories.NewStatisticRepository(poolPg, redis) + chatRepo := repositories.NewChatRepository(poolPg, redis) // service setup authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis, poolPg) @@ -116,7 +117,7 @@ func (s *FiberServer) SetupServer( userRepo, wikiRepo, geometryRepo, entityRepo, raguRepo, raguUtils, poolPg, redis, ) - chatbotService := services.NewChatbotService(raguRepo, usageRepo, raguUtils) + chatbotService := services.NewChatbotService(raguRepo, usageRepo, chatRepo, raguUtils) statisticService := services.NewStatisticService(statisticRepo) // controller setup diff --git a/db/migrations/0000015_chat_and_support.down.sql b/db/migrations/0000015_chat_and_support.down.sql new file mode 100644 index 0000000..f2c0812 --- /dev/null +++ b/db/migrations/0000015_chat_and_support.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS chatbot_histories CASCADE; +DROP TABLE IF EXISTS messages CASCADE; +DROP TABLE IF EXISTS conversations CASCADE; diff --git a/db/migrations/0000015_chat_and_support.up.sql b/db/migrations/0000015_chat_and_support.up.sql new file mode 100644 index 0000000..70da5d2 --- /dev/null +++ b/db/migrations/0000015_chat_and_support.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mod_id UUID REFERENCES users(id) ON DELETE SET NULL, + status SMALLINT NOT NULL DEFAULT 1, -- 1: waiting, 2: active, 3: resolved + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_conversations_user_id ON conversations (user_id); +CREATE INDEX idx_conversations_mod_id ON conversations (mod_id); +CREATE INDEX idx_conversations_status ON conversations (status); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_messages_conversation_id ON messages (conversation_id); +CREATE INDEX idx_messages_created_at ON messages (created_at DESC); + +CREATE TABLE IF NOT EXISTS chatbot_histories ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + answer TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chatbot_histories_user_id ON chatbot_histories (user_id, created_at DESC); diff --git a/db/query/chat.sql b/db/query/chat.sql new file mode 100644 index 0000000..13afffb --- /dev/null +++ b/db/query/chat.sql @@ -0,0 +1,46 @@ +-- name: CreateConversation :one +INSERT INTO conversations (user_id, status) +VALUES ($1, $2) +RETURNING *; + +-- name: UpdateConversationStatus :one +UPDATE conversations +SET status = $2, mod_id = COALESCE($3, mod_id), closed_at = $4, updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: CreateMessage :one +INSERT INTO messages (conversation_id, sender_id, content) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetMessagesByConversation :many +SELECT * FROM messages +WHERE conversation_id = $1 + AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid) +ORDER BY created_at DESC +LIMIT sqlc.arg('limit'); + +-- name: GetConversationsByIDs :many +SELECT * FROM conversations WHERE id = ANY($1::uuid[]); + +-- name: GetMessagesByIDs :many +SELECT * FROM messages WHERE id = ANY($1::uuid[]); + +-- name: CreateChatbotHistory :one +INSERT INTO chatbot_histories (user_id, question, answer) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetChatbotHistory :many +SELECT * FROM ( + SELECT * FROM chatbot_histories + WHERE user_id = $1 + AND (sqlc.narg('cursor_id')::uuid IS NULL OR id < sqlc.narg('cursor_id')::uuid) + ORDER BY created_at DESC + LIMIT sqlc.arg('limit') +) sub +ORDER BY created_at ASC; + +-- name: GetChatbotHistoriesByIDs :many +SELECT * FROM chatbot_histories WHERE id = ANY($1::uuid[]); diff --git a/db/schema.sql b/db/schema.sql index 1c6ef07..7dd52f3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -208,3 +208,30 @@ CREATE TABLE IF NOT EXISTS system_statistics ( created_at TIMESTAMPTZ DEFAULT now() ); + +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mod_id UUID REFERENCES users(id) ON DELETE SET NULL, + status SMALLINT NOT NULL DEFAULT 1, + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS chatbot_histories ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + answer TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + diff --git a/docs/docs.go b/docs/docs.go index c161a7a..c62a1de 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -462,6 +462,72 @@ const docTemplate = `{ } } }, + "/chatbot/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get chatbot history for the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chatbot" + ], + "summary": "Get chatbot history", + "parameters": [ + { + "type": "string", + "description": "Cursor ID for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Limit number of items returned, default 10", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful response", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/entities": { "get": { "description": "Search entities with cursor pagination", @@ -4208,12 +4274,6 @@ const docTemplate = `{ "$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot" } }, - "entity_wikis": { - "type": "array", - "items": { - "$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot" - } - }, "geometries": { "type": "array", "items": { @@ -4378,8 +4438,7 @@ const docTemplate = `{ "history-api_internal_dtos_request.EntitySnapshot": { "type": "object", "required": [ - "id", - "name" + "id" ], "properties": { "base_hash": { @@ -4452,7 +4511,8 @@ const docTemplate = `{ "type": "string", "enum": [ "reference", - "delete" + "delete", + "binding" ] }, "wiki_id": { @@ -4586,14 +4646,21 @@ const docTemplate = `{ }, "geometry_id": { "type": "string" + }, + "operation": { + "type": "string", + "enum": [ + "reference", + "delete", + "binding" + ] } } }, "history-api_internal_dtos_request.GeometrySnapshot": { "type": "object", "required": [ - "id", - "type" + "id" ], "properties": { "base_hash": { @@ -4926,6 +4993,26 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_response.ChatbotHistoryDto": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_response.CommonResponse": { "type": "object", "properties": { @@ -4939,6 +5026,20 @@ const docTemplate = `{ } } }, + "history-api_internal_dtos_response.GetChatbotHistoryResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto" + } + }, + "pre_cursor": { + "type": "string" + } + } + }, "history-api_internal_dtos_response.PaginatedResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index e08b35b..5be0132 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -455,6 +455,72 @@ } } }, + "/chatbot/history": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get chatbot history for the current user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chatbot" + ], + "summary": "Get chatbot history", + "parameters": [ + { + "type": "string", + "description": "Cursor ID for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Limit number of items returned, default 10", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful response", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" + } + } + } + } + }, "/entities": { "get": { "description": "Search entities with cursor pagination", @@ -4201,12 +4267,6 @@ "$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot" } }, - "entity_wikis": { - "type": "array", - "items": { - "$ref": "#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot" - } - }, "geometries": { "type": "array", "items": { @@ -4371,8 +4431,7 @@ "history-api_internal_dtos_request.EntitySnapshot": { "type": "object", "required": [ - "id", - "name" + "id" ], "properties": { "base_hash": { @@ -4445,7 +4504,8 @@ "type": "string", "enum": [ "reference", - "delete" + "delete", + "binding" ] }, "wiki_id": { @@ -4579,14 +4639,21 @@ }, "geometry_id": { "type": "string" + }, + "operation": { + "type": "string", + "enum": [ + "reference", + "delete", + "binding" + ] } } }, "history-api_internal_dtos_request.GeometrySnapshot": { "type": "object", "required": [ - "id", - "type" + "id" ], "properties": { "base_hash": { @@ -4919,6 +4986,26 @@ } } }, + "history-api_internal_dtos_response.ChatbotHistoryDto": { + "type": "object", + "properties": { + "answer": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "question": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "history-api_internal_dtos_response.CommonResponse": { "type": "object", "properties": { @@ -4932,6 +5019,20 @@ } } }, + "history-api_internal_dtos_response.GetChatbotHistoryResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto" + } + }, + "pre_cursor": { + "type": "string" + } + } + }, "history-api_internal_dtos_response.PaginatedResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e68ae20..1fb2ce0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -80,10 +80,6 @@ definitions: items: $ref: '#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot' type: array - entity_wikis: - items: - $ref: '#/definitions/history-api_internal_dtos_request.EntityWikiLinkSnapshot' - type: array geometries: items: $ref: '#/definitions/history-api_internal_dtos_request.GeometrySnapshot' @@ -232,7 +228,6 @@ definitions: type: number required: - id - - name type: object history-api_internal_dtos_request.EntityWikiLinkSnapshot: properties: @@ -247,6 +242,7 @@ definitions: enum: - reference - delete + - binding type: string wiki_id: type: string @@ -338,6 +334,12 @@ definitions: type: string geometry_id: type: string + operation: + enum: + - reference + - delete + - binding + type: string required: - entity_id - geometry_id @@ -380,7 +382,6 @@ definitions: type: string required: - id - - type type: object history-api_internal_dtos_request.MediaBulkDeleteDto: properties: @@ -577,6 +578,19 @@ definitions: - id - title type: object + history-api_internal_dtos_response.ChatbotHistoryDto: + properties: + answer: + type: string + created_at: + type: string + id: + type: string + question: + type: string + user_id: + type: string + type: object history-api_internal_dtos_response.CommonResponse: properties: data: {} @@ -586,6 +600,15 @@ definitions: status: type: boolean type: object + history-api_internal_dtos_response.GetChatbotHistoryResponse: + properties: + items: + items: + $ref: '#/definitions/history-api_internal_dtos_response.ChatbotHistoryDto' + type: array + pre_cursor: + type: string + type: object history-api_internal_dtos_response.PaginatedResponse: properties: data: {} @@ -962,6 +985,45 @@ paths: summary: Ask the AI chatbot tags: - Chatbot + /chatbot/history: + get: + consumes: + - application/json + description: Get chatbot history for the current user + parameters: + - description: Cursor ID for pagination + in: query + name: cursor + type: string + - description: Limit number of items returned, default 10 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Successful response + schema: + allOf: + - $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + - properties: + data: + $ref: '#/definitions/history-api_internal_dtos_response.GetChatbotHistoryResponse' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' + security: + - BearerAuth: [] + summary: Get chatbot history + tags: + - Chatbot /entities: get: consumes: diff --git a/internal/controllers/chatbotController.go b/internal/controllers/chatbotController.go index a63493f..944bbd9 100644 --- a/internal/controllers/chatbotController.go +++ b/internal/controllers/chatbotController.go @@ -4,6 +4,7 @@ import ( "context" "history-api/internal/dtos/request" "history-api/internal/dtos/response" + "history-api/internal/models" "history-api/internal/services" "history-api/pkg/validator" "time" @@ -67,3 +68,51 @@ func (cx *ChatbotController) Chat(c fiber.Ctx) error { Data: answer, }) } + +// GetHistory godoc +// @Summary Get chatbot history +// @Description Get chatbot history for the current user +// @Tags Chatbot +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param cursor query string false "Cursor ID for pagination" +// @Param limit query int false "Limit number of items returned, default 10" +// @Success 200 {object} response.CommonResponse{data=response.GetChatbotHistoryResponse} "Successful response" +// @Failure 400 {object} response.CommonResponse "Invalid request" +// @Failure 500 {object} response.CommonResponse "Internal server error" +// @Router /chatbot/history [get] +func (cx *ChatbotController) GetHistory(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + dto := &request.GetChatbotHistoryDto{} + if err := validator.ValidateQueryDto(c, dto); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Errors: err, + }) + } + + uid := c.Locals("uid").(string) + histories, err := cx.chatbotService.GetHistory(ctx, uid, dto) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + var preCursor string + if len(histories) > 0 { + preCursor = histories[0].ID + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: response.GetChatbotHistoryResponse{ + Items: models.ChatbotHistoryEntitiesToResponse(histories), + PreCursor: preCursor, + }, + }) +} diff --git a/internal/dtos/request/chatbot.go b/internal/dtos/request/chatbot.go index aa3a814..dd93de9 100644 --- a/internal/dtos/request/chatbot.go +++ b/internal/dtos/request/chatbot.go @@ -4,3 +4,8 @@ type ChatbotDto struct { ProjectID *string `json:"project_id"` Question string `json:"question" validate:"required"` } + +type GetChatbotHistoryDto struct { + Cursor *string `json:"cursor" query:"cursor" validate:"omitempty,uuid"` + Limit int `json:"limit" query:"limit" validate:"omitempty,min=1,max=100"` +} diff --git a/internal/dtos/response/chatbot.go b/internal/dtos/response/chatbot.go new file mode 100644 index 0000000..2aca51b --- /dev/null +++ b/internal/dtos/response/chatbot.go @@ -0,0 +1,18 @@ +package response + +import ( + "time" +) + +type ChatbotHistoryDto struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Question string `json:"question"` + Answer string `json:"answer"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +type GetChatbotHistoryResponse struct { + Items []*ChatbotHistoryDto `json:"items"` + PreCursor string `json:"pre_cursor,omitempty"` +} diff --git a/internal/gen/sqlc/chat.sql.go b/internal/gen/sqlc/chat.sql.go new file mode 100644 index 0000000..d07a6b7 --- /dev/null +++ b/internal/gen/sqlc/chat.sql.go @@ -0,0 +1,297 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: chat.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createChatbotHistory = `-- name: CreateChatbotHistory :one +INSERT INTO chatbot_histories (user_id, question, answer) +VALUES ($1, $2, $3) +RETURNING id, user_id, question, answer, created_at +` + +type CreateChatbotHistoryParams struct { + UserID pgtype.UUID `json:"user_id"` + Question string `json:"question"` + Answer string `json:"answer"` +} + +func (q *Queries) CreateChatbotHistory(ctx context.Context, arg CreateChatbotHistoryParams) (ChatbotHistory, error) { + row := q.db.QueryRow(ctx, createChatbotHistory, arg.UserID, arg.Question, arg.Answer) + var i ChatbotHistory + err := row.Scan( + &i.ID, + &i.UserID, + &i.Question, + &i.Answer, + &i.CreatedAt, + ) + return i, err +} + +const createConversation = `-- name: CreateConversation :one +INSERT INTO conversations (user_id, status) +VALUES ($1, $2) +RETURNING id, user_id, mod_id, status, closed_at, created_at, updated_at +` + +type CreateConversationParams struct { + UserID pgtype.UUID `json:"user_id"` + Status int16 `json:"status"` +} + +func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) { + row := q.db.QueryRow(ctx, createConversation, arg.UserID, arg.Status) + var i Conversation + err := row.Scan( + &i.ID, + &i.UserID, + &i.ModID, + &i.Status, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createMessage = `-- name: CreateMessage :one +INSERT INTO messages (conversation_id, sender_id, content) +VALUES ($1, $2, $3) +RETURNING id, conversation_id, sender_id, content, created_at +` + +type CreateMessageParams struct { + ConversationID pgtype.UUID `json:"conversation_id"` + SenderID pgtype.UUID `json:"sender_id"` + Content string `json:"content"` +} + +func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) { + row := q.db.QueryRow(ctx, createMessage, arg.ConversationID, arg.SenderID, arg.Content) + var i Message + err := row.Scan( + &i.ID, + &i.ConversationID, + &i.SenderID, + &i.Content, + &i.CreatedAt, + ) + return i, err +} + +const getChatbotHistoriesByIDs = `-- name: GetChatbotHistoriesByIDs :many +SELECT id, user_id, question, answer, created_at FROM chatbot_histories WHERE id = ANY($1::uuid[]) +` + +func (q *Queries) GetChatbotHistoriesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]ChatbotHistory, error) { + rows, err := q.db.Query(ctx, getChatbotHistoriesByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChatbotHistory{} + for rows.Next() { + var i ChatbotHistory + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Question, + &i.Answer, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatbotHistory = `-- name: GetChatbotHistory :many +SELECT id, user_id, question, answer, created_at FROM ( + SELECT id, user_id, question, answer, created_at FROM chatbot_histories + WHERE user_id = $1 + AND ($2::uuid IS NULL OR id < $2::uuid) + ORDER BY created_at DESC + LIMIT $3 +) sub +ORDER BY created_at ASC +` + +type GetChatbotHistoryParams struct { + UserID pgtype.UUID `json:"user_id"` + CursorID pgtype.UUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) GetChatbotHistory(ctx context.Context, arg GetChatbotHistoryParams) ([]ChatbotHistory, error) { + rows, err := q.db.Query(ctx, getChatbotHistory, arg.UserID, arg.CursorID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChatbotHistory{} + for rows.Next() { + var i ChatbotHistory + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Question, + &i.Answer, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getConversationsByIDs = `-- name: GetConversationsByIDs :many +SELECT id, user_id, mod_id, status, closed_at, created_at, updated_at FROM conversations WHERE id = ANY($1::uuid[]) +` + +func (q *Queries) GetConversationsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Conversation, error) { + rows, err := q.db.Query(ctx, getConversationsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Conversation{} + for rows.Next() { + var i Conversation + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ModID, + &i.Status, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMessagesByConversation = `-- name: GetMessagesByConversation :many +SELECT id, conversation_id, sender_id, content, created_at FROM messages +WHERE conversation_id = $1 + AND ($2::uuid IS NULL OR id < $2::uuid) +ORDER BY created_at DESC +LIMIT $3 +` + +type GetMessagesByConversationParams struct { + ConversationID pgtype.UUID `json:"conversation_id"` + CursorID pgtype.UUID `json:"cursor_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) GetMessagesByConversation(ctx context.Context, arg GetMessagesByConversationParams) ([]Message, error) { + rows, err := q.db.Query(ctx, getMessagesByConversation, arg.ConversationID, arg.CursorID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.ConversationID, + &i.SenderID, + &i.Content, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMessagesByIDs = `-- name: GetMessagesByIDs :many +SELECT id, conversation_id, sender_id, content, created_at FROM messages WHERE id = ANY($1::uuid[]) +` + +func (q *Queries) GetMessagesByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Message, error) { + rows, err := q.db.Query(ctx, getMessagesByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.ConversationID, + &i.SenderID, + &i.Content, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateConversationStatus = `-- name: UpdateConversationStatus :one +UPDATE conversations +SET status = $2, mod_id = COALESCE($3, mod_id), closed_at = $4, updated_at = NOW() +WHERE id = $1 +RETURNING id, user_id, mod_id, status, closed_at, created_at, updated_at +` + +type UpdateConversationStatusParams struct { + ID pgtype.UUID `json:"id"` + Status int16 `json:"status"` + ModID pgtype.UUID `json:"mod_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` +} + +func (q *Queries) UpdateConversationStatus(ctx context.Context, arg UpdateConversationStatusParams) (Conversation, error) { + row := q.db.QueryRow(ctx, updateConversationStatus, + arg.ID, + arg.Status, + arg.ModID, + arg.ClosedAt, + ) + var i Conversation + err := row.Scan( + &i.ID, + &i.UserID, + &i.ModID, + &i.Status, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index df6dd5b..63e5539 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -11,6 +11,14 @@ import ( "github.com/pgvector/pgvector-go" ) +type ChatbotHistory struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Question string `json:"question"` + Answer string `json:"answer"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Commit struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -22,6 +30,16 @@ type Commit struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ModID pgtype.UUID `json:"mod_id"` + Status int16 `json:"status"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Entity struct { ID pgtype.UUID `json:"id"` ProjectID pgtype.UUID `json:"project_id"` @@ -74,6 +92,14 @@ type Media struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type Message struct { + ID pgtype.UUID `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + SenderID pgtype.UUID `json:"sender_id"` + Content string `json:"content"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Title string `json:"title"` diff --git a/internal/models/chat.go b/internal/models/chat.go new file mode 100644 index 0000000..accbf12 --- /dev/null +++ b/internal/models/chat.go @@ -0,0 +1,53 @@ +package models + +import ( + "history-api/internal/dtos/response" + "time" +) + +type ConversationEntity struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ModID *string `json:"mod_id,omitempty"` + Status int16 `json:"status"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type MessageEntity struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + SenderID string `json:"sender_id"` + Content string `json:"content"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +type ChatbotHistoryEntity struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Question string `json:"question"` + Answer string `json:"answer"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +func (c *ChatbotHistoryEntity) ToResponse() *response.ChatbotHistoryDto { + if c == nil { + return nil + } + return &response.ChatbotHistoryDto{ + ID: c.ID, + UserID: c.UserID, + Question: c.Question, + Answer: c.Answer, + CreatedAt: c.CreatedAt, + } +} + +func ChatbotHistoryEntitiesToResponse(entities []*ChatbotHistoryEntity) []*response.ChatbotHistoryDto { + res := make([]*response.ChatbotHistoryDto, 0, len(entities)) + for _, e := range entities { + res = append(res, e.ToResponse()) + } + return res +} diff --git a/internal/repositories/chatRepository.go b/internal/repositories/chatRepository.go new file mode 100644 index 0000000..356f98d --- /dev/null +++ b/internal/repositories/chatRepository.go @@ -0,0 +1,379 @@ +package repositories + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type ChatRepository interface { + CreateConversation(ctx context.Context, params sqlc.CreateConversationParams) (*models.ConversationEntity, error) + UpdateConversationStatus(ctx context.Context, params sqlc.UpdateConversationStatusParams) (*models.ConversationEntity, error) + CreateMessage(ctx context.Context, params sqlc.CreateMessageParams) (*models.MessageEntity, error) + GetMessagesByConversation(ctx context.Context, params sqlc.GetMessagesByConversationParams) ([]*models.MessageEntity, error) + CreateChatbotHistory(ctx context.Context, params sqlc.CreateChatbotHistoryParams) (*models.ChatbotHistoryEntity, error) + GetChatbotHistory(ctx context.Context, params sqlc.GetChatbotHistoryParams) ([]*models.ChatbotHistoryEntity, error) +} + +type chatRepository struct { + db *pgxpool.Pool + q *sqlc.Queries + c cache.Cache +} + +func NewChatRepository(db *pgxpool.Pool, c cache.Cache) ChatRepository { + return &chatRepository{ + db: db, + q: sqlc.New(db), + c: c, + } +} + +func (r *chatRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:query:%s", prefix, hash) +} + +func (r *chatRepository) getConversationsByIDsWithFallback(ctx context.Context, ids []string) ([]*models.ConversationEntity, error) { + if len(ids) == 0 { + return []*models.ConversationEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("conversation:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var results []*models.ConversationEntity + missingToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.ConversationEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetConversationsByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + item := models.ConversationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + ModID: convert.UUIDToStringPtr(row.ModID), + Status: row.Status, + ClosedAt: convert.TimeToPtr(row.ClosedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + dbMap[item.ID] = &item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var c models.ConversationEntity + if err := json.Unmarshal(b, &c); err == nil { + results = append(results, &c) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + results = append(results, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return results, nil +} + +func (r *chatRepository) getMessagesByIDsWithFallback(ctx context.Context, ids []string) ([]*models.MessageEntity, error) { + if len(ids) == 0 { + return []*models.MessageEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("message:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var results []*models.MessageEntity + missingToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.MessageEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetMessagesByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + item := models.MessageEntity{ + ID: convert.UUIDToString(row.ID), + ConversationID: convert.UUIDToString(row.ConversationID), + SenderID: convert.UUIDToString(row.SenderID), + Content: row.Content, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + dbMap[item.ID] = &item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var c models.MessageEntity + if err := json.Unmarshal(b, &c); err == nil { + results = append(results, &c) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + results = append(results, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return results, nil +} + +func (r *chatRepository) getChatbotHistoriesByIDsWithFallback(ctx context.Context, ids []string) ([]*models.ChatbotHistoryEntity, error) { + if len(ids) == 0 { + return []*models.ChatbotHistoryEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("chatbot_history:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var results []*models.ChatbotHistoryEntity + missingToCache := make(map[string]any) + + var missingPgIds []pgtype.UUID + for i, b := range raws { + if len(b) == 0 { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err == nil { + missingPgIds = append(missingPgIds, pgId) + } + } + } + + dbMap := make(map[string]*models.ChatbotHistoryEntity) + if len(missingPgIds) > 0 { + dbRows, err := r.q.GetChatbotHistoriesByIDs(ctx, missingPgIds) + if err == nil { + for _, row := range dbRows { + item := models.ChatbotHistoryEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + Question: row.Question, + Answer: row.Answer, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + dbMap[item.ID] = &item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var c models.ChatbotHistoryEntity + if err := json.Unmarshal(b, &c); err == nil { + results = append(results, &c) + } + } else { + if item, ok := dbMap[ids[i]]; ok { + results = append(results, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return results, nil +} + +func (r *chatRepository) CreateConversation(ctx context.Context, params sqlc.CreateConversationParams) (*models.ConversationEntity, error) { + row, err := r.q.CreateConversation(ctx, params) + if err != nil { + return nil, err + } + entity := &models.ConversationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + ModID: convert.UUIDToStringPtr(row.ModID), + Status: row.Status, + ClosedAt: convert.TimeToPtr(row.ClosedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + _ = r.c.Set(ctx, fmt.Sprintf("conversation:id:%s", entity.ID), entity, constants.NormalCacheDuration) + return entity, nil +} + +func (r *chatRepository) UpdateConversationStatus(ctx context.Context, params sqlc.UpdateConversationStatusParams) (*models.ConversationEntity, error) { + row, err := r.q.UpdateConversationStatus(ctx, params) + if err != nil { + return nil, err + } + entity := &models.ConversationEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + ModID: convert.UUIDToStringPtr(row.ModID), + Status: row.Status, + ClosedAt: convert.TimeToPtr(row.ClosedAt), + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + _ = r.c.Set(ctx, fmt.Sprintf("conversation:id:%s", entity.ID), entity, constants.NormalCacheDuration) + return entity, nil +} + +func (r *chatRepository) CreateMessage(ctx context.Context, params sqlc.CreateMessageParams) (*models.MessageEntity, error) { + row, err := r.q.CreateMessage(ctx, params) + if err != nil { + return nil, err + } + entity := &models.MessageEntity{ + ID: convert.UUIDToString(row.ID), + ConversationID: convert.UUIDToString(row.ConversationID), + SenderID: convert.UUIDToString(row.SenderID), + Content: row.Content, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + _ = r.c.Set(ctx, fmt.Sprintf("message:id:%s", entity.ID), entity, constants.NormalCacheDuration) + return entity, nil +} + +func (r *chatRepository) GetMessagesByConversation(ctx context.Context, params sqlc.GetMessagesByConversationParams) ([]*models.MessageEntity, error) { + queryKey := r.generateQueryKey("message:conversation", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getMessagesByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetMessagesByConversation(ctx, params) + if err != nil { + return nil, err + } + + var results []*models.MessageEntity + var ids []string + toCache := make(map[string]any) + + for _, row := range rows { + item := &models.MessageEntity{ + ID: convert.UUIDToString(row.ID), + ConversationID: convert.UUIDToString(row.ConversationID), + SenderID: convert.UUIDToString(row.SenderID), + Content: row.Content, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + ids = append(ids, item.ID) + results = append(results, item) + toCache[fmt.Sprintf("message:id:%s", item.ID)] = item + } + + if len(toCache) > 0 { + _ = r.c.MSet(ctx, toCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return results, nil +} + +func (r *chatRepository) CreateChatbotHistory(ctx context.Context, params sqlc.CreateChatbotHistoryParams) (*models.ChatbotHistoryEntity, error) { + row, err := r.q.CreateChatbotHistory(ctx, params) + if err != nil { + return nil, err + } + entity := &models.ChatbotHistoryEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + Question: row.Question, + Answer: row.Answer, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + _ = r.c.Set(ctx, fmt.Sprintf("chatbot_history:id:%s", entity.ID), entity, constants.NormalCacheDuration) + return entity, nil +} + +func (r *chatRepository) GetChatbotHistory(ctx context.Context, params sqlc.GetChatbotHistoryParams) ([]*models.ChatbotHistoryEntity, error) { + queryKey := r.generateQueryKey("chatbot_history:user", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getChatbotHistoriesByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetChatbotHistory(ctx, params) + if err != nil { + return nil, err + } + + var results []*models.ChatbotHistoryEntity + var ids []string + toCache := make(map[string]any) + + for _, row := range rows { + item := &models.ChatbotHistoryEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + Question: row.Question, + Answer: row.Answer, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + } + ids = append(ids, item.ID) + results = append(results, item) + toCache[fmt.Sprintf("chatbot_history:id:%s", item.ID)] = item + } + + if len(toCache) > 0 { + _ = r.c.MSet(ctx, toCache, constants.NormalCacheDuration) + } + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return results, nil +} diff --git a/internal/routes/chatbotRoute.go b/internal/routes/chatbotRoute.go index 3288c5d..8415e7c 100644 --- a/internal/routes/chatbotRoute.go +++ b/internal/routes/chatbotRoute.go @@ -13,4 +13,5 @@ func ChatbotRoutes(app *fiber.App, controller *controllers.ChatbotController, us route.Use(middlewares.JwtAccess(userRepo)) route.Post("/chat", controller.Chat) + route.Get("/history", controller.GetHistory) } diff --git a/internal/services/chatbotService.go b/internal/services/chatbotService.go index 5dfb663..a62c4a0 100644 --- a/internal/services/chatbotService.go +++ b/internal/services/chatbotService.go @@ -4,25 +4,34 @@ import ( "context" "errors" "fmt" + "history-api/internal/dtos/request" + "history-api/internal/gen/sqlc" + "history-api/internal/models" "history-api/internal/repositories" "history-api/pkg/ai" "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/jackc/pgx/v5/pgtype" ) type ChatbotService interface { Chat(ctx context.Context, userID string, projectID *string, question string) (string, error) + GetHistory(ctx context.Context, userID string, dto *request.GetChatbotHistoryDto) ([]*models.ChatbotHistoryEntity, error) } type chatbotService struct { repo repositories.RagRepository usageRepo repositories.UsageRepository + chatRepo repositories.ChatRepository ragUtils *ai.RagUtils } -func NewChatbotService(repo repositories.RagRepository, usageRepo repositories.UsageRepository, ragUtils *ai.RagUtils) ChatbotService { +func NewChatbotService(repo repositories.RagRepository, usageRepo repositories.UsageRepository, chatRepo repositories.ChatRepository, ragUtils *ai.RagUtils) ChatbotService { return &chatbotService{ repo: repo, usageRepo: usageRepo, + chatRepo: chatRepo, ragUtils: ragUtils, } } @@ -51,15 +60,38 @@ func (s *chatbotService) Chat(ctx context.Context, userID string, projectID *str contextStr += fmt.Sprintf("[Document %d]: %s\n", i+1, res.Content) } + pgUserID, err := convert.StringToUUID(userID) + if err != nil { + return "", fmt.Errorf("invalid user id: %w", err) + } + + histories, err := s.chatRepo.GetChatbotHistory(ctx, sqlc.GetChatbotHistoryParams{ + UserID: pgUserID, + Limit: 10, + }) + if err != nil { + fmt.Printf("Warning: failed to get chatbot history: %v\n", err) + } + + historyStr := "" + for _, h := range histories { + historyStr += fmt.Sprintf("User: %s\nAssistant: %s\n\n", h.Question, h.Answer) + } + var prompt string if contextStr == "" { - prompt = fmt.Sprintf(`You are a friendly history assistant chatbot. The user said: "%s" + prompt = fmt.Sprintf(`You are a friendly history assistant chatbot. + +Recent Chat History: +%s + +The user said: "%s" Rules: - If it is a greeting (like "hello", "hi", "xin chào"), respond with a friendly greeting and briefly introduce yourself. - If it is a history question, say that you don't have relevant documents to answer. - You MUST wrap your final response inside tags. Example: Hello! -- Do NOT show your reasoning outside or inside the tags if possible, but the final answer MUST be in tags.`, question) +- Do NOT show your reasoning outside or inside the tags if possible, but the final answer MUST be in tags.`, historyStr, question) } else { prompt = fmt.Sprintf(`You are a helpful history assistant. Answer the question using ONLY the provided context. @@ -71,7 +103,10 @@ Rules: Context: %s -Question: %s`, contextStr, question) +Recent Chat History: +%s + +Question: %s`, contextStr, historyStr, question) } response, err := s.ragUtils.GenerateResponse(ctx, prompt) @@ -80,5 +115,40 @@ Question: %s`, contextStr, question) } _, _ = s.usageRepo.IncrementAIUsage(ctx, userID) + _, err = s.chatRepo.CreateChatbotHistory(ctx, sqlc.CreateChatbotHistoryParams{ + UserID: pgUserID, + Question: question, + Answer: response, + }) + if err != nil { + fmt.Printf("Warning: failed to save chatbot history: %v\n", err) + } + return response, nil } + +func (s *chatbotService) GetHistory(ctx context.Context, userID string, dto *request.GetChatbotHistoryDto) ([]*models.ChatbotHistoryEntity, error) { + pgUserID, err := convert.StringToUUID(userID) + if err != nil { + return nil, fmt.Errorf("invalid user id: %w", err) + } + + var pgCursorID pgtype.UUID + if dto.Cursor != nil { + if err := pgCursorID.Scan(*dto.Cursor); err != nil { + return nil, fmt.Errorf("invalid cursor id: %w", err) + } + } else { + pgCursorID.Valid = false + } + + if dto.Limit <= 0 { + dto.Limit = 10 + } + + return s.chatRepo.GetChatbotHistory(ctx, sqlc.GetChatbotHistoryParams{ + UserID: pgUserID, + CursorID: pgCursorID, + Limit: int32(dto.Limit), + }) +}