From a70527b38870863860ec58f18a07775051859c66 Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Fri, 6 Mar 2026 23:30:58 +0300 Subject: [PATCH] Added users support, made CRUD for users. Updated receipts feature --- FamilyHub.drawio | 622 ++++++++++++++++++ go.mod | 20 + ...wn.sql => 000002_create_families.down.sql} | 0 migrations/000002_create_families.up.sql | 14 + .../000002_create_telegram_chats.down.sql | 1 - .../000002_create_telegram_chats.up.sql | 9 - migrations/000003_create_families.up.sql | 12 - ... => 000003_create_family_members.down.sql} | 0 ...ql => 000003_create_family_members.up.sql} | 0 ... => 000004_create_family_threads.down.sql} | 0 .../000004_create_family_threads.up.sql | 19 + ...wn.sql => 000005_create_receipts.down.sql} | 0 ...s.up.sql => 000005_create_receipts.up.sql} | 0 migrations/000005_create_threads.up.sql | 27 - ...n.sql => 000006_create_positions.down.sql} | 0 ....up.sql => 000006_create_positions.up.sql} | 0 src/api/docs/docs.go | 401 +++++++++++ src/api/dto/common.go | 5 + src/api/dto/{requests.go => receipts.go} | 10 +- src/api/dto/responses.go | 17 - src/api/dto/users.go | 43 ++ src/api/{handlers => routers}/receipts.go | 18 +- src/api/routers/users.go | 192 ++++++ src/api/server.go | 30 +- src/api/services/users.go | 107 +++ src/config/config_test.go | 2 +- src/domain/{models => }/receipt.go | 2 +- src/domain/users.go | 16 + .../receiptService/receipt_service.go | 14 +- src/repositories/receipt_repository.go | 15 - .../{receipt_sql.go => receipts.go} | 36 +- src/repositories/users.go | 158 +++++ 32 files changed, 1667 insertions(+), 123 deletions(-) create mode 100644 FamilyHub.drawio rename migrations/{000003_create_families.down.sql => 000002_create_families.down.sql} (100%) create mode 100644 migrations/000002_create_families.up.sql delete mode 100644 migrations/000002_create_telegram_chats.down.sql delete mode 100644 migrations/000002_create_telegram_chats.up.sql delete mode 100644 migrations/000003_create_families.up.sql rename migrations/{000004_create_family_members.down.sql => 000003_create_family_members.down.sql} (100%) rename migrations/{000004_create_family_members.up.sql => 000003_create_family_members.up.sql} (100%) rename migrations/{000005_create_threads.down.sql => 000004_create_family_threads.down.sql} (100%) create mode 100644 migrations/000004_create_family_threads.up.sql rename migrations/{000006_create_receipts.down.sql => 000005_create_receipts.down.sql} (100%) rename migrations/{000006_create_receipts.up.sql => 000005_create_receipts.up.sql} (100%) delete mode 100644 migrations/000005_create_threads.up.sql rename migrations/{000007_create_positions.down.sql => 000006_create_positions.down.sql} (100%) rename migrations/{000007_create_positions.up.sql => 000006_create_positions.up.sql} (100%) create mode 100644 src/api/docs/docs.go create mode 100644 src/api/dto/common.go rename src/api/dto/{requests.go => receipts.go} (52%) delete mode 100644 src/api/dto/responses.go create mode 100644 src/api/dto/users.go rename src/api/{handlers => routers}/receipts.go (68%) create mode 100644 src/api/routers/users.go create mode 100644 src/api/services/users.go rename src/domain/{models => }/receipt.go (99%) create mode 100644 src/domain/users.go delete mode 100644 src/repositories/receipt_repository.go rename src/repositories/{receipt_sql.go => receipts.go} (82%) create mode 100644 src/repositories/users.go diff --git a/FamilyHub.drawio b/FamilyHub.drawio new file mode 100644 index 0000000..e7aa1c2 --- /dev/null +++ b/FamilyHub.drawio @@ -0,0 +1,622 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/go.mod b/go.mod index 80ddafd..2697674 100644 --- a/go.mod +++ b/go.mod @@ -21,16 +21,24 @@ require ( cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/vision/v2 v2.9.5 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect @@ -39,9 +47,11 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -49,8 +59,14 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.1 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -59,17 +75,21 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/grpc v1.74.2 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/migrations/000003_create_families.down.sql b/migrations/000002_create_families.down.sql similarity index 100% rename from migrations/000003_create_families.down.sql rename to migrations/000002_create_families.down.sql diff --git a/migrations/000002_create_families.up.sql b/migrations/000002_create_families.up.sql new file mode 100644 index 0000000..ec260d4 --- /dev/null +++ b/migrations/000002_create_families.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE families +( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + owner_id BIGINT NOT NULL REFERENCES users (id), + telegram_chat_id BIGINT NOT NULL, + telegram_chat_name TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_families_owner_id ON families (owner_id); + +CREATE UNIQUE INDEX idx_families_chat_id ON families (telegram_chat_id); \ No newline at end of file diff --git a/migrations/000002_create_telegram_chats.down.sql b/migrations/000002_create_telegram_chats.down.sql deleted file mode 100644 index edce235..0000000 --- a/migrations/000002_create_telegram_chats.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS telegram_chats; \ No newline at end of file diff --git a/migrations/000002_create_telegram_chats.up.sql b/migrations/000002_create_telegram_chats.up.sql deleted file mode 100644 index 4b6110b..0000000 --- a/migrations/000002_create_telegram_chats.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE telegram_chats -( - id BIGSERIAL PRIMARY KEY, - telegram_id BIGINT UNIQUE NOT NULL, - title TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_telegram_chats_telegram_id ON telegram_chats (telegram_id); \ No newline at end of file diff --git a/migrations/000003_create_families.up.sql b/migrations/000003_create_families.up.sql deleted file mode 100644 index 25a32ac..0000000 --- a/migrations/000003_create_families.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE families -( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - owner_id BIGINT NOT NULL REFERENCES users (id), - telegram_chat_id BIGINT NOT NULL UNIQUE REFERENCES telegram_chats (id), - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_families_owner_id ON families (owner_id); - -CREATE UNIQUE INDEX idx_families_chat_id ON families (telegram_chat_id); \ No newline at end of file diff --git a/migrations/000004_create_family_members.down.sql b/migrations/000003_create_family_members.down.sql similarity index 100% rename from migrations/000004_create_family_members.down.sql rename to migrations/000003_create_family_members.down.sql diff --git a/migrations/000004_create_family_members.up.sql b/migrations/000003_create_family_members.up.sql similarity index 100% rename from migrations/000004_create_family_members.up.sql rename to migrations/000003_create_family_members.up.sql diff --git a/migrations/000005_create_threads.down.sql b/migrations/000004_create_family_threads.down.sql similarity index 100% rename from migrations/000005_create_threads.down.sql rename to migrations/000004_create_family_threads.down.sql diff --git a/migrations/000004_create_family_threads.up.sql b/migrations/000004_create_family_threads.up.sql new file mode 100644 index 0000000..309481e --- /dev/null +++ b/migrations/000004_create_family_threads.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE family_threads +( + id BIGSERIAL PRIMARY KEY, + family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT NOT NULL, + telegram_topic_id BIGINT NOT NULL, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT NOT NULL REFERENCES users (id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + UNIQUE (family_id, telegram_topic_id) +); + +CREATE UNIQUE INDEX idx_unique_system_threads ON family_threads (family_id, type) WHERE is_system = TRUE; + +CREATE INDEX idx_threads_family_id ON family_threads (family_id); + +CREATE INDEX idx_threads_family_type ON family_threads (family_id, type); diff --git a/migrations/000006_create_receipts.down.sql b/migrations/000005_create_receipts.down.sql similarity index 100% rename from migrations/000006_create_receipts.down.sql rename to migrations/000005_create_receipts.down.sql diff --git a/migrations/000006_create_receipts.up.sql b/migrations/000005_create_receipts.up.sql similarity index 100% rename from migrations/000006_create_receipts.up.sql rename to migrations/000005_create_receipts.up.sql diff --git a/migrations/000005_create_threads.up.sql b/migrations/000005_create_threads.up.sql deleted file mode 100644 index cc0d8c1..0000000 --- a/migrations/000005_create_threads.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TYPE thread_type AS ENUM ( - 'expenses', - 'movies', - 'schedule', - 'recipes', - 'custom' -); - -CREATE TABLE threads -( - id BIGSERIAL PRIMARY KEY, - family_id BIGINT NOT NULL REFERENCES families (id) ON DELETE CASCADE, - type thread_type NOT NULL, - title TEXT NOT NULL, - telegram_topic_id BIGINT NOT NULL, - is_system BOOLEAN NOT NULL DEFAULT FALSE, - created_by BIGINT NOT NULL REFERENCES users (id), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - - UNIQUE (family_id, telegram_topic_id) -); - -CREATE UNIQUE INDEX idx_unique_system_threads ON threads (family_id, type) WHERE is_system = TRUE; - -CREATE INDEX idx_threads_family_id ON threads (family_id); - -CREATE INDEX idx_threads_family_type ON threads(family_id, type); diff --git a/migrations/000007_create_positions.down.sql b/migrations/000006_create_positions.down.sql similarity index 100% rename from migrations/000007_create_positions.down.sql rename to migrations/000006_create_positions.down.sql diff --git a/migrations/000007_create_positions.up.sql b/migrations/000006_create_positions.up.sql similarity index 100% rename from migrations/000007_create_positions.up.sql rename to migrations/000006_create_positions.up.sql diff --git a/src/api/docs/docs.go b/src/api/docs/docs.go new file mode 100644 index 0000000..3c58623 --- /dev/null +++ b/src/api/docs/docs.go @@ -0,0 +1,401 @@ +// 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": { + "/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": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/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": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "user not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/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": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "user 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": [ + "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": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "user 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": [ + "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": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "user not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "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.UpdateUserRequest": { + "type": "object", + "properties": { + "first_name": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "username": { + "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" + } + } + } + } +}` + +// 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) +} diff --git a/src/api/dto/common.go b/src/api/dto/common.go new file mode 100644 index 0000000..1c74ecb --- /dev/null +++ b/src/api/dto/common.go @@ -0,0 +1,5 @@ +package dto + +type ErrorResponse struct { + Message string `json:"message"` +} diff --git a/src/api/dto/requests.go b/src/api/dto/receipts.go similarity index 52% rename from src/api/dto/requests.go rename to src/api/dto/receipts.go index 0065ff3..6e5cf27 100644 --- a/src/api/dto/requests.go +++ b/src/api/dto/receipts.go @@ -1,10 +1,14 @@ package dto -type HelloRequest struct { - Name string `form:"name" binding:"required,min=2,max=50"` -} +import "time" 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"` +} diff --git a/src/api/dto/responses.go b/src/api/dto/responses.go deleted file mode 100644 index d217661..0000000 --- a/src/api/dto/responses.go +++ /dev/null @@ -1,17 +0,0 @@ -package dto - -import "time" - -type HelloResponse struct { - Message string `json:"message"` -} - -type ErrorResponse struct { - Message string `json:"message"` -} - -type AddReceiptResponse struct { - ID int32 `json:"id"` - Number string `json:"number"` - Date time.Time `json:"date"` -} diff --git a/src/api/dto/users.go b/src/api/dto/users.go new file mode 100644 index 0000000..3eb6840 --- /dev/null +++ b/src/api/dto/users.go @@ -0,0 +1,43 @@ +package dto + +import ( + "FamilyHub/src/domain" + "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"` +} + +func (response *UserResponse) ModelToResponse(u *domain.User) 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), + } +} diff --git a/src/api/handlers/receipts.go b/src/api/routers/receipts.go similarity index 68% rename from src/api/handlers/receipts.go rename to src/api/routers/receipts.go index b6f6001..e522248 100644 --- a/src/api/handlers/receipts.go +++ b/src/api/routers/receipts.go @@ -1,4 +1,4 @@ -package handlers +package routers import ( "FamilyHub/src/api/dto" @@ -12,15 +12,21 @@ import ( "github.com/gin-gonic/gin" ) -type ReceiptHandler struct { +type ReceiptRouter struct { service *receiptService.ReceiptService } -func NewReceiptHandler(s *receiptService.ReceiptService) *ReceiptHandler { - return &ReceiptHandler{service: s} +func NewReceiptRouter(s *receiptService.ReceiptService) *ReceiptRouter { + return &ReceiptRouter{service: s} +} +func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) { + receipts := r.Group("/receipts") + { + receipts.POST("", router.AddReceipt) + } } -func (handler *ReceiptHandler) AddReceipt(context_ *gin.Context) { +func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) { var req dto.AddReceiptRequest if err := context_.ShouldBindJSON(&req); err != nil { log.Println("bind error:", err) @@ -36,7 +42,7 @@ func (handler *ReceiptHandler) AddReceipt(context_ *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - receipt, err := handler.service.GetReceipt(ctx, isoDate, req.Number) + 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()) diff --git a/src/api/routers/users.go b/src/api/routers/users.go new file mode 100644 index 0000000..5de2934 --- /dev/null +++ b/src/api/routers/users.go @@ -0,0 +1,192 @@ +package routers + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/api/services" + "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 dto.CreateUserRequest true "User info" +// @Success 201 {object} dto.UserResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @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()}) + 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} 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" +// @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"}) + 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} 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" +// @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"}) + 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 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" +// @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"}) + return + } + + var req dto.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"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} map[string]string "invalid id" +// @Failure 404 {object} map[string]string "user not found" +// @Failure 500 {object} map[string]string "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"}) + 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, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrInvalidPatch): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrTelegramIDMissing): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } +} diff --git a/src/api/server.go b/src/api/server.go index 7ab5bcf..20d34c0 100644 --- a/src/api/server.go +++ b/src/api/server.go @@ -1,7 +1,9 @@ package api import ( - "FamilyHub/src/api/handlers" + _ "FamilyHub/src/api/docs" + "FamilyHub/src/api/routers" + "FamilyHub/src/api/services" "FamilyHub/src/config" "FamilyHub/src/database" "FamilyHub/src/integrations/receiptService" @@ -11,6 +13,8 @@ import ( "net/http" "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" ) type Server struct { @@ -18,7 +22,6 @@ type Server struct { } func NewServer(cfg config.Config) *Server { - handler := gin.Default() dbManager := &database.Database{ ConnectionString: cfg.DBConnectionString, MigrationsPath: "file://migrations", @@ -27,24 +30,31 @@ func NewServer(cfg config.Config) *Server { if err != nil { log.Fatal(err) } - if err := dbManager.RunMigrations(dbConn); err != nil { log.Fatal(err) } - receiptRepo := repositories.NewReceiptSQLRepository(dbConn) - receiptService_ := receiptService.NewReceiptService(receiptRepo) - receiptHandler := handlers.NewReceiptHandler(receiptService_) - + router := gin.Default() if cfg.OpenAPIEnabled { - handler.GET(cfg.OpenAPIEndpoint) + router.GET("/openapi/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } - handler.POST("/receipts", receiptHandler.AddReceipt) + + 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) return &Server{ httpServer: &http.Server{ Addr: cfg.APIHost + ":" + cfg.APIPort, - Handler: handler, + Handler: router, }, } } diff --git a/src/api/services/users.go b/src/api/services/users.go new file mode 100644 index 0000000..3a2ee9e --- /dev/null +++ b/src/api/services/users.go @@ -0,0 +1,107 @@ +package services + +import ( + "FamilyHub/src/api/dto" + "FamilyHub/src/domain" + "FamilyHub/src/repositories" + "context" + "errors" +) + +type UserService interface { + Create(ctx context.Context, req dto.CreateUserRequest) (*domain.User, error) + GetByID(ctx context.Context, id int64) (*domain.User, error) + GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) + Update(ctx context.Context, id int64, req dto.UpdateUserRequest) (*domain.User, 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 dto.CreateUserRequest) (*domain.User, error) { + user_ := &domain.User{ + 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.User, 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.User, 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 dto.UpdateUserRequest) (*domain.User, 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.User{ + 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) +} diff --git a/src/config/config_test.go b/src/config/config_test.go index 100665c..681edc6 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -1,7 +1,7 @@ package config_test import ( - "GoFinanceManager/internal/config" + "FamilyHub/src/config" "os" "testing" diff --git a/src/domain/models/receipt.go b/src/domain/receipt.go similarity index 99% rename from src/domain/models/receipt.go rename to src/domain/receipt.go index fc8476c..0231919 100644 --- a/src/domain/models/receipt.go +++ b/src/domain/receipt.go @@ -1,4 +1,4 @@ -package models +package domain import "time" diff --git a/src/domain/users.go b/src/domain/users.go new file mode 100644 index 0000000..ae9eb12 --- /dev/null +++ b/src/domain/users.go @@ -0,0 +1,16 @@ +package domain + +import ( + "time" +) + +type User struct { + ID int64 + TelegramID int64 + Username *string + FirstName string + LastName *string + LanguageCode *string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/src/integrations/receiptService/receipt_service.go b/src/integrations/receiptService/receipt_service.go index 8094096..ec26f47 100644 --- a/src/integrations/receiptService/receipt_service.go +++ b/src/integrations/receiptService/receipt_service.go @@ -1,7 +1,7 @@ package receiptService import ( - "FamilyHub/src/domain/models" + "FamilyHub/src/domain" "FamilyHub/src/repositories" "FamilyHub/src/utils" "bytes" @@ -18,10 +18,10 @@ import ( type ReceiptService struct { client *http.Client - repo repositories.ReceiptRepository + repo repositories.ReceiptsRepository } -func NewReceiptService(repo repositories.ReceiptRepository) *ReceiptService { +func NewReceiptService(repo repositories.ReceiptsRepository) *ReceiptService { return &ReceiptService{ client: &http.Client{ Timeout: 60 * time.Second, @@ -39,9 +39,9 @@ func (s *ReceiptService) GetReceipt( ctx context.Context, date string, number string, -) (*models.Receipt, error) { +) (*domain.Receipt, error) { url := "https://ch.info-center.by/ajax/check1.php" - var receipt models.Receipt + var receipt domain.Receipt body, contentType := buildMultipartBody(date, number) req, err := http.NewRequestWithContext( @@ -137,8 +137,8 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) { return body, writer.FormDataContentType() } -func parsePositions(raw string) ([]models.Position, error) { - var positions []models.Position +func parsePositions(raw string) ([]domain.Position, error) { + var positions []domain.Position if raw == "" { return positions, nil diff --git a/src/repositories/receipt_repository.go b/src/repositories/receipt_repository.go deleted file mode 100644 index c13daed..0000000 --- a/src/repositories/receipt_repository.go +++ /dev/null @@ -1,15 +0,0 @@ -package repositories - -import ( - "context" - - "FamilyHub/src/domain/models" -) - -type ReceiptRepository interface { - Create(ctx context.Context, receipt *models.Receipt) (int64, error) - GetByID(ctx context.Context, id int64) (*models.Receipt, error) - GetAll(ctx context.Context, limit, offset int) ([]*models.Receipt, error) - Update(ctx context.Context, receipt *models.Receipt) error - Delete(ctx context.Context, id int64) error -} diff --git a/src/repositories/receipt_sql.go b/src/repositories/receipts.go similarity index 82% rename from src/repositories/receipt_sql.go rename to src/repositories/receipts.go index 8bbf71d..e399858 100644 --- a/src/repositories/receipt_sql.go +++ b/src/repositories/receipts.go @@ -5,18 +5,26 @@ import ( "database/sql" "errors" - "FamilyHub/src/domain/models" + "FamilyHub/src/domain" ) -type ReceiptSQLRepository struct { +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 NewReceiptSQLRepository(db *sql.DB) *ReceiptSQLRepository { - return &ReceiptSQLRepository{db: db} +func NewReceiptsSQLRepository(db *sql.DB) *ReceiptsSQLRepository { + return &ReceiptsSQLRepository{db: db} } -func (r *ReceiptSQLRepository) Create(ctx context.Context, receipt *models.Receipt) (int64, error) { +func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -129,9 +137,9 @@ func (r *ReceiptSQLRepository) Create(ctx context.Context, receipt *models.Recei return receiptID, tx.Commit() } -func (r *ReceiptSQLRepository) GetByID(ctx context.Context, id int64) (*models.Receipt, error) { +func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) { - var receipt models.Receipt + var receipt domain.Receipt err := r.db.QueryRowContext(ctx, ` SELECT @@ -145,7 +153,7 @@ func (r *ReceiptSQLRepository) GetByID(ctx context.Context, id int64) (*models.R street_to, house_to, kod_soato, oblast_soato, rayon_soato, selsovet_soato, doc_num, skno_number, unp, - success, raw_json + success FROM receipts WHERE id = ? `, id).Scan( @@ -209,7 +217,7 @@ func (r *ReceiptSQLRepository) GetByID(ctx context.Context, id int64) (*models.R defer rows.Close() for rows.Next() { - var p models.Position + var p domain.Position if err := rows.Scan( &p.SectionNumber, &p.GTINCode, @@ -230,7 +238,7 @@ func (r *ReceiptSQLRepository) GetByID(ctx context.Context, id int64) (*models.R return &receipt, nil } -func (r *ReceiptSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*models.Receipt, error) { +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 @@ -243,10 +251,10 @@ func (r *ReceiptSQLRepository) GetAll(ctx context.Context, limit, offset int) ([ } defer rows.Close() - var receipts []*models.Receipt + var receipts []*domain.Receipt for rows.Next() { - var rct models.Receipt + var rct domain.Receipt if err := rows.Scan( &rct.ID, &rct.ReceiptNumber, @@ -262,7 +270,7 @@ func (r *ReceiptSQLRepository) GetAll(ctx context.Context, limit, offset int) ([ return receipts, nil } -func (r *ReceiptSQLRepository) Update(ctx context.Context, receipt *models.Receipt) error { +func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Receipt) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -305,7 +313,7 @@ func (r *ReceiptSQLRepository) Update(ctx context.Context, receipt *models.Recei return tx.Commit() } -func (r *ReceiptSQLRepository) Delete(ctx context.Context, id int64) error { +func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM receipts WHERE id = ?`, id, diff --git a/src/repositories/users.go b/src/repositories/users.go new file mode 100644 index 0000000..a21a1af --- /dev/null +++ b/src/repositories/users.go @@ -0,0 +1,158 @@ +package repositories + +import ( + "FamilyHub/src/domain" + "context" + "database/sql" + "errors" +) + +type UsersRepository interface { + Create(ctx context.Context, user *domain.User) error + GetByID(ctx context.Context, id int64) (*domain.User, error) + GetByTelegramID(ctx context.Context, telegramID int64) (*domain.User, error) + Update(ctx context.Context, user *domain.User) 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.User) 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.User, 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.User + + 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.User, 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.User + + 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.User) 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 +}