Restructured project
- backend moved to backend directory - added and initialized frontend with vue - moved infrastructure files to infra directory
This commit is contained in:
@@ -0,0 +1,655 @@
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/families": {
|
||||
"post": {
|
||||
"description": "Создает новую семью",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Создать семью",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Family info",
|
||||
"name": "family",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.CreateFamilyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid body",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/families/{id}": {
|
||||
"get": {
|
||||
"description": "Возвращает семью по ее внутреннему ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Получить семью по ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Удаляет семью по ее ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Удалить семью",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "no content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Частично обновляет данные семьи по ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Обновить семью",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Данные для обновления",
|
||||
"name": "family",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UpdateFamilyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "name is required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Создать пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.CreateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/by-telegram/{telegramId}": {
|
||||
"get": {
|
||||
"description": "Возвращает пользователя по его Telegram ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Получить пользователя по Telegram ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Telegram ID",
|
||||
"name": "telegramId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid telegram id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"description": "Возвращает пользователя по его внутреннему ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Получить пользователя по ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Удаляет пользователя по его ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Удалить пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "no content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Частично обновляет данные пользователя по ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Обновить пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Данные для обновления",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UpdateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id or invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"domain.CreateFamilyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"first_name",
|
||||
"telegram_id"
|
||||
],
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.FamilyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UpdateFamilyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UpdateUserRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "",
|
||||
Host: "",
|
||||
BasePath: "",
|
||||
Schemes: []string{},
|
||||
Title: "",
|
||||
Description: "",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"contact": {}
|
||||
},
|
||||
"paths": {
|
||||
"/families": {
|
||||
"post": {
|
||||
"description": "Создает новую семью",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Создать семью",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Family info",
|
||||
"name": "family",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.CreateFamilyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid body",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/families/{id}": {
|
||||
"get": {
|
||||
"description": "Возвращает семью по ее внутреннему ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Получить семью по ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Удаляет семью по ее ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Удалить семью",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "no content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Частично обновляет данные семьи по ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Families"
|
||||
],
|
||||
"summary": "Обновить семью",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Family ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Данные для обновления",
|
||||
"name": "family",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UpdateFamilyRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.FamilyResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "name is required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "family not found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Создать пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User info",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.CreateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/by-telegram/{telegramId}": {
|
||||
"get": {
|
||||
"description": "Возвращает пользователя по его Telegram ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Получить пользователя по Telegram ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Telegram ID",
|
||||
"name": "telegramId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid telegram id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"description": "Возвращает пользователя по его внутреннему ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Получить пользователя по ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Удаляет пользователя по его ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Удалить пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "no content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"description": "Частично обновляет данные пользователя по ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Обновить пользователя",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Данные для обновления",
|
||||
"name": "user",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UpdateUserRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid id or invalid body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "user not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.UserErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"domain.CreateFamilyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"first_name",
|
||||
"telegram_id"
|
||||
],
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.FamilyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"owner_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UpdateFamilyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_chat_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UpdateUserRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"language_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"telegram_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
definitions:
|
||||
domain.CreateFamilyRequest:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
owner_id:
|
||||
type: integer
|
||||
telegram_chat_id:
|
||||
type: integer
|
||||
telegram_chat_name:
|
||||
type: string
|
||||
type: object
|
||||
domain.CreateUserRequest:
|
||||
properties:
|
||||
first_name:
|
||||
type: string
|
||||
language_code:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
telegram_id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- first_name
|
||||
- telegram_id
|
||||
type: object
|
||||
domain.FamilyResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
owner_id:
|
||||
type: integer
|
||||
telegram_chat_id:
|
||||
type: integer
|
||||
telegram_chat_name:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
domain.UpdateFamilyRequest:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
telegram_chat_name:
|
||||
type: string
|
||||
type: object
|
||||
domain.UpdateUserRequest:
|
||||
properties:
|
||||
first_name:
|
||||
type: string
|
||||
language_code:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
domain.UserErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
domain.UserResponse:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
first_name:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
language_code:
|
||||
type: string
|
||||
last_name:
|
||||
type: string
|
||||
telegram_id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
/families:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Создает новую семью
|
||||
parameters:
|
||||
- description: Family info
|
||||
in: body
|
||||
name: family
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.CreateFamilyRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/domain.FamilyResponse'
|
||||
"400":
|
||||
description: invalid body
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Создать семью
|
||||
tags:
|
||||
- Families
|
||||
/families/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Удаляет семью по ее ID
|
||||
parameters:
|
||||
- description: Family ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: no content
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: invalid id
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: family not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Удалить семью
|
||||
tags:
|
||||
- Families
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Возвращает семью по ее внутреннему ID
|
||||
parameters:
|
||||
- description: Family ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.FamilyResponse'
|
||||
"400":
|
||||
description: invalid id
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: family not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Получить семью по ID
|
||||
tags:
|
||||
- Families
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Частично обновляет данные семьи по ID
|
||||
parameters:
|
||||
- description: Family ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Данные для обновления
|
||||
in: body
|
||||
name: family
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UpdateFamilyRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.FamilyResponse'
|
||||
"400":
|
||||
description: name is required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: family not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Обновить семью
|
||||
tags:
|
||||
- Families
|
||||
/users:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: User info
|
||||
in: body
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.CreateUserRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
summary: Создать пользователя
|
||||
tags:
|
||||
- Users
|
||||
/users/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Удаляет пользователя по его ID
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: no content
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: invalid id
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"404":
|
||||
description: user not found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
summary: Удалить пользователя
|
||||
tags:
|
||||
- Users
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Возвращает пользователя по его внутреннему ID
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserResponse'
|
||||
"400":
|
||||
description: invalid id
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"404":
|
||||
description: user not found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
summary: Получить пользователя по ID
|
||||
tags:
|
||||
- Users
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Частично обновляет данные пользователя по ID
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Данные для обновления
|
||||
in: body
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UpdateUserRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserResponse'
|
||||
"400":
|
||||
description: invalid id or invalid body
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"404":
|
||||
description: user not found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
summary: Обновить пользователя
|
||||
tags:
|
||||
- Users
|
||||
/users/by-telegram/{telegramId}:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Возвращает пользователя по его Telegram ID
|
||||
parameters:
|
||||
- description: Telegram ID
|
||||
in: path
|
||||
name: telegramId
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserResponse'
|
||||
"400":
|
||||
description: invalid telegram id
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"404":
|
||||
description: user not found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
"500":
|
||||
description: internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.UserErrorResponse'
|
||||
summary: Получить пользователя по Telegram ID
|
||||
tags:
|
||||
- Users
|
||||
swagger: "2.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
package dto
|
||||
|
||||
type ErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthRouter struct {
|
||||
service services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthRouter(s services.AuthService) *AuthRouter {
|
||||
return &AuthRouter{service: s}
|
||||
}
|
||||
|
||||
func (router *AuthRouter) RegisterRouter(r *gin.RouterGroup) {
|
||||
auth := r.Group("/auth")
|
||||
{
|
||||
auth.POST("", router.Auth)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *AuthRouter) Auth(c *gin.Context) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type FamiliesRouter struct {
|
||||
service services.FamilyService
|
||||
}
|
||||
|
||||
func NewFamiliesRouter(s services.FamilyService) *FamiliesRouter {
|
||||
return &FamiliesRouter{service: s}
|
||||
}
|
||||
|
||||
func (router *FamiliesRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
families := r.Group("/families")
|
||||
{
|
||||
families.POST("", router.Create)
|
||||
families.GET("/:id", router.GetByID)
|
||||
families.PATCH("/:id", router.Update)
|
||||
families.DELETE("/:id", router.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// Create GoDoc
|
||||
// @Summary Создать семью
|
||||
// @Description Создает новую семью
|
||||
// @Tags Families
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param family body domain.CreateFamilyRequest true "Family info"
|
||||
// @Success 201 {object} domain.FamilyResponse
|
||||
// @Failure 400 {object} map[string]string "invalid body"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families [post]
|
||||
func (router *FamiliesRouter) Create(c *gin.Context) {
|
||||
var req domain.CreateFamilyRequest
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
family, err := router.service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
handleFamilyError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp.ModelToResponse(family))
|
||||
}
|
||||
|
||||
// GetByID GoDoc
|
||||
// @Summary Получить семью по ID
|
||||
// @Description Возвращает семью по ее внутреннему ID
|
||||
// @Tags Families
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Family ID"
|
||||
// @Success 200 {object} domain.FamilyResponse
|
||||
// @Failure 400 {object} map[string]string "invalid id"
|
||||
// @Failure 404 {object} map[string]string "family not found"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families/{id} [get]
|
||||
func (router *FamiliesRouter) GetByID(c *gin.Context) {
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
family, err := router.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
handleFamilyError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp.ModelToResponse(family))
|
||||
}
|
||||
|
||||
// Update GoDoc
|
||||
// @Summary Обновить семью
|
||||
// @Description Частично обновляет данные семьи по ID
|
||||
// @Tags Families
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Family ID"
|
||||
// @Param family body domain.UpdateFamilyRequest true "Данные для обновления"
|
||||
// @Success 200 {object} domain.FamilyResponse
|
||||
// @Failure 400 {object} map[string]string "invalid id or invalid body"
|
||||
// @Failure 400 {object} map[string]string "name is required"
|
||||
// @Failure 404 {object} map[string]string "family not found"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families/{id} [patch]
|
||||
func (router *FamiliesRouter) Update(c *gin.Context) {
|
||||
var resp domain.FamilyResponse
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req domain.UpdateFamilyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Name == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
family, err := router.service.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
handleFamilyError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp.ModelToResponse(family))
|
||||
}
|
||||
|
||||
// Delete GoDoc
|
||||
// @Summary Удалить семью
|
||||
// @Description Удаляет семью по ее ID
|
||||
// @Tags Families
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Family ID"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {object} map[string]string "invalid id"
|
||||
// @Failure 404 {object} map[string]string "family not found"
|
||||
// @Failure 500 {object} map[string]string "internal server error"
|
||||
// @Router /families/{id} [delete]
|
||||
func (router *FamiliesRouter) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := router.service.Delete(c.Request.Context(), id); err != nil {
|
||||
handleFamilyError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleFamilyError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrFamilyNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "family not found"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type familyServiceMock struct {
|
||||
createFn func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
|
||||
getByIDFn func(ctx context.Context, id int64) (*domain.Family, error)
|
||||
updateFn func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error)
|
||||
deleteFn func(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
func (m *familyServiceMock) Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
|
||||
if m.createFn != nil {
|
||||
return m.createFn(ctx, req)
|
||||
}
|
||||
return nil, errors.New("mock create is not configured")
|
||||
}
|
||||
|
||||
func (m *familyServiceMock) GetByID(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
if m.getByIDFn != nil {
|
||||
return m.getByIDFn(ctx, id)
|
||||
}
|
||||
return nil, errors.New("mock getByID is not configured")
|
||||
}
|
||||
|
||||
func (m *familyServiceMock) Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
|
||||
if m.updateFn != nil {
|
||||
return m.updateFn(ctx, id, req)
|
||||
}
|
||||
return nil, errors.New("mock update is not configured")
|
||||
}
|
||||
|
||||
func (m *familyServiceMock) Delete(ctx context.Context, id int64) error {
|
||||
if m.deleteFn != nil {
|
||||
return m.deleteFn(ctx, id)
|
||||
}
|
||||
return errors.New("mock delete is not configured")
|
||||
}
|
||||
|
||||
func setupFamiliesRouter(mock services.FamilyService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
router := NewFamiliesRouter(mock)
|
||||
router.RegisterRoutes(apiV1)
|
||||
return r
|
||||
}
|
||||
|
||||
func sampleFamily() *domain.Family {
|
||||
return &domain.Family{
|
||||
ID: 7,
|
||||
Name: "Belan",
|
||||
OwnerID: 10,
|
||||
TelegramChatID: 12345,
|
||||
TelegramChatName: "Family Chat",
|
||||
CreatedAt: time.Date(2026, time.January, 21, 10, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, time.January, 21, 11, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamiliesRouter_Create(t *testing.T) {
|
||||
t.Run("bad request on malformed body", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "error")
|
||||
})
|
||||
|
||||
t.Run("internal error", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
|
||||
return nil, errors.New("db unavailable")
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "internal server error")
|
||||
})
|
||||
|
||||
t.Run("created", func(t *testing.T) {
|
||||
expected := sampleFamily()
|
||||
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
|
||||
assert.Equal(t, "Belan", req.Name)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Belan")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFamiliesRouter_GetByID(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/families/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("not found on service error", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
return nil, services.ErrFamilyNotFound
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/families/7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrFamilyNotFound.Error())
|
||||
})
|
||||
|
||||
t.Run("not found on sql no rows", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
return nil, sql.ErrNoRows
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/families/7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "family not found")
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := sampleFamily()
|
||||
r := setupFamiliesRouter(&familyServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
assert.Equal(t, int64(7), id)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/families/7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Belan")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFamiliesRouter_Update(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/abc", bytes.NewBufferString(`{"name":"Belan"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("bad request on malformed body", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "error")
|
||||
})
|
||||
|
||||
t.Run("bad request on missing name", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"telegram_chat_name":"Updated"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "name is required")
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
|
||||
return nil, services.ErrFamilyNotFound
|
||||
}})
|
||||
name := "Belan Updated"
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+name+`","telegram_chat_name":"Updated"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrFamilyNotFound.Error())
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := sampleFamily()
|
||||
updatedName := "Belan Updated"
|
||||
expected.Name = updatedName
|
||||
r := setupFamiliesRouter(&familyServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
|
||||
assert.Equal(t, int64(7), id)
|
||||
require.NotNil(t, req.Name)
|
||||
assert.Equal(t, updatedName, *req.Name)
|
||||
assert.Equal(t, "Updated", req.TelegramChatName)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/families/7", bytes.NewBufferString(`{"name":"`+updatedName+`","telegram_chat_name":"Updated"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), updatedName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFamiliesRouter_Delete(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/families/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
r := setupFamiliesRouter(&familyServiceMock{deleteFn: func(ctx context.Context, id int64) error {
|
||||
return sql.ErrNoRows
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/families/7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "family not found")
|
||||
})
|
||||
|
||||
t.Run("no content", func(t *testing.T) {
|
||||
called := false
|
||||
r := setupFamiliesRouter(&familyServiceMock{deleteFn: func(ctx context.Context, id int64) error {
|
||||
called = true
|
||||
assert.Equal(t, int64(7), id)
|
||||
return nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/families/7", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, w.Code)
|
||||
assert.True(t, called)
|
||||
assert.Empty(t, strings.TrimSpace(w.Body.String()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFamiliesRouter_Create_ResponseShape(t *testing.T) {
|
||||
expected := sampleFamily()
|
||||
r := setupFamiliesRouter(&familyServiceMock{createFn: func(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
|
||||
return expected, nil
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/families", bytes.NewBufferString(`{"name":"Belan","owner_id":10,"telegram_chat_id":12345,"telegram_chat_name":"Family Chat"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp domain.FamilyResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected.ID, resp.ID)
|
||||
assert.Equal(t, expected.Name, resp.Name)
|
||||
assert.Equal(t, expected.OwnerID, resp.OwnerID)
|
||||
assert.Equal(t, expected.TelegramChatID, resp.TelegramChatID)
|
||||
assert.Equal(t, expected.TelegramChatName, resp.TelegramChatName)
|
||||
assert.Equal(t, expected.CreatedAt.Format(time.RFC3339), resp.CreatedAt)
|
||||
assert.Equal(t, expected.UpdatedAt.Format(time.RFC3339), resp.UpdatedAt)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/dto"
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type receiptService interface {
|
||||
GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error)
|
||||
}
|
||||
|
||||
type ReceiptRouter struct {
|
||||
service receiptService
|
||||
}
|
||||
|
||||
func NewReceiptRouter(s receiptService) *ReceiptRouter {
|
||||
return &ReceiptRouter{service: s}
|
||||
}
|
||||
func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
receipts := r.Group("/receipts")
|
||||
{
|
||||
receipts.POST("", router.AddReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) {
|
||||
var req domain.AddReceiptRequest
|
||||
if err := context_.ShouldBindJSON(&req); err != nil {
|
||||
log.Println("bind error:", err)
|
||||
context_.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
isoDate, err := utils.NormalizeDateToISO(req.Date)
|
||||
if err != nil {
|
||||
context_.JSON(400, gin.H{"error": "invalid date format"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
receipt, err := router.service.GetReceipt(ctx, isoDate, req.Number)
|
||||
if err != nil {
|
||||
context_.JSON(400, gin.H{"error": err.Error()})
|
||||
log.Printf("API error, %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := domain.AddReceiptResponse{
|
||||
ID: 1,
|
||||
Number: receipt.ReceiptNumber,
|
||||
Date: receipt.IssuedAt,
|
||||
}
|
||||
context_.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type receiptServiceMock struct {
|
||||
getReceiptFn func(ctx context.Context, date string, number string) (*domain.Receipt, error)
|
||||
}
|
||||
|
||||
func (m *receiptServiceMock) GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) {
|
||||
if m.getReceiptFn != nil {
|
||||
return m.getReceiptFn(ctx, date, number)
|
||||
}
|
||||
return nil, errors.New("mock is not configured")
|
||||
}
|
||||
|
||||
func TestReceiptRouter_AddReceipt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
validNumber := strings.Repeat("1", 24)
|
||||
validDate := "21.01.2026"
|
||||
expectedDate := "2026-01-21"
|
||||
now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
mock *receiptServiceMock
|
||||
expectedStatus int
|
||||
expectedContains string
|
||||
}{
|
||||
{
|
||||
name: "bad request on invalid body",
|
||||
body: `{"date":"21.01.2026"}`,
|
||||
mock: &receiptServiceMock{},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedContains: "Number",
|
||||
},
|
||||
{
|
||||
name: "bad request on invalid date format",
|
||||
body: `{"number":"` + validNumber + `","date":"2026-01-21"}`,
|
||||
mock: &receiptServiceMock{},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedContains: "invalid date format",
|
||||
},
|
||||
{
|
||||
name: "bad request on service error",
|
||||
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
|
||||
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
|
||||
assert.Equal(t, expectedDate, date)
|
||||
assert.Equal(t, validNumber, number)
|
||||
return nil, errors.New("receipt not found")
|
||||
}},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedContains: "receipt not found",
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
body: `{"number":"` + validNumber + `","date":"` + validDate + `"}`,
|
||||
mock: &receiptServiceMock{getReceiptFn: func(ctx context.Context, date string, number string) (*domain.Receipt, error) {
|
||||
assert.Equal(t, expectedDate, date)
|
||||
assert.Equal(t, validNumber, number)
|
||||
return &domain.Receipt{ReceiptNumber: validNumber, IssuedAt: now}, nil
|
||||
}},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedContains: validNumber,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
router := NewReceiptRouter(tc.mock)
|
||||
router.RegisterRoutes(apiV1)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, tc.expectedStatus, w.Code)
|
||||
assert.Contains(t, w.Body.String(), tc.expectedContains)
|
||||
|
||||
if tc.expectedStatus == http.StatusOK {
|
||||
var resp struct {
|
||||
ID int32 `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(1), resp.ID)
|
||||
assert.Equal(t, validNumber, resp.Number)
|
||||
assert.Equal(t, now, resp.Date)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UsersRouter struct {
|
||||
service services.UserService
|
||||
}
|
||||
|
||||
func NewUsersRouter(s services.UserService) *UsersRouter {
|
||||
return &UsersRouter{service: s}
|
||||
}
|
||||
|
||||
func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) {
|
||||
users := r.Group("/users")
|
||||
{
|
||||
users.POST("", router.CreateUser)
|
||||
users.GET("/:id", router.GetByID)
|
||||
users.GET("/by-telegram/:telegramId", router.GetByTelegramID)
|
||||
users.PATCH("/:id", router.Update)
|
||||
users.DELETE("/:id", router.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser GoDoc
|
||||
// @Summary Создать пользователя
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body domain.CreateUserRequest true "User info"
|
||||
// @Success 201 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse
|
||||
// @Failure 500 {object} domain.UserErrorResponse
|
||||
// @Router /users [post]
|
||||
func (router *UsersRouter) CreateUser(c *gin.Context) {
|
||||
var req domain.CreateUserRequest
|
||||
var resp domain.UserResponse
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := router.service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp.ModelToResponse(user))
|
||||
}
|
||||
|
||||
// GetByID GoDoc
|
||||
// @Summary Получить пользователя по ID
|
||||
// @Description Возвращает пользователя по его внутреннему ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [get]
|
||||
func (router *UsersRouter) GetByID(c *gin.Context) {
|
||||
var resp domain.UserResponse
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := router.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp.ModelToResponse(user))
|
||||
}
|
||||
|
||||
// GetByTelegramID GoDoc
|
||||
// @Summary Получить пользователя по Telegram ID
|
||||
// @Description Возвращает пользователя по его Telegram ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param telegramId path int true "Telegram ID"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid telegram id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/by-telegram/{telegramId} [get]
|
||||
func (router *UsersRouter) GetByTelegramID(c *gin.Context) {
|
||||
var resp domain.UserResponse
|
||||
telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid telegram id"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := router.service.GetByTelegramID(c.Request.Context(), telegramID)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp.ModelToResponse(user))
|
||||
}
|
||||
|
||||
// Update GoDoc
|
||||
// @Summary Обновить пользователя
|
||||
// @Description Частично обновляет данные пользователя по ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Param user body domain.UpdateUserRequest true "Данные для обновления"
|
||||
// @Success 200 {object} domain.UserResponse
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id or invalid body"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [patch]
|
||||
func (router *UsersRouter) Update(c *gin.Context) {
|
||||
var resp domain.UserResponse
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req domain.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := router.service.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp.ModelToResponse(user))
|
||||
}
|
||||
|
||||
// Delete GoDoc
|
||||
// @Summary Удалить пользователя
|
||||
// @Description Удаляет пользователя по его ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {object} domain.UserErrorResponse "invalid id"
|
||||
// @Failure 404 {object} domain.UserErrorResponse "user not found"
|
||||
// @Failure 500 {object} domain.UserErrorResponse "internal server error"
|
||||
// @Router /users/{id} [delete]
|
||||
func (router *UsersRouter) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := router.service.Delete(c.Request.Context(), id); err != nil {
|
||||
handleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUserNotFound):
|
||||
c.JSON(http.StatusNotFound, domain.UserErrorResponse{Error: err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidPatch):
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||
case errors.Is(err, services.ErrTelegramIDMissing):
|
||||
c.JSON(http.StatusBadRequest, domain.UserErrorResponse{Error: err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, domain.UserErrorResponse{Error: "internal server error"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type userServiceMock struct {
|
||||
createFn func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
|
||||
getByIDFn func(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
updateFn func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error)
|
||||
deleteFn func(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
func (m *userServiceMock) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
if m.createFn != nil {
|
||||
return m.createFn(ctx, req)
|
||||
}
|
||||
return nil, errors.New("mock create is not configured")
|
||||
}
|
||||
|
||||
func (m *userServiceMock) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
if m.getByIDFn != nil {
|
||||
return m.getByIDFn(ctx, id)
|
||||
}
|
||||
return nil, errors.New("mock getByID is not configured")
|
||||
}
|
||||
|
||||
func (m *userServiceMock) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
|
||||
if m.getByTelegramIDFn != nil {
|
||||
return m.getByTelegramIDFn(ctx, telegramID)
|
||||
}
|
||||
return nil, errors.New("mock getByTelegramID is not configured")
|
||||
}
|
||||
|
||||
func (m *userServiceMock) Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
|
||||
if m.updateFn != nil {
|
||||
return m.updateFn(ctx, id, req)
|
||||
}
|
||||
return nil, errors.New("mock update is not configured")
|
||||
}
|
||||
|
||||
func (m *userServiceMock) Delete(ctx context.Context, id int64) error {
|
||||
if m.deleteFn != nil {
|
||||
return m.deleteFn(ctx, id)
|
||||
}
|
||||
return errors.New("mock delete is not configured")
|
||||
}
|
||||
|
||||
func setupUsersRouter(mock services.UserService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
router := NewUsersRouter(mock)
|
||||
router.RegisterRoutes(apiV1)
|
||||
return r
|
||||
}
|
||||
|
||||
func sampleUser() *domain.UserModel {
|
||||
username := "john"
|
||||
firstName := "John"
|
||||
lastName := "Doe"
|
||||
languageCode := "en"
|
||||
|
||||
return &domain.UserModel{
|
||||
ID: 10,
|
||||
TelegramID: 100500,
|
||||
Username: &username,
|
||||
FirstName: &firstName,
|
||||
LastName: &lastName,
|
||||
LanguageCode: &languageCode,
|
||||
CreatedAt: time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, time.January, 21, 12, 11, 12, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersRouter_CreateUser(t *testing.T) {
|
||||
t.Run("bad request on malformed body", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "error")
|
||||
})
|
||||
|
||||
t.Run("bad request on domain validation error", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
return nil, services.ErrTelegramIDMissing
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":1,"first_name":"A"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrTelegramIDMissing.Error())
|
||||
})
|
||||
|
||||
t.Run("created", func(t *testing.T) {
|
||||
expected := sampleUser()
|
||||
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
assert.Equal(t, int64(100500), req.TelegramID)
|
||||
require.NotNil(t, req.FirstName)
|
||||
assert.Equal(t, "John", *req.FirstName)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":100500,"first_name":"John"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "100500")
|
||||
assert.Contains(t, w.Body.String(), "John")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersRouter_GetByID(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
return nil, services.ErrUserNotFound
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrUserNotFound.Error())
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := sampleUser()
|
||||
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
assert.Equal(t, int64(10), id)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "100500")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersRouter_GetByTelegramID(t *testing.T) {
|
||||
t.Run("bad request on invalid telegram id", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-telegram/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid telegram id")
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := sampleUser()
|
||||
r := setupUsersRouter(&userServiceMock{getByTelegramIDFn: func(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
|
||||
assert.Equal(t, int64(100500), telegramID)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-telegram/100500", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "John")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersRouter_Update(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/abc", bytes.NewBufferString(`{"first_name":"John"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("bad request on malformed body", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/10", bytes.NewBufferString(`{"first_name":`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "error")
|
||||
})
|
||||
|
||||
t.Run("bad request on invalid patch", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
|
||||
return nil, services.ErrInvalidPatch
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/10", bytes.NewBufferString(`{"first_name":"John"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrInvalidPatch.Error())
|
||||
})
|
||||
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := sampleUser()
|
||||
r := setupUsersRouter(&userServiceMock{updateFn: func(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
|
||||
assert.Equal(t, int64(10), id)
|
||||
require.NotNil(t, req.FirstName)
|
||||
assert.Equal(t, "John", *req.FirstName)
|
||||
return expected, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/10", bytes.NewBufferString(`{"first_name":"John"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "100500")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersRouter_Delete(t *testing.T) {
|
||||
t.Run("bad request on invalid id", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/abc", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid id")
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{deleteFn: func(ctx context.Context, id int64) error {
|
||||
return services.ErrUserNotFound
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), services.ErrUserNotFound.Error())
|
||||
})
|
||||
|
||||
t.Run("no content", func(t *testing.T) {
|
||||
called := false
|
||||
r := setupUsersRouter(&userServiceMock{deleteFn: func(ctx context.Context, id int64) error {
|
||||
called = true
|
||||
assert.Equal(t, int64(10), id)
|
||||
return nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, w.Code)
|
||||
assert.True(t, called)
|
||||
assert.Empty(t, strings.TrimSpace(w.Body.String()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsersRouter_CreateUser_ResponseShape(t *testing.T) {
|
||||
expected := sampleUser()
|
||||
r := setupUsersRouter(&userServiceMock{createFn: func(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
return expected, nil
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewBufferString(`{"telegram_id":100500,"first_name":"John"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp domain.UserResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected.ID, resp.ID)
|
||||
assert.Equal(t, expected.TelegramID, resp.TelegramID)
|
||||
assert.Equal(t, expected.FirstName, resp.FirstName)
|
||||
assert.Equal(t, expected.CreatedAt.Format(time.RFC3339), resp.CreatedAt)
|
||||
assert.Equal(t, expected.UpdatedAt.Format(time.RFC3339), resp.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestUsersRouter_GetByID_UsesPathID(t *testing.T) {
|
||||
r := setupUsersRouter(&userServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
assert.Equal(t, int64(42), id)
|
||||
u := sampleUser()
|
||||
u.ID = id
|
||||
return u, nil
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/42", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), strconv.FormatInt(42, 10))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "FamilyHub/src/api/docs"
|
||||
"FamilyHub/src/api/routers"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/database"
|
||||
"FamilyHub/src/integrations/receiptService"
|
||||
"FamilyHub/src/repositories"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config) *Server {
|
||||
dbManager := &database.Database{
|
||||
ConnectionString: cfg.DBConnectionString,
|
||||
MigrationsPath: "file://migrations",
|
||||
}
|
||||
dbConn, err := dbManager.Connect()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := dbManager.RunMigrations(dbConn); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
if cfg.OpenAPIEnabled {
|
||||
router.GET("/openapi/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
}
|
||||
|
||||
apiV1 := router.Group("/api/v1")
|
||||
|
||||
receiptRepo := repositories.NewReceiptsSQLRepository(dbConn)
|
||||
receiptService_ := receiptService.NewReceiptService(receiptRepo)
|
||||
receiptRouter := routers.NewReceiptRouter(receiptService_)
|
||||
receiptRouter.RegisterRoutes(apiV1)
|
||||
|
||||
usersRepo := repositories.NewUsersSQLRepository(dbConn)
|
||||
usersService := services.NewUserService(usersRepo)
|
||||
usersRouter := routers.NewUsersRouter(usersService)
|
||||
usersRouter.RegisterRoutes(apiV1)
|
||||
|
||||
familyRepo := repositories.NewFamilySQLRepository(dbConn)
|
||||
familyService := services.NewFamilyService(familyRepo)
|
||||
familyRouter := routers.NewFamiliesRouter(familyService)
|
||||
familyRouter.RegisterRoutes(apiV1)
|
||||
|
||||
otpRepo := repositories.NewOTPSQLRepository(dbConn)
|
||||
authService := services.NewAuthService(usersRepo, otpRepo)
|
||||
authRouter := routers.NewAuthRouter(authService)
|
||||
authRouter.RegisterRouter(apiV1)
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||
Handler: router,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
AuthByTelegram(ctx context.Context, initData string) (string, error)
|
||||
CreateOTP(ctx context.Context, telegramId int64) error
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
usersRepo repositories.UsersRepository
|
||||
otpRepo repositories.OTPRepository
|
||||
config config.Config
|
||||
jwt *utils.JWTManager
|
||||
}
|
||||
|
||||
func NewAuthService(usersRepo repositories.UsersRepository, otpRepo repositories.OTPRepository) AuthService {
|
||||
return &authService{
|
||||
usersRepo: usersRepo,
|
||||
otpRepo: otpRepo,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrWrongOtp = errors.New("wrong otp")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
func (s *authService) AuthByTelegram(ctx context.Context, initData string) (string, error) {
|
||||
data, ok := ValidateTelegramInitData(initData, s.config.BotToken)
|
||||
if !ok {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(data["user"]), &user)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userModel, err := s.usersRepo.GetByTelegramID(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.jwt.Generate(userModel.ID)
|
||||
}
|
||||
|
||||
func (s *authService) CreateOTP(ctx context.Context, telegramId int64) error {
|
||||
user, err := s.usersRepo.GetByTelegramID(ctx, telegramId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
b := make([]byte, 3)
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := fmt.Sprintf("%06d", (int(b[0])<<16|int(b[1])<<8|int(b[2]))%1000000)
|
||||
otp := &domain.OTP{
|
||||
UserID: user.ID,
|
||||
Code: code,
|
||||
ExpiredAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
err = s.otpRepo.Create(ctx, otp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTelegramInitData(initData string, botToken string) (map[string]string, bool) {
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
hash := values.Get("hash")
|
||||
values.Del("hash")
|
||||
|
||||
var dataCheck []string
|
||||
for k, v := range values {
|
||||
dataCheck = append(dataCheck, k+"="+v[0])
|
||||
}
|
||||
|
||||
sort.Strings(dataCheck)
|
||||
dataCheckString := strings.Join(dataCheck, "\n")
|
||||
|
||||
secret := sha256.Sum256([]byte(botToken))
|
||||
h := hmac.New(sha256.New, secret[:])
|
||||
h.Write([]byte(dataCheckString))
|
||||
|
||||
expectedHash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
return mapFromValues(values), expectedHash == hash
|
||||
}
|
||||
|
||||
func mapFromValues(v url.Values) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for k, val := range v {
|
||||
m[k] = val[0]
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type FamilyService interface {
|
||||
Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error)
|
||||
GetByID(ctx context.Context, id int64) (*domain.Family, error)
|
||||
Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type familyService struct {
|
||||
repo repositories.FamilyRepository
|
||||
}
|
||||
|
||||
func NewFamilyService(repo repositories.FamilyRepository) FamilyService {
|
||||
return &familyService{repo: repo}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrFamilyNotFound = errors.New("family not found")
|
||||
)
|
||||
|
||||
func (s *familyService) Create(ctx context.Context, req domain.CreateFamilyRequest) (*domain.Family, error) {
|
||||
family_ := &domain.Family{
|
||||
Name: req.Name,
|
||||
OwnerID: req.OwnerID,
|
||||
TelegramChatID: req.TelegramChatID,
|
||||
TelegramChatName: req.TelegramChatName,
|
||||
}
|
||||
if err := s.repo.Create(ctx, family_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return family_, nil
|
||||
}
|
||||
func (s *familyService) GetByID(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
family_, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if family_ == nil {
|
||||
return nil, ErrFamilyNotFound
|
||||
}
|
||||
return family_, nil
|
||||
}
|
||||
func (s *familyService) Update(ctx context.Context, id int64, req domain.UpdateFamilyRequest) (*domain.Family, error) {
|
||||
existing, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, ErrFamilyNotFound
|
||||
}
|
||||
if err := s.repo.Update(ctx, &domain.Family{
|
||||
ID: id,
|
||||
Name: *req.Name,
|
||||
OwnerID: existing.OwnerID,
|
||||
TelegramChatID: existing.TelegramChatID,
|
||||
TelegramChatName: req.TelegramChatName,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
func (s *familyService) Delete(ctx context.Context, id int64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error)
|
||||
GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
repo repositories.UsersRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo repositories.UsersRepository) UserService {
|
||||
return &userService{repo: repo}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidPatch = errors.New("empty update payload")
|
||||
ErrTelegramIDMissing = errors.New("telegram_id is required")
|
||||
)
|
||||
|
||||
func (s *userService) Create(ctx context.Context, req domain.CreateUserRequest) (*domain.UserModel, error) {
|
||||
user_ := &domain.UserModel{
|
||||
TelegramID: req.TelegramID,
|
||||
Username: req.Username,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
LanguageCode: req.LanguageCode,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, user_); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user_, nil
|
||||
}
|
||||
func (s *userService) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
user, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
func (s *userService) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
|
||||
user, err := s.repo.GetByTelegramID(ctx, telegramID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
func (s *userService) Update(ctx context.Context, id int64, req domain.UpdateUserRequest) (*domain.UserModel, error) {
|
||||
existing, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if req.Username == nil &&
|
||||
req.FirstName == nil &&
|
||||
req.LastName == nil &&
|
||||
req.LanguageCode == nil {
|
||||
return nil, ErrInvalidPatch
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, &domain.UserModel{
|
||||
ID: id,
|
||||
Username: req.Username,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
LanguageCode: req.LanguageCode,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
func (s *userService) Delete(ctx context.Context, id int64) error {
|
||||
user, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"FamilyHub/src/bot/handlers"
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/integrations/familyHub"
|
||||
"FamilyHub/src/integrations/ocr"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
api *tgbotapi.BotAPI
|
||||
ocr ocr.OCR
|
||||
router *Router
|
||||
}
|
||||
|
||||
func NewBot(cfg config.Config) (*Bot, error) {
|
||||
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create telegram bot api: %w", err)
|
||||
}
|
||||
api.Debug = cfg.DebugMode
|
||||
|
||||
ctx := context.Background()
|
||||
ocrSvc, err := ocr.NewGoogleOCR(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create google ocr client: %w", err)
|
||||
}
|
||||
|
||||
apiHost := strings.TrimSpace(cfg.APIHost)
|
||||
if apiHost == "" {
|
||||
apiHost = "localhost"
|
||||
}
|
||||
apiPort := strings.TrimSpace(cfg.APIPort)
|
||||
if apiPort == "" {
|
||||
apiPort = "8000"
|
||||
}
|
||||
|
||||
receiptAPI, err := familyHub.NewApiClient(cfg)
|
||||
if err != nil {
|
||||
_ = ocrSvc.Close()
|
||||
return nil, fmt.Errorf("create family hub api client: %w", err)
|
||||
}
|
||||
handler := handlers.New(api, ocrSvc, receiptAPI)
|
||||
|
||||
return &Bot{api: api, ocr: ocrSvc, router: NewRouter(handler)}, nil
|
||||
}
|
||||
|
||||
func (bot *Bot) Start(ctx context.Context) error {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 1
|
||||
updates := bot.api.GetUpdatesChan(u)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Telegram bot stopping...")
|
||||
bot.api.StopReceivingUpdates()
|
||||
if bot.ocr != nil {
|
||||
_ = bot.ocr.Close()
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bot.router.Handle(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandleCreateFamily(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Chat == nil || msg.Chat.Type != "supergroup" {
|
||||
h.reply(msg.Chat.ID, "Для создания семьи переведи бота в супергруппу и запусти /createFamily там")
|
||||
return
|
||||
}
|
||||
|
||||
h.setFamilyState(msg.From.ID, familyCreationState{AwaitingName: true, ChatID: msg.Chat.ID})
|
||||
h.reply(msg.Chat.ID, "Введи имя семьи одним сообщением")
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreateFamilyName(msg *tgbotapi.Message) {
|
||||
if msg.From == nil || msg.Chat == nil {
|
||||
return
|
||||
}
|
||||
|
||||
familyName := strings.TrimSpace(msg.Text)
|
||||
if familyName == "" {
|
||||
h.reply(msg.Chat.ID, "Имя семьи не может быть пустым. Введи имя еще раз")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := h.receiptApi.GetUserByTelegramID(ctx, msg.From.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUserNotFound) {
|
||||
h.reply(msg.Chat.ID, "Сначала зарегистрируйся: /register")
|
||||
return
|
||||
}
|
||||
log.Printf("failed to get user by telegram id: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось получить пользователя приложения")
|
||||
return
|
||||
}
|
||||
|
||||
promoteCfg := tgbotapi.PromoteChatMemberConfig{
|
||||
ChatMemberConfig: tgbotapi.ChatMemberConfig{
|
||||
ChatID: msg.Chat.ID,
|
||||
UserID: msg.From.ID,
|
||||
},
|
||||
CanManageChat: true,
|
||||
CanChangeInfo: true,
|
||||
CanDeleteMessages: true,
|
||||
CanManageVoiceChats: true,
|
||||
CanInviteUsers: true,
|
||||
CanRestrictMembers: true,
|
||||
CanPinMessages: true,
|
||||
}
|
||||
if _, err := h.api.Request(promoteCfg); err != nil {
|
||||
log.Printf("failed to promote user to admin: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось назначить тебя администратором. Проверь права бота")
|
||||
return
|
||||
}
|
||||
|
||||
chatName := msg.Chat.Title
|
||||
if strings.TrimSpace(chatName) == "" {
|
||||
chatName = familyName
|
||||
}
|
||||
|
||||
err = h.receiptApi.CreateFamily(ctx, domain.CreateFamilyRequest{
|
||||
Name: familyName,
|
||||
OwnerID: user.ID,
|
||||
TelegramChatID: msg.Chat.ID,
|
||||
TelegramChatName: chatName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to create family in api: %v", err)
|
||||
h.reply(msg.Chat.ID, fmt.Sprintf("Не удалось создать семью в API: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.clearFamilyState(msg.From.ID)
|
||||
h.reply(msg.Chat.ID, "Семья создана успешно")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
api "FamilyHub/src/integrations/familyHub"
|
||||
"FamilyHub/src/integrations/ocr"
|
||||
"sync"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type registrationState struct {
|
||||
AgreementOffered bool
|
||||
AwaitingApproval bool
|
||||
}
|
||||
|
||||
type familyCreationState struct {
|
||||
AwaitingName bool
|
||||
ChatID int64
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
api *tgbotapi.BotAPI
|
||||
ocr ocr.OCR
|
||||
receiptApi api.ApiClient
|
||||
|
||||
registrationMu sync.Mutex
|
||||
registrationState map[int64]registrationState
|
||||
|
||||
familyMu sync.Mutex
|
||||
familyState map[int64]familyCreationState
|
||||
}
|
||||
|
||||
func New(api *tgbotapi.BotAPI, ocrSvc ocr.OCR, receiptClient api.ApiClient) *Handler {
|
||||
return &Handler{
|
||||
api: api,
|
||||
ocr: ocrSvc,
|
||||
receiptApi: receiptClient,
|
||||
registrationState: map[int64]registrationState{},
|
||||
familyState: map[int64]familyCreationState{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
func (h *Handler) HandleHelp(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, "Доступные команды:\n/start\n/register\n/termsOfService\n/getAgreement\n/createFamily\n/help")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/utils"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandlePhoto(msg *tgbotapi.Message) {
|
||||
photo := msg.Photo[len(msg.Photo)-1]
|
||||
|
||||
file, err := h.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Не смог получить файл 😢")
|
||||
return
|
||||
}
|
||||
|
||||
url := file.Link(h.api.Token)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка загрузки изображения")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка чтения изображения")
|
||||
return
|
||||
}
|
||||
|
||||
text, err := h.ocr.Recognize(context.Background(), imageBytes)
|
||||
if err != nil {
|
||||
h.reply(msg.Chat.ID, "Ошибка OCR 😢")
|
||||
return
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
h.reply(msg.Chat.ID, "Текст не найден")
|
||||
return
|
||||
}
|
||||
|
||||
receiptMeta := utils.ExtractReceiptMeta(text)
|
||||
payload := domain.AddReceiptRequest{Number: receiptMeta.ReceiptID, Date: receiptMeta.Date}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
txt, err := utils.DecodeQR(imageBytes)
|
||||
println(txt)
|
||||
|
||||
err = h.receiptApi.SendReceipt(ctx, payload)
|
||||
|
||||
reply := "📄 *Результат распознавания*\n\n"
|
||||
if receiptMeta.Date != "" {
|
||||
reply += "📅 Дата: " + receiptMeta.Date + "\n"
|
||||
} else {
|
||||
reply += "📅 Дата: не найдена\n"
|
||||
}
|
||||
if receiptMeta.ReceiptID != "" {
|
||||
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
|
||||
} else {
|
||||
reply += "🧾 Номер чека: не найден\n"
|
||||
}
|
||||
if err != nil {
|
||||
reply += "Не удалось отправить чек в API " + err.Error()
|
||||
} else {
|
||||
reply += "Чек добавлен в базу"
|
||||
}
|
||||
|
||||
h.replyMarkdown(msg.Chat.ID, reply)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const agreementConfirmationText = "Я принимаю условия"
|
||||
|
||||
const termsOfServiceText = "Лицензионное соглашение:\n" +
|
||||
"1. Вы подтверждаете согласие на обработку данных.\n" +
|
||||
"2. Вы соглашаетесь с правилами использования FamilyHUB."
|
||||
|
||||
func (h *Handler) HandleRegister(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
registered, err := h.receiptApi.IsUserRegistered(ctx, msg.From.ID)
|
||||
if err != nil {
|
||||
log.Printf("failed to check registration: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось проверить регистрацию. Попробуйте позже.")
|
||||
return
|
||||
}
|
||||
|
||||
if registered {
|
||||
h.reply(msg.Chat.ID, "Ты уже зарегистрирован. Доступно: /createFamily, /help, /info")
|
||||
return
|
||||
}
|
||||
|
||||
h.setRegistrationState(msg.From.ID, registrationState{AgreementOffered: true})
|
||||
h.reply(msg.Chat.ID, termsOfServiceText+"\n\nЕсли согласен, нажми /getAgreement")
|
||||
}
|
||||
func (h *Handler) HandleAgreementConfirmation(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(msg.Text), agreementConfirmationText) {
|
||||
h.reply(msg.Chat.ID, "Фраза не совпадает. Введи точно: \"Я принимаю условия\"")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.receiptApi.RegisterUser(ctx, domain.CreateUserRequest{
|
||||
TelegramID: msg.From.ID,
|
||||
Username: stringPtrOrNil(msg.From.UserName),
|
||||
FirstName: stringPtrOrNil(msg.From.FirstName),
|
||||
LastName: stringPtrOrNil(msg.From.LastName),
|
||||
LanguageCode: stringPtrOrNil(msg.From.LanguageCode),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("failed to register user: %v", err)
|
||||
h.reply(msg.Chat.ID, "Не удалось завершить регистрацию. Попробуй позже.")
|
||||
return
|
||||
}
|
||||
|
||||
h.clearRegistrationState(msg.From.ID)
|
||||
h.reply(msg.Chat.ID, "Регистрация завершена. Доступно: /createFamily, /help, /info")
|
||||
}
|
||||
func (h *Handler) HandleGetAgreement(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
h.reply(msg.Chat.ID, "Не удалось определить пользователя Telegram")
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := h.getRegistrationState(msg.From.ID)
|
||||
if !ok || !state.AgreementOffered {
|
||||
h.reply(msg.Chat.ID, "Сначала запусти /register")
|
||||
return
|
||||
}
|
||||
|
||||
state.AwaitingApproval = true
|
||||
h.setRegistrationState(msg.From.ID, state)
|
||||
h.reply(msg.Chat.ID, "Введи фразу для подтверждения: \"Я принимаю условия\"")
|
||||
}
|
||||
func (h *Handler) HandleTermsOfService(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, termsOfServiceText)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) reply(chat int64, text string) {
|
||||
m := tgbotapi.NewMessage(chat, text)
|
||||
_, err := h.api.Send(m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) replyMarkdown(chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
h.api.Send(msg)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
func (h *Handler) HandleStart(msg *tgbotapi.Message) {
|
||||
h.reply(msg.Chat.ID, "Привет! Я FamilyHUB-бот. Доступно: /register, /termsOfService, /help")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
func (h *Handler) setRegistrationState(userID int64, state registrationState) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
h.registrationState[userID] = state
|
||||
}
|
||||
|
||||
func (h *Handler) getRegistrationState(userID int64) (registrationState, bool) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
state, ok := h.registrationState[userID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (h *Handler) clearRegistrationState(userID int64) {
|
||||
h.registrationMu.Lock()
|
||||
defer h.registrationMu.Unlock()
|
||||
delete(h.registrationState, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) isAwaitingAgreement(userID int64) bool {
|
||||
state, ok := h.getRegistrationState(userID)
|
||||
return ok && state.AwaitingApproval
|
||||
}
|
||||
|
||||
func (h *Handler) setFamilyState(userID int64, state familyCreationState) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
h.familyState[userID] = state
|
||||
}
|
||||
|
||||
func (h *Handler) getFamilyState(userID int64) (familyCreationState, bool) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
state, ok := h.familyState[userID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (h *Handler) clearFamilyState(userID int64) {
|
||||
h.familyMu.Lock()
|
||||
defer h.familyMu.Unlock()
|
||||
delete(h.familyState, userID)
|
||||
}
|
||||
|
||||
func (h *Handler) isAwaitingFamilyName(userID, chatID int64) bool {
|
||||
state, ok := h.getFamilyState(userID)
|
||||
return ok && state.AwaitingName && state.ChatID == chatID
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) HandleUnknown(msg *tgbotapi.Message) {
|
||||
if msg.From == nil {
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(msg.Text)
|
||||
if text == "" || strings.HasPrefix(text, "/") {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isAwaitingAgreement(msg.From.ID) {
|
||||
h.HandleAgreementConfirmation(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Chat != nil && h.isAwaitingFamilyName(msg.From.ID, msg.Chat.ID) {
|
||||
h.handleCreateFamilyName(msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package handlers
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"FamilyHub/src/bot/handlers"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
handler *handlers.Handler
|
||||
}
|
||||
|
||||
func NewRouter(handler *handlers.Handler) *Router {
|
||||
return &Router{handler: handler}
|
||||
}
|
||||
|
||||
func (r *Router) Handle(update tgbotapi.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case update.Message.Photo != nil:
|
||||
r.handler.HandlePhoto(update.Message)
|
||||
case update.Message.Text == "/start":
|
||||
r.handler.HandleStart(update.Message)
|
||||
case update.Message.Text == "/register":
|
||||
r.handler.HandleRegister(update.Message)
|
||||
case update.Message.Text == "/termsOfService":
|
||||
r.handler.HandleTermsOfService(update.Message)
|
||||
case update.Message.Text == "/getAgreement":
|
||||
r.handler.HandleGetAgreement(update.Message)
|
||||
case update.Message.Text == "/help":
|
||||
r.handler.HandleHelp(update.Message)
|
||||
case update.Message.Text == "/createFamily":
|
||||
r.handler.HandleCreateFamily(update.Message)
|
||||
default:
|
||||
r.handler.HandleUnknown(update.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RunMode RunMode
|
||||
DebugMode bool
|
||||
BotToken string
|
||||
|
||||
DBConnectionString string
|
||||
|
||||
OCRTokenPath string
|
||||
|
||||
TelegramApi string
|
||||
|
||||
APIPort string
|
||||
APIHost string
|
||||
APISecret string
|
||||
OpenAPIEnabled bool
|
||||
OpenAPIEndpoint string
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
_ = godotenv.Load()
|
||||
var warnings []string
|
||||
|
||||
mode := os.Getenv("RUN_MODE")
|
||||
debugMode := os.Getenv("DEBUG_MODE") == "true"
|
||||
botToken := os.Getenv("BOT_TOKEN")
|
||||
dbConnectionString := os.Getenv("DB_PATH")
|
||||
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
apiPort := os.Getenv("API_PORT")
|
||||
apiHost := os.Getenv("API_HOST")
|
||||
apiSecret := os.Getenv("API_SECRET")
|
||||
openAPIEnabled := os.Getenv("OPEN_API_ENABLED") == "true"
|
||||
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
|
||||
|
||||
runMode, err := ParseRunMode(mode)
|
||||
if err != nil {
|
||||
warnings = append(warnings, err.Error())
|
||||
}
|
||||
|
||||
if runMode == Bot || runMode == Standalone {
|
||||
if ocrTokenPath == "" {
|
||||
warnings = append(warnings, "Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
|
||||
}
|
||||
if botToken == "" {
|
||||
warnings = append(warnings, "Missing required environment variable: BOT_TOKEN")
|
||||
}
|
||||
}
|
||||
if runMode == API || runMode == Standalone {
|
||||
if apiSecret == "" {
|
||||
warnings = append(warnings, "Missing required environment variable: API_SECRET")
|
||||
}
|
||||
if dbConnectionString == "" {
|
||||
dbConnectionString = "sqlite://data/app.db"
|
||||
}
|
||||
if apiHost == "" {
|
||||
apiHost = "localhost"
|
||||
}
|
||||
if apiPort == "" {
|
||||
apiPort = "8000"
|
||||
}
|
||||
if openAPIEndpoint == "" {
|
||||
openAPIEndpoint = "/docs"
|
||||
}
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
return Config{}, errors.New(strings.Join(warnings, "\n"))
|
||||
}
|
||||
return Config{
|
||||
BotToken: botToken,
|
||||
DBConnectionString: dbConnectionString,
|
||||
OCRTokenPath: ocrTokenPath,
|
||||
DebugMode: debugMode,
|
||||
RunMode: runMode,
|
||||
APIPort: apiPort,
|
||||
APIHost: apiHost,
|
||||
APISecret: apiSecret,
|
||||
OpenAPIEnabled: openAPIEnabled,
|
||||
OpenAPIEndpoint: openAPIEndpoint,
|
||||
TelegramApi: "https://api.telegram.org",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type EnvFixture struct {
|
||||
backup map[string]string
|
||||
}
|
||||
|
||||
func NewEnvFixture() *EnvFixture {
|
||||
env := make(map[string]string)
|
||||
|
||||
for _, e := range os.Environ() {
|
||||
pair := split(e)
|
||||
env[pair[0]] = pair[1]
|
||||
}
|
||||
|
||||
// полностью чистое окружение
|
||||
os.Clearenv()
|
||||
return &EnvFixture{backup: env}
|
||||
}
|
||||
|
||||
func (e *EnvFixture) Restore() {
|
||||
os.Clearenv()
|
||||
for key, val := range e.backup {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
func split(s string) [2]string {
|
||||
var p [2]string
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '=' {
|
||||
p[0] = s[:i]
|
||||
p[1] = s[i+1:]
|
||||
return p
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func MustSet(env map[string]string) {
|
||||
for k, v := range env {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func MustUnset(keys ...string) {
|
||||
for _, k := range keys {
|
||||
_ = os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoad_Table(t *testing.T) {
|
||||
env := NewEnvFixture()
|
||||
defer env.Restore()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want config.Config
|
||||
error bool
|
||||
}{
|
||||
{
|
||||
name: "ok - values set",
|
||||
env: map[string]string{"BOT_TOKEN": "abc", "DB_PATH": "db.sqlite"},
|
||||
want: config.Config{BotToken: "abc", DBConnectionString: "db.sqlite"},
|
||||
},
|
||||
{
|
||||
name: "fail - missing token",
|
||||
env: map[string]string{}, // ничего нет
|
||||
error: true,
|
||||
},
|
||||
{
|
||||
name: "default DB path applied",
|
||||
env: map[string]string{"BOT_TOKEN": "xyz"},
|
||||
error: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
testCase := tc
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
os.Clearenv()
|
||||
MustSet(testCase.env)
|
||||
|
||||
cfg, err := config.Load()
|
||||
|
||||
if testCase.error {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.want.BotToken, cfg.BotToken)
|
||||
assert.Equal(t, testCase.want.DBConnectionString, cfg.DBConnectionString)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RunMode string
|
||||
|
||||
const (
|
||||
Bot RunMode = "bot"
|
||||
API RunMode = "api"
|
||||
Standalone RunMode = "standalone"
|
||||
Unknown RunMode = "unknown"
|
||||
)
|
||||
|
||||
func ParseRunMode(s string) (RunMode, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "bot":
|
||||
return Bot, nil
|
||||
case "api":
|
||||
return API, nil
|
||||
case "standalone":
|
||||
return Standalone, nil
|
||||
default:
|
||||
return Unknown, fmt.Errorf("invalid run mode: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
ConnectionString string
|
||||
MigrationsPath string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
}
|
||||
|
||||
func resolveMigrationsPath(path string) (string, error) {
|
||||
if path == "" {
|
||||
path = "file://migrations"
|
||||
}
|
||||
|
||||
const filePrefix = "file://"
|
||||
if !strings.HasPrefix(path, filePrefix) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
relativePath := strings.TrimPrefix(path, filePrefix)
|
||||
candidates := []string{
|
||||
relativePath,
|
||||
filepath.Join("backend", relativePath),
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
absPath, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filePrefix + absPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("migrations path not found: %s", path)
|
||||
}
|
||||
|
||||
func (d *Database) Connect() (*sql.DB, error) {
|
||||
u, _ := url.Parse(d.ConnectionString)
|
||||
if u == nil {
|
||||
return nil, errors.New("nil url")
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "sqlite", "sqlite3":
|
||||
path := filepath.Join(u.Host, u.Path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("empty sqlite path")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d.MaxOpenConns == 0 {
|
||||
d.MaxOpenConns = 1
|
||||
}
|
||||
db.SetMaxOpenConns(d.MaxOpenConns)
|
||||
return db, nil
|
||||
|
||||
case "postgres", "postgresql":
|
||||
db, err := sql.Open("postgres", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if d.MaxOpenConns == 0 {
|
||||
d.MaxOpenConns = 20
|
||||
}
|
||||
if d.MaxIdleConns == 0 {
|
||||
d.MaxIdleConns = 10
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(d.MaxOpenConns)
|
||||
db.SetMaxIdleConns(d.MaxIdleConns)
|
||||
return db, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database scheme: %s", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) RunMigrations(db *sql.DB) error {
|
||||
u, _ := url.Parse(d.ConnectionString)
|
||||
if u == nil {
|
||||
return errors.New("nil url")
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
migrationsPath, err := resolveMigrationsPath(d.MigrationsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var m *migrate.Migrate
|
||||
|
||||
switch u.Scheme {
|
||||
case "sqlite", "sqlite3":
|
||||
driver, driverErr := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
if driverErr != nil {
|
||||
return driverErr
|
||||
}
|
||||
m, err = migrate.NewWithDatabaseInstance(migrationsPath, "sqlite", driver)
|
||||
|
||||
case "postgres", "postgresql":
|
||||
driver, driverErr := postgres.WithInstance(db, &postgres.Config{})
|
||||
if driverErr != nil {
|
||||
return driverErr
|
||||
}
|
||||
m, err = migrate.NewWithDatabaseInstance(migrationsPath, "postgres", driver)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported database scheme for migrations: %s", u.Scheme)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
type PostgresDB struct {
|
||||
MigrationsPath string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
}
|
||||
|
||||
// Connect открывает соединение с Postgres
|
||||
func (p *PostgresDB) Connect(u *url.URL) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.MaxOpenConns == 0 {
|
||||
p.MaxOpenConns = 20
|
||||
}
|
||||
if p.MaxIdleConns == 0 {
|
||||
p.MaxIdleConns = 10
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(p.MaxOpenConns)
|
||||
db.SetMaxIdleConns(p.MaxIdleConns)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// RunMigrations запускает миграции PostgreSQL
|
||||
func (p *PostgresDB) RunMigrations(db *sql.DB) error {
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create postgres driver: %w", err)
|
||||
}
|
||||
|
||||
if p.MigrationsPath == "" {
|
||||
p.MigrationsPath = "file://migrations"
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
p.MigrationsPath,
|
||||
"postgres",
|
||||
driver,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
type SQLiteDB struct {
|
||||
MigrationsPath string
|
||||
MaxOpenConns int
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Connect(u *url.URL) (*sql.DB, error) {
|
||||
path := filepath.Join(u.Host, u.Path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("empty sqlite path")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.MaxOpenConns == 0 {
|
||||
s.MaxOpenConns = 1
|
||||
}
|
||||
db.SetMaxOpenConns(s.MaxOpenConns)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) RunMigrations(db *sql.DB) error {
|
||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.MigrationsPath == "" {
|
||||
s.MigrationsPath = "file://migrations"
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(
|
||||
s.MigrationsPath,
|
||||
"sqlite",
|
||||
driver,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package domain
|
||||
|
||||
type AuthRequest struct {
|
||||
TelegramId *string `json:"telegram_id"`
|
||||
OTP *int64 `json:"otp"`
|
||||
InitData *string `json:"init_data"`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Family struct {
|
||||
ID int64
|
||||
Name string
|
||||
OwnerID int64
|
||||
TelegramChatID int64
|
||||
TelegramChatName string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FamilyRole string
|
||||
|
||||
const (
|
||||
FamilyRoleOwner FamilyRole = "owner"
|
||||
FamilyRoleAdmin FamilyRole = "admin"
|
||||
FamilyRoleMember FamilyRole = "member"
|
||||
FamilyRoleChild FamilyRole = "child"
|
||||
)
|
||||
|
||||
type FamilyMember struct {
|
||||
ID int64
|
||||
FamilyID int64
|
||||
UserID int64
|
||||
Role FamilyRole
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type FamilyThread struct {
|
||||
ID int64
|
||||
FamilyID int64
|
||||
Type string
|
||||
Title string
|
||||
TelegramTopicID int64
|
||||
IsSystem bool
|
||||
CreatedBy int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFamilyRequest struct {
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type UpdateFamilyRequest struct {
|
||||
Name *string `json:"name"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
}
|
||||
|
||||
type FamilyResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"owner_id"`
|
||||
TelegramChatID int64 `json:"telegram_chat_id"`
|
||||
TelegramChatName string `json:"telegram_chat_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (response *FamilyResponse) ModelToResponse(f *Family) FamilyResponse {
|
||||
return FamilyResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
OwnerID: f.OwnerID,
|
||||
TelegramChatID: f.TelegramChatID,
|
||||
TelegramChatName: f.TelegramChatName,
|
||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: f.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type OTP struct {
|
||||
UserID int64
|
||||
Code string
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Position struct {
|
||||
SectionNumber string `json:"section_number"`
|
||||
GTINCode string `json:"gtin_code"`
|
||||
Tag string `json:"tag"`
|
||||
MarkingCode string `json:"marking_code"`
|
||||
UKZCode string `json:"ukz_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
|
||||
ProductCountRaw string `json:"product_count"`
|
||||
ProductCount float64 `json:"-"`
|
||||
|
||||
AmountRaw string `json:"amount"`
|
||||
Amount float64 `json:"-"`
|
||||
|
||||
DiscountRaw string `json:"discount"`
|
||||
Discount float64 `json:"-"`
|
||||
|
||||
SurchargeRaw string `json:"surcharge"`
|
||||
Surcharge float64 `json:"-"`
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
ID int `json:"id"`
|
||||
Status int `json:"STATUS"`
|
||||
AnotherAmount float64 `json:"another_amount"`
|
||||
CashAmount float64 `json:"cash_amount"`
|
||||
CashboxNumber int64 `json:"cashbox_number"`
|
||||
Cashier string `json:"cashier"`
|
||||
ClearingAmount float64 `json:"clearing_amount"`
|
||||
Currency string `json:"currency"`
|
||||
DocNum string `json:"doc_num"`
|
||||
HouseTo string `json:"house_to"`
|
||||
KodSoato string `json:"kod_soato"`
|
||||
Margin float64 `json:"margin"`
|
||||
NameNP string `json:"name_np"`
|
||||
NameSPD string `json:"name_spd"`
|
||||
NameTO string `json:"name_to"`
|
||||
|
||||
OblastSoato *string `json:"oblast_soato"`
|
||||
RayonSoato *string `json:"rayon_soato"`
|
||||
SelsovetSoato *string `json:"selsovet_soato"`
|
||||
|
||||
PaymentAmount float64 `json:"payment_amount"`
|
||||
PaymentType int `json:"payment_type"`
|
||||
|
||||
ReceiptNumber string `json:"receipt_number"`
|
||||
SknoNumber string `json:"skno_number"`
|
||||
StreetTo string `json:"street_to"`
|
||||
Success string `json:"success"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TypeNP string `json:"type_np"`
|
||||
UI string `json:"ui"`
|
||||
UNP string `json:"unp"`
|
||||
|
||||
IssuedAtRaw string `json:"issued_at"`
|
||||
IssuedAt time.Time `json:"-"`
|
||||
|
||||
PositionsRaw string `json:"positions"`
|
||||
Positions []Position `json:"-"`
|
||||
}
|
||||
|
||||
type AddReceiptRequest struct {
|
||||
Number string `json:"number" binding:"required,min=24,max=24"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
}
|
||||
|
||||
type AddReceiptResponse struct {
|
||||
ID int32 `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserModel struct {
|
||||
ID int64
|
||||
TelegramID int64
|
||||
Username *string
|
||||
FirstName *string
|
||||
LastName *string
|
||||
LanguageCode *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
TelegramID int64 `json:"telegram_id" validate:"required"`
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name" validate:"required"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username *string `json:"username"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
LanguageCode *string `json:"language_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (response *UserResponse) ModelToResponse(u *UserModel) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
TelegramID: u.TelegramID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errUserNotFound = errors.New("user not found")
|
||||
|
||||
func NewApiClient(config config.Config) (*HTTPClient, error) {
|
||||
return &HTTPClient{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/receipts",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||
registered, err := c.IsUserRegistered(ctx, payload.TelegramID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registered {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.RegisterUser(ctx, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) IsUserRegistered(ctx context.Context, telegramID int64) (bool, error) {
|
||||
_, err := c.GetUserByTelegramID(ctx, telegramID)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, errUserNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error {
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/users",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/users/by-telegram/"+strconv.FormatInt(telegramID, 10),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, errUserNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var user domain.UserResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.config.APIHost+c.config.APIPort+"/api/v1/families",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("api error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strPtr(v string) *string {
|
||||
return &v
|
||||
}
|
||||
|
||||
func testConfig(baseURL string) config.Config {
|
||||
return config.Config{
|
||||
APIHost: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_AlreadyExists(t *testing.T) {
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(domain.UserResponse{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||
t.Fatalf("expected no POST calls, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_CreateOnNotFound(t *testing.T) {
|
||||
var getCalls int32
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
atomic.AddInt32(&getCalls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
var req domain.CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("failed to decode body: %v", err)
|
||||
}
|
||||
if req.TelegramID != 100500 || req.FirstName == nil || *req.FirstName != "John" {
|
||||
t.Fatalf("unexpected payload: %+v", req)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&getCalls); got != 1 {
|
||||
t.Fatalf("expected 1 GET call, got %d", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&postCalls); got != 1 {
|
||||
t.Fatalf("expected 1 POST call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_EnsureUser_ReturnsErrorWhenLookupFails(t *testing.T) {
|
||||
var postCalls int32
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/by-telegram/100500":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/users":
|
||||
atomic.AddInt32(&postCalls, 1)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := NewApiClient(testConfig(ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
err = client.EnsureUser(context.Background(), domain.CreateUserRequest{
|
||||
TelegramID: 100500,
|
||||
FirstName: strPtr("John"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&postCalls); got != 0 {
|
||||
t.Fatalf("expected no POST calls, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewBotClient(config config.Config) (*HTTPClient, error) {
|
||||
return &HTTPClient{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package familyHub
|
||||
|
||||
import (
|
||||
"FamilyHub/src/config"
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ApiClient interface {
|
||||
SendReceipt(ctx context.Context, payload domain.AddReceiptRequest) error
|
||||
EnsureUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||
IsUserRegistered(ctx context.Context, telegramID int64) (bool, error)
|
||||
RegisterUser(ctx context.Context, payload domain.CreateUserRequest) error
|
||||
GetUserByTelegramID(ctx context.Context, telegramID int64) (*domain.UserResponse, error)
|
||||
CreateFamily(ctx context.Context, payload domain.CreateFamilyRequest) error
|
||||
}
|
||||
|
||||
type BotClient interface {
|
||||
SendMessage(ctx context.Context, chatId int64, message string) error
|
||||
}
|
||||
|
||||
type HTTPClient struct {
|
||||
config config.Config
|
||||
client *http.Client
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package familyHub
|
||||
|
||||
type ReceiptPayload struct {
|
||||
Number string `json:"number"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
vision "cloud.google.com/go/vision/apiv1"
|
||||
)
|
||||
|
||||
type GoogleOCR struct {
|
||||
client *vision.ImageAnnotatorClient
|
||||
}
|
||||
|
||||
func NewGoogleOCR(ctx context.Context) (*GoogleOCR, error) {
|
||||
client, err := vision.NewImageAnnotatorClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GoogleOCR{client: client}, nil
|
||||
}
|
||||
|
||||
func (g *GoogleOCR) Close() error {
|
||||
if g == nil || g.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return g.client.Close()
|
||||
}
|
||||
|
||||
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
|
||||
img, err := vision.NewImageFromReader(bytes.NewReader(image))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load image: %w", err)
|
||||
}
|
||||
|
||||
annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect text: %w", err)
|
||||
}
|
||||
|
||||
if len(annotations) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return annotations[0].Description, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ocr
|
||||
|
||||
import "context"
|
||||
|
||||
// OCR — контракт для любого OCR сервиса
|
||||
type OCR interface {
|
||||
Recognize(ctx context.Context, image []byte) (string, error)
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package receiptService
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"FamilyHub/src/repositories"
|
||||
"FamilyHub/src/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReceiptService struct {
|
||||
client *http.Client
|
||||
repo repositories.ReceiptsRepository
|
||||
}
|
||||
|
||||
func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService {
|
||||
return &ReceiptService{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ReceiptService) GetReceipt(
|
||||
ctx context.Context,
|
||||
date string,
|
||||
number string,
|
||||
) (*domain.Receipt, error) {
|
||||
url := "https://ch.info-center.by/ajax/check1.php"
|
||||
var receipt domain.Receipt
|
||||
|
||||
body, contentType := buildMultipartBody(date, number)
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
url,
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
log.Println(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
log.Println(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("external service returned %d\n", resp.StatusCode)
|
||||
return nil, fmt.Errorf("external service returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Message map[string]interface{} `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
log.Printf("external service returned %s\n", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bytes_, _ := json.Marshal(raw.Message)
|
||||
|
||||
if err := json.Unmarshal(bytes_, &receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if receipt.IssuedAtRaw == "" {
|
||||
return nil, errors.New("receipt not found")
|
||||
}
|
||||
|
||||
positions, err := parsePositions(receipt.PositionsRaw)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse positions: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse issued at: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receipt.Positions = positions
|
||||
|
||||
for i := range receipt.Positions {
|
||||
p := &receipt.Positions[i]
|
||||
|
||||
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse product count: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.Amount, err = utils.ParseFloat(p.AmountRaw)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse amount: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.Discount, _ = utils.ParseFloat(p.DiscountRaw)
|
||||
p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw)
|
||||
}
|
||||
|
||||
if _, err := s.repo.Create(ctx, &receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
_ = writer.WriteField("orig_date", date)
|
||||
_ = writer.WriteField("orig_ui", number)
|
||||
|
||||
_ = writer.Close()
|
||||
|
||||
return body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func parsePositions(raw string) ([]domain.Position, error) {
|
||||
var positions []domain.Position
|
||||
|
||||
if raw == "" {
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(raw), &positions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"FamilyHub/src/api"
|
||||
"FamilyHub/src/bot"
|
||||
"FamilyHub/src/config"
|
||||
|
||||
"context"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var runnable []Runnable
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if cfg.RunMode == config.API || cfg.RunMode == config.Standalone {
|
||||
server := api.NewServer(cfg)
|
||||
runnable = append(runnable, func(ctx context.Context) error {
|
||||
log.Println("API started on", cfg.APIPort)
|
||||
return server.Start()
|
||||
})
|
||||
runnable = append(runnable, func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return server.Shutdown(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.RunMode == config.Bot || cfg.RunMode == config.Standalone {
|
||||
tgBot, err := bot.NewBot(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Println("Bot started...")
|
||||
runnable = append(runnable, func(ctx context.Context) error {
|
||||
return tgBot.Start(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
Run(ctx, runnable...)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type FamilyRepository interface {
|
||||
Create(ctx context.Context, family *domain.Family) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.Family, error)
|
||||
Update(ctx context.Context, family *domain.Family) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type FamilySQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewFamilySQLRepository(db *sql.DB) *FamilySQLRepository {
|
||||
return &FamilySQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *FamilySQLRepository) Create(ctx context.Context, family *domain.Family) error {
|
||||
query := `
|
||||
INSERT INTO families
|
||||
(name, owner_id, telegram_chat_id, telegram_chat_name, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
family.Name,
|
||||
family.OwnerID,
|
||||
family.TelegramChatID,
|
||||
family.TelegramChatName,
|
||||
family.CreatedAt,
|
||||
family.UpdatedAt,
|
||||
).Scan(&family.ID, &family.CreatedAt, &family.UpdatedAt)
|
||||
}
|
||||
func (r *FamilySQLRepository) GetByID(ctx context.Context, id int64) (*domain.Family, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
telegram_chat_id,
|
||||
telegram_chat_name,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM families
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var family domain.Family
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&family.ID,
|
||||
&family.Name,
|
||||
&family.OwnerID,
|
||||
&family.TelegramChatID,
|
||||
&family.TelegramChatName,
|
||||
&family.CreatedAt,
|
||||
&family.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil // или кастомную ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &family, nil
|
||||
}
|
||||
func (r *FamilySQLRepository) Update(ctx context.Context, family *domain.Family) error {
|
||||
query := `
|
||||
UPDATE families SET
|
||||
name = $1,
|
||||
telegram_chat_id = $2,
|
||||
telegram_chat_name = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $4
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
family.Name,
|
||||
family.TelegramChatID,
|
||||
family.TelegramChatName,
|
||||
family.UpdatedAt,
|
||||
family.ID,
|
||||
).Scan(&family.UpdatedAt)
|
||||
}
|
||||
func (r *FamilySQLRepository) Delete(ctx context.Context, id int64) error {
|
||||
query := `DELETE FROM families WHERE id = $1`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type OTPRepository interface {
|
||||
Create(ctx context.Context, otp *domain.OTP) error
|
||||
Get(ctx context.Context, userID int64, code string) (*domain.OTP, error)
|
||||
}
|
||||
|
||||
type OTPSQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewOTPSQLRepository(db *sql.DB) *OTPSQLRepository {
|
||||
return &OTPSQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *OTPSQLRepository) Create(ctx context.Context, otp *domain.OTP) error {
|
||||
query := `
|
||||
INSERT INTO otp (user_id, otp, expired_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, otp.UserID, otp.Code, otp.ExpiredAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *OTPSQLRepository) Get(ctx context.Context, userID int64, code string) (*domain.OTP, error) {
|
||||
query := `
|
||||
DELETE FROM otp
|
||||
WHERE ctid IN (
|
||||
SELECT ctid
|
||||
FROM otp
|
||||
WHERE user_id = $1
|
||||
AND otp = $2
|
||||
AND expired_at > NOW()
|
||||
ORDER BY expired_at
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING user_id, otp, expired_at
|
||||
`
|
||||
|
||||
var otp domain.OTP
|
||||
err := r.db.QueryRowContext(ctx, query, userID, code).Scan(
|
||||
&otp.UserID,
|
||||
&otp.Code,
|
||||
&otp.ExpiredAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &otp, nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"FamilyHub/src/domain"
|
||||
)
|
||||
|
||||
type ReceiptsRepository interface {
|
||||
Create(ctx context.Context, receipt *domain.Receipt) (int64, error)
|
||||
GetByID(ctx context.Context, id int64) (*domain.Receipt, error)
|
||||
GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error)
|
||||
Update(ctx context.Context, receipt *domain.Receipt) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type ReceiptsSQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewReceiptsSQLRepository(db *sql.DB) *ReceiptsSQLRepository {
|
||||
return &ReceiptsSQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) {
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if receipt.ReceiptNumber != receipt.UI {
|
||||
receipt.ReceiptNumber = receipt.UI
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO receipts (
|
||||
receipt_number, ui, status, issued_at,
|
||||
total_amount, payment_amount, cash_amount,
|
||||
another_amount, clearing_amount, margin,
|
||||
currency, payment_type,
|
||||
cashbox_number, cashier,
|
||||
name_spd, name_to, name_np, type_np,
|
||||
street_to, house_to,
|
||||
kod_soato, oblast_soato, rayon_soato, selsovet_soato,
|
||||
doc_num, skno_number, unp,
|
||||
success
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
receipt.ReceiptNumber,
|
||||
receipt.UI,
|
||||
receipt.Status,
|
||||
receipt.IssuedAt,
|
||||
|
||||
receipt.TotalAmount,
|
||||
receipt.PaymentAmount,
|
||||
receipt.CashAmount,
|
||||
receipt.AnotherAmount,
|
||||
receipt.ClearingAmount,
|
||||
receipt.Margin,
|
||||
|
||||
receipt.Currency,
|
||||
receipt.PaymentType,
|
||||
|
||||
receipt.CashboxNumber,
|
||||
receipt.Cashier,
|
||||
|
||||
receipt.NameSPD,
|
||||
receipt.NameTO,
|
||||
receipt.NameNP,
|
||||
receipt.TypeNP,
|
||||
|
||||
receipt.StreetTo,
|
||||
receipt.HouseTo,
|
||||
|
||||
receipt.KodSoato,
|
||||
receipt.OblastSoato,
|
||||
receipt.RayonSoato,
|
||||
receipt.SelsovetSoato,
|
||||
|
||||
receipt.DocNum,
|
||||
receipt.SknoNumber,
|
||||
receipt.UNP,
|
||||
|
||||
receipt.Success,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
receiptID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO positions (
|
||||
receipt_id,
|
||||
section_number,
|
||||
gtin_code,
|
||||
product_name,
|
||||
product_count,
|
||||
amount,
|
||||
discount,
|
||||
surcharge,
|
||||
tag,
|
||||
marking_code,
|
||||
ukz_code
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, p := range receipt.Positions {
|
||||
_, err = stmt.ExecContext(ctx,
|
||||
receiptID,
|
||||
p.SectionNumber,
|
||||
p.GTINCode,
|
||||
p.ProductName,
|
||||
p.ProductCount,
|
||||
p.Amount,
|
||||
p.Discount,
|
||||
p.Surcharge,
|
||||
p.Tag,
|
||||
p.MarkingCode,
|
||||
p.UKZCode,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return receiptID, tx.Commit()
|
||||
}
|
||||
|
||||
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
|
||||
|
||||
var receipt domain.Receipt
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
receipt_number, ui, status, issued_at,
|
||||
total_amount, payment_amount, cash_amount,
|
||||
another_amount, clearing_amount, margin,
|
||||
currency, payment_type,
|
||||
cashbox_number, cashier,
|
||||
name_spd, name_to, name_np, type_np,
|
||||
street_to, house_to,
|
||||
kod_soato, oblast_soato, rayon_soato, selsovet_soato,
|
||||
doc_num, skno_number, unp,
|
||||
success
|
||||
FROM receipts
|
||||
WHERE id = ?
|
||||
`, id).Scan(
|
||||
&receipt.ID,
|
||||
&receipt.ReceiptNumber,
|
||||
&receipt.UI,
|
||||
&receipt.Status,
|
||||
&receipt.IssuedAt,
|
||||
|
||||
&receipt.TotalAmount,
|
||||
&receipt.PaymentAmount,
|
||||
&receipt.CashAmount,
|
||||
&receipt.AnotherAmount,
|
||||
&receipt.ClearingAmount,
|
||||
&receipt.Margin,
|
||||
|
||||
&receipt.Currency,
|
||||
&receipt.PaymentType,
|
||||
|
||||
&receipt.CashboxNumber,
|
||||
&receipt.Cashier,
|
||||
|
||||
&receipt.NameSPD,
|
||||
&receipt.NameTO,
|
||||
&receipt.NameNP,
|
||||
&receipt.TypeNP,
|
||||
|
||||
&receipt.StreetTo,
|
||||
&receipt.HouseTo,
|
||||
|
||||
&receipt.KodSoato,
|
||||
&receipt.OblastSoato,
|
||||
&receipt.RayonSoato,
|
||||
&receipt.SelsovetSoato,
|
||||
|
||||
&receipt.DocNum,
|
||||
&receipt.SknoNumber,
|
||||
&receipt.UNP,
|
||||
|
||||
&receipt.Success,
|
||||
)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
section_number, gtin_code, product_name,
|
||||
product_count, amount,
|
||||
discount, surcharge,
|
||||
tag, marking_code, ukz_code
|
||||
FROM positions WHERE receipt_id = ?
|
||||
`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var p domain.Position
|
||||
if err := rows.Scan(
|
||||
&p.SectionNumber,
|
||||
&p.GTINCode,
|
||||
&p.ProductName,
|
||||
&p.ProductCount,
|
||||
&p.Amount,
|
||||
&p.Discount,
|
||||
&p.Surcharge,
|
||||
&p.Tag,
|
||||
&p.MarkingCode,
|
||||
&p.UKZCode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receipt.Positions = append(receipt.Positions, p)
|
||||
}
|
||||
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) {
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, receipt_number, issued_at, total_amount, currency
|
||||
FROM receipts
|
||||
ORDER BY issued_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var receipts []*domain.Receipt
|
||||
|
||||
for rows.Next() {
|
||||
var rct domain.Receipt
|
||||
if err := rows.Scan(
|
||||
&rct.ID,
|
||||
&rct.ReceiptNumber,
|
||||
&rct.IssuedAt,
|
||||
&rct.TotalAmount,
|
||||
&rct.Currency,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receipts = append(receipts, &rct)
|
||||
}
|
||||
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Receipt) error {
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE receipts SET
|
||||
issued_at = ?,
|
||||
total_amount = ?,
|
||||
currency = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
receipt.IssuedAt,
|
||||
receipt.TotalAmount,
|
||||
receipt.Currency,
|
||||
receipt.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = ?`, receipt.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range receipt.Positions {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO positions (
|
||||
receipt_id, product_name, product_count, amount
|
||||
) VALUES (?, ?, ?, ?)
|
||||
`, receipt.ID, p.ProductName, p.ProductCount, p.Amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM receipts WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type UsersRepository interface {
|
||||
Create(ctx context.Context, user *domain.UserModel) error
|
||||
GetByID(ctx context.Context, id int64) (*domain.UserModel, error)
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error)
|
||||
Update(ctx context.Context, user *domain.UserModel) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type UsersSQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUsersSQLRepository(db *sql.DB) *UsersSQLRepository {
|
||||
return &UsersSQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UsersSQLRepository) Create(ctx context.Context, user *domain.UserModel) error {
|
||||
query := `
|
||||
INSERT INTO users
|
||||
(telegram_id, username, first_name, last_name, language_code)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
user.TelegramID,
|
||||
user.Username,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.LanguageCode,
|
||||
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
|
||||
}
|
||||
func (r *UsersSQLRepository) GetByID(ctx context.Context, id int64) (*domain.UserModel, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
telegram_id,
|
||||
username,
|
||||
first_name,
|
||||
last_name,
|
||||
language_code,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var user domain.UserModel
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
&user.TelegramID,
|
||||
&user.Username,
|
||||
&user.FirstName,
|
||||
&user.LastName,
|
||||
&user.LanguageCode,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil // или кастомную ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
func (r *UsersSQLRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*domain.UserModel, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
telegram_id,
|
||||
username,
|
||||
first_name,
|
||||
last_name,
|
||||
language_code,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
WHERE telegram_id = $1
|
||||
`
|
||||
|
||||
var user domain.UserModel
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, telegramID).Scan(
|
||||
&user.ID,
|
||||
&user.TelegramID,
|
||||
&user.Username,
|
||||
&user.FirstName,
|
||||
&user.LastName,
|
||||
&user.LanguageCode,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
func (r *UsersSQLRepository) Update(ctx context.Context, user *domain.UserModel) error {
|
||||
query := `
|
||||
UPDATE users SET
|
||||
username = $1,
|
||||
first_name = $2,
|
||||
last_name = $3,
|
||||
language_code = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $5
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
user.Username,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.LanguageCode,
|
||||
user.ID,
|
||||
).Scan(&user.UpdatedAt)
|
||||
}
|
||||
func (r *UsersSQLRepository) Delete(ctx context.Context, id int64) error {
|
||||
query := `DELETE FROM users WHERE id = $1`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Runnable func(ctx context.Context) error
|
||||
|
||||
func Run(ctx context.Context, runnable ...Runnable) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, len(runnable))
|
||||
|
||||
for _, r := range runnable {
|
||||
go func(run Runnable) {
|
||||
if err := run(ctx); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}(r)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
log.Println("shutdown signal:", sig)
|
||||
case err := <-errCh:
|
||||
log.Println("runtime error:", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const issuedAtLayout = "02/01/2006, 15:04:05"
|
||||
|
||||
var knownDateFormats = []string{
|
||||
"02.01.2006", // 21.01.2026
|
||||
"02.01.06", // 21.01.2026
|
||||
"02-01-2006", // 21-01-2026
|
||||
"02/01/2006", // 21/01/2026
|
||||
//"2006/01/02", // 2026/01/21
|
||||
//"2006-01-02", // 2026-01-21
|
||||
//"2006.01.02", // 2026.01.21
|
||||
}
|
||||
|
||||
func NormalizeDateToISO(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
for _, layout := range knownDateFormats {
|
||||
if t, err := time.Parse(layout, input); err == nil {
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("unsupported date format")
|
||||
}
|
||||
|
||||
func ParseIssuedAt(value string) (time.Time, error) {
|
||||
return time.Parse(issuedAtLayout, value)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string) *JWTManager {
|
||||
return &JWTManager{secret: secret}
|
||||
}
|
||||
|
||||
func (j *JWTManager) Generate(userID int64) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(j.secret))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseFloat(value string) (float64, error) {
|
||||
value = strings.ReplaceAll(value, ",", ".")
|
||||
return strconv.ParseFloat(value, 64)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package utils
|
||||
|
||||
import "regexp"
|
||||
|
||||
type ReceiptMeta struct {
|
||||
Date string
|
||||
ReceiptID string
|
||||
}
|
||||
|
||||
func ExtractReceiptMeta(text string) ReceiptMeta {
|
||||
result := ReceiptMeta{}
|
||||
|
||||
// --- ДАТА ---
|
||||
datePatterns := []string{
|
||||
`(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026
|
||||
`(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26
|
||||
`(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25
|
||||
}
|
||||
|
||||
for _, pattern := range datePatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
if match := re.FindString(text); match != "" {
|
||||
result.Date = match
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// --- НОМЕР ЧЕКА (24 символа) ---
|
||||
receiptRe := regexp.MustCompile(`\b[A-Za-z0-9]{24}\b`)
|
||||
result.ReceiptID = receiptRe.FindString(text)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"github.com/liyue201/goqr"
|
||||
)
|
||||
|
||||
func DecodeQR(imageBytes []byte) ([]string, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(imageBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
codes, err := goqr.Recognize(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
println(codes)
|
||||
|
||||
var result []string
|
||||
for _, code := range codes {
|
||||
result = append(result, string(code.Payload))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user