diff --git a/src/api/docs/docs.go b/src/api/docs/docs.go index 3c58623..665d02c 100644 --- a/src/api/docs/docs.go +++ b/src/api/docs/docs.go @@ -15,6 +15,240 @@ const docTemplate = `{ "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/dto.CreateFamilyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/dto.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/dto.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/dto.UpdateFamilyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.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": [ @@ -48,19 +282,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } } } @@ -98,28 +326,19 @@ const docTemplate = `{ "400": { "description": "invalid telegram id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "404": { "description": "user not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } } } @@ -157,28 +376,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "404": { "description": "user not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } } } @@ -214,28 +424,19 @@ const docTemplate = `{ "400": { "description": "invalid id", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "404": { "description": "user not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } } } @@ -280,28 +481,19 @@ const docTemplate = `{ "400": { "description": "invalid id or invalid body", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "404": { "description": "user not found", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } }, "500": { "description": "internal server error", "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "$ref": "#/definitions/dto.UserErrorResponse" } } } @@ -309,6 +501,23 @@ const docTemplate = `{ } }, "definitions": { + "dto.CreateFamilyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "owner_id": { + "type": "integer" + }, + "telegram_chat_id": { + "type": "integer" + }, + "telegram_chat_name": { + "type": "string" + } + } + }, "dto.CreateUserRequest": { "type": "object", "required": [ @@ -333,6 +542,43 @@ const docTemplate = `{ } } }, + "dto.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" + } + } + }, + "dto.UpdateFamilyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "telegram_chat_name": { + "type": "string" + } + } + }, "dto.UpdateUserRequest": { "type": "object", "properties": { @@ -350,6 +596,14 @@ const docTemplate = `{ } } }, + "dto.UserErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, "dto.UserResponse": { "type": "object", "properties": { diff --git a/src/api/docs/swagger.json b/src/api/docs/swagger.json new file mode 100644 index 0000000..caf7078 --- /dev/null +++ b/src/api/docs/swagger.json @@ -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/dto.CreateFamilyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/dto.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/dto.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/dto.UpdateFamilyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.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/dto.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.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/dto.UserResponse" + } + }, + "400": { + "description": "invalid telegram id", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "404": { + "description": "user not found", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dto.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/dto.UserResponse" + } + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "404": { + "description": "user not found", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dto.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/dto.UserErrorResponse" + } + }, + "404": { + "description": "user not found", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dto.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/dto.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.UserResponse" + } + }, + "400": { + "description": "invalid id or invalid body", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "404": { + "description": "user not found", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dto.UserErrorResponse" + } + } + } + } + } + }, + "definitions": { + "dto.CreateFamilyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "owner_id": { + "type": "integer" + }, + "telegram_chat_id": { + "type": "integer" + }, + "telegram_chat_name": { + "type": "string" + } + } + }, + "dto.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" + } + } + }, + "dto.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" + } + } + }, + "dto.UpdateFamilyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "telegram_chat_name": { + "type": "string" + } + } + }, + "dto.UpdateUserRequest": { + "type": "object", + "properties": { + "first_name": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "dto.UserErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "dto.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" + } + } + } + } +} \ No newline at end of file diff --git a/src/api/docs/swagger.yaml b/src/api/docs/swagger.yaml new file mode 100644 index 0000000..a562d44 --- /dev/null +++ b/src/api/docs/swagger.yaml @@ -0,0 +1,411 @@ +definitions: + dto.CreateFamilyRequest: + properties: + name: + type: string + owner_id: + type: integer + telegram_chat_id: + type: integer + telegram_chat_name: + type: string + type: object + dto.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 + dto.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 + dto.UpdateFamilyRequest: + properties: + name: + type: string + telegram_chat_name: + type: string + type: object + dto.UpdateUserRequest: + properties: + first_name: + type: string + language_code: + type: string + last_name: + type: string + username: + type: string + type: object + dto.UserErrorResponse: + properties: + error: + type: string + type: object + dto.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/dto.CreateFamilyRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/dto.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/dto.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/dto.UpdateFamilyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.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/dto.CreateUserRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/dto.UserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.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/dto.UserErrorResponse' + "404": + description: user not found + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "500": + description: internal server error + schema: + $ref: '#/definitions/dto.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/dto.UserResponse' + "400": + description: invalid id + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "404": + description: user not found + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "500": + description: internal server error + schema: + $ref: '#/definitions/dto.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/dto.UpdateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.UserResponse' + "400": + description: invalid id or invalid body + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "404": + description: user not found + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "500": + description: internal server error + schema: + $ref: '#/definitions/dto.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/dto.UserResponse' + "400": + description: invalid telegram id + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "404": + description: user not found + schema: + $ref: '#/definitions/dto.UserErrorResponse' + "500": + description: internal server error + schema: + $ref: '#/definitions/dto.UserErrorResponse' + summary: Получить пользователя по Telegram ID + tags: + - Users +swagger: "2.0" diff --git a/src/api/dto/families.go b/src/api/dto/families.go new file mode 100644 index 0000000..b107d6e --- /dev/null +++ b/src/api/dto/families.go @@ -0,0 +1,40 @@ +package dto + +import ( + "FamilyHub/src/domain" + "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 *domain.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), + } +} diff --git a/src/api/dto/users.go b/src/api/dto/users.go index 3eb6840..62ad62f 100644 --- a/src/api/dto/users.go +++ b/src/api/dto/users.go @@ -29,6 +29,10 @@ type UserResponse struct { UpdatedAt string `json:"updated_at"` } +type UserErrorResponse struct { + Error string `json:"error"` +} + func (response *UserResponse) ModelToResponse(u *domain.User) UserResponse { return UserResponse{ ID: u.ID, diff --git a/src/api/routers/families.go b/src/api/routers/families.go new file mode 100644 index 0000000..244f830 --- /dev/null +++ b/src/api/routers/families.go @@ -0,0 +1,169 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/services" + "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 dto.CreateFamilyRequest true "Family info" +// @Success 201 {object} dto.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 dto.CreateFamilyRequest + var resp dto.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} dto.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 dto.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 dto.UpdateFamilyRequest true "Данные для обновления" +// @Success 200 {object} dto.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 dto.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 dto.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"}) + } +} diff --git a/src/api/routers/families_test.go b/src/api/routers/families_test.go new file mode 100644 index 0000000..48f6f20 --- /dev/null +++ b/src/api/routers/families_test.go @@ -0,0 +1,316 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "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 dto.CreateFamilyRequest) (*domain.Family, error) + getByIDFn func(ctx context.Context, id int64) (*domain.Family, error) + updateFn func(ctx context.Context, id int64, req dto.UpdateFamilyRequest) (*domain.Family, error) + deleteFn func(ctx context.Context, id int64) error +} + +func (m *familyServiceMock) Create(ctx context.Context, req dto.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 dto.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 dto.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 dto.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 dto.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 dto.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 dto.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 dto.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) +} diff --git a/src/api/routers/receipts.go b/src/api/routers/receipts.go index e522248..bc416f6 100644 --- a/src/api/routers/receipts.go +++ b/src/api/routers/receipts.go @@ -2,7 +2,7 @@ package routers import ( "FamilyHub/src/api/dto" - "FamilyHub/src/integrations/receiptService" + "FamilyHub/src/domain" "FamilyHub/src/utils" "context" "log" @@ -12,11 +12,15 @@ import ( "github.com/gin-gonic/gin" ) -type ReceiptRouter struct { - service *receiptService.ReceiptService +type receiptService interface { + GetReceipt(ctx context.Context, date string, number string) (*domain.Receipt, error) } -func NewReceiptRouter(s *receiptService.ReceiptService) *ReceiptRouter { +type ReceiptRouter struct { + service receiptService +} + +func NewReceiptRouter(s receiptService) *ReceiptRouter { return &ReceiptRouter{service: s} } func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) { diff --git a/src/api/routers/receipts_test.go b/src/api/routers/receipts_test.go new file mode 100644 index 0000000..64d857d --- /dev/null +++ b/src/api/routers/receipts_test.go @@ -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) + } + }) + } +} diff --git a/src/api/routers/users.go b/src/api/routers/users.go index 5de2934..5e957d8 100644 --- a/src/api/routers/users.go +++ b/src/api/routers/users.go @@ -14,8 +14,8 @@ type UsersRouter struct { service services.UserService } -func NewUsersRouter(s *services.UserService) *UsersRouter { - return &UsersRouter{service: *s} +func NewUsersRouter(s services.UserService) *UsersRouter { + return &UsersRouter{service: s} } func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { @@ -36,15 +36,15 @@ func (router *UsersRouter) RegisterRoutes(r *gin.RouterGroup) { // @Produce json // @Param user body dto.CreateUserRequest true "User info" // @Success 201 {object} dto.UserResponse -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string +// @Failure 400 {object} dto.UserErrorResponse +// @Failure 500 {object} dto.UserErrorResponse // @Router /users [post] func (router *UsersRouter) CreateUser(c *gin.Context) { var req dto.CreateUserRequest var resp dto.UserResponse if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()}) return } @@ -65,15 +65,15 @@ func (router *UsersRouter) CreateUser(c *gin.Context) { // @Produce json // @Param id path int true "User ID" // @Success 200 {object} dto.UserResponse -// @Failure 400 {object} map[string]string "invalid id" -// @Failure 404 {object} map[string]string "user not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.UserErrorResponse "invalid id" +// @Failure 404 {object} dto.UserErrorResponse "user not found" +// @Failure 500 {object} dto.UserErrorResponse "internal server error" // @Router /users/{id} [get] func (router *UsersRouter) GetByID(c *gin.Context) { var resp dto.UserResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"}) return } @@ -94,15 +94,15 @@ func (router *UsersRouter) GetByID(c *gin.Context) { // @Produce json // @Param telegramId path int true "Telegram ID" // @Success 200 {object} dto.UserResponse -// @Failure 400 {object} map[string]string "invalid telegram id" -// @Failure 404 {object} map[string]string "user not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.UserErrorResponse "invalid telegram id" +// @Failure 404 {object} dto.UserErrorResponse "user not found" +// @Failure 500 {object} dto.UserErrorResponse "internal server error" // @Router /users/by-telegram/{telegramId} [get] func (router *UsersRouter) GetByTelegramID(c *gin.Context) { var resp dto.UserResponse telegramID, err := strconv.ParseInt(c.Param("telegramId"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram id"}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid telegram id"}) return } @@ -124,21 +124,21 @@ func (router *UsersRouter) GetByTelegramID(c *gin.Context) { // @Param id path int true "User ID" // @Param user body dto.UpdateUserRequest true "Данные для обновления" // @Success 200 {object} dto.UserResponse -// @Failure 400 {object} map[string]string "invalid id or invalid body" -// @Failure 404 {object} map[string]string "user not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.UserErrorResponse "invalid id or invalid body" +// @Failure 404 {object} dto.UserErrorResponse "user not found" +// @Failure 500 {object} dto.UserErrorResponse "internal server error" // @Router /users/{id} [patch] func (router *UsersRouter) Update(c *gin.Context) { var resp dto.UserResponse id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"}) return } var req dto.UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()}) return } @@ -159,14 +159,14 @@ func (router *UsersRouter) Update(c *gin.Context) { // @Produce json // @Param id path int true "User ID" // @Success 204 {string} string "no content" -// @Failure 400 {object} map[string]string "invalid id" -// @Failure 404 {object} map[string]string "user not found" -// @Failure 500 {object} map[string]string "internal server error" +// @Failure 400 {object} dto.UserErrorResponse "invalid id" +// @Failure 404 {object} dto.UserErrorResponse "user not found" +// @Failure 500 {object} dto.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, gin.H{"error": "invalid id"}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: "invalid id"}) return } @@ -181,12 +181,12 @@ func (router *UsersRouter) Delete(c *gin.Context) { func handleError(c *gin.Context, err error) { switch { case errors.Is(err, services.ErrUserNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, dto.UserErrorResponse{Error: err.Error()}) case errors.Is(err, services.ErrInvalidPatch): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()}) case errors.Is(err, services.ErrTelegramIDMissing): - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, dto.UserErrorResponse{Error: err.Error()}) default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + c.JSON(http.StatusInternalServerError, dto.UserErrorResponse{Error: "internal server error"}) } } diff --git a/src/api/routers/users_test.go b/src/api/routers/users_test.go new file mode 100644 index 0000000..27fa0f1 --- /dev/null +++ b/src/api/routers/users_test.go @@ -0,0 +1,344 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "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 dto.CreateUserRequest) (*domain.User, error) + getByIDFn func(ctx context.Context, id int64) (*domain.User, error) + getByTelegramIDFn func(ctx context.Context, telegramID int64) (*domain.User, error) + updateFn func(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, error) + deleteFn func(ctx context.Context, id int64) error +} + +func (m *userServiceMock) Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, 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.User, 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.User, 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 dto.UpdateUserRequest) (*domain.User, 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.User { + username := "john" + lastName := "Doe" + languageCode := "en" + + return &domain.User{ + ID: 10, + TelegramID: 100500, + Username: &username, + FirstName: "John", + 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 dto.CreateUserRequest) (*domain.User, 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 dto.CreateUserRequest) (*domain.User, error) { + assert.Equal(t, int64(100500), req.TelegramID) + 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.User, 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.User, 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.User, 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 dto.UpdateUserRequest) (*domain.User, 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 dto.UpdateUserRequest) (*domain.User, 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 dto.CreateUserRequest) (*domain.User, 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 dto.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.User, 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)) +} diff --git a/src/api/server.go b/src/api/server.go index 20d34c0..7ead90e 100644 --- a/src/api/server.go +++ b/src/api/server.go @@ -48,9 +48,14 @@ func NewServer(cfg config.Config) *Server { usersRepo := repositories.NewUsersSQLRepository(dbConn) usersService := services.NewUserService(usersRepo) - usersRouter := routers.NewUsersRouter(&usersService) + usersRouter := routers.NewUsersRouter(usersService) usersRouter.RegisterRoutes(apiV1) + familyRepo := repositories.NewFamilySQLRepository(dbConn) + familyService := services.NewFamilyService(familyRepo) + familyRouter := routers.NewFamiliesRouter(familyService) + familyRouter.RegisterRoutes(apiV1) + return &Server{ httpServer: &http.Server{ Addr: cfg.APIHost + ":" + cfg.APIPort, diff --git a/src/api/services/families.go b/src/api/services/families.go new file mode 100644 index 0000000..ba95dc1 --- /dev/null +++ b/src/api/services/families.go @@ -0,0 +1,78 @@ +package services + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/domain" + "FamilyHub/src/repositories" + "context" + "errors" +) + +type FamilyService interface { + Create(ctx context.Context, req dto.CreateFamilyRequest) (*domain.Family, error) + GetByID(ctx context.Context, id int64) (*domain.Family, error) + Update(ctx context.Context, id int64, req dto.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 dto.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 dto.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 +} diff --git a/src/domain/families.go b/src/domain/families.go new file mode 100644 index 0000000..3623ab5 --- /dev/null +++ b/src/domain/families.go @@ -0,0 +1,41 @@ +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 +} diff --git a/src/domain/models/family.go b/src/domain/models/family.go deleted file mode 100644 index 8eef5ad..0000000 --- a/src/domain/models/family.go +++ /dev/null @@ -1,67 +0,0 @@ -package models - -import "time" - -type User struct { - ID int64 - TelegramID int64 - Username *string - FirstName string - LastName *string - LanguageCode *string - CreatedAt time.Time - UpdatedAt time.Time -} - -type TelegramChat struct { - ID int64 - TelegramID int64 - Title string - CreatedAt time.Time -} - -type Family struct { - ID int64 - Name string - OwnerID int64 - TelegramChatID int64 - CreatedAt 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 ThreadType string - -const ( - ThreadExpenses ThreadType = "expenses" - ThreadMovies ThreadType = "movies" - ThreadSchedule ThreadType = "schedule" - ThreadRecipes ThreadType = "recipes" - ThreadCustom ThreadType = "custom" -) - -type Thread struct { - ID int64 - FamilyID int64 - Type ThreadType - Title string - TelegramTopicID int64 - IsSystem bool - CreatedBy int64 - CreatedAt time.Time -} diff --git a/src/repositories/families.go b/src/repositories/families.go new file mode 100644 index 0000000..8187751 --- /dev/null +++ b/src/repositories/families.go @@ -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 +}