├── .gitignore ├── Dockerfile ├── LICENSE ├── README.fr.md ├── README.md ├── README.ru.md ├── config └── settings.go ├── go.mod ├── go.sum ├── internal ├── entities │ └── task.go ├── server │ ├── auth.go │ └── task.go ├── service │ └── task.go └── storage │ └── sqlite │ ├── database.go │ └── task.go ├── main.go ├── middleware └── auth.go ├── models └── task.go ├── routes └── routes.go ├── tests ├── addtask_4_test.go ├── app_1_test.go ├── db_2_test.go ├── nextdate_3_test.go ├── settings.go ├── task_6_test.go ├── task_7_test.go └── tasks_5_test.go ├── utils └── responses.go └── web ├── css ├── style.css └── theme.css ├── favicon.ico ├── index.html ├── js ├── axios.min.js └── scripts.min.js └── login.html /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.db -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22.2 as builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN apt-get update && apt-get install -y --no-install-recommends \ 8 | gcc libc6-dev \ 9 | && apt-get clean && rm -rf /var/lib/apt/lists/* 10 | 11 | RUN go mod download 12 | 13 | RUN CGO_ENABLED=1 GOOS=linux go build -o main . 14 | 15 | ENV CGO_ENABLED=1 16 | ENV GOOS=linux 17 | 18 | RUN go build -o main . 19 | 20 | FROM ubuntu:latest 21 | 22 | RUN apt-get update && apt-get install -y --no-install-recommends \ 23 | ca-certificates \ 24 | && apt-get clean && rm -rf /var/lib/apt/lists/* 25 | 26 | WORKDIR /app 27 | 28 | COPY --from=builder /app/main . 29 | COPY --from=builder /app/web ./web 30 | 31 | RUN chmod +x /app/main 32 | 33 | ENV TODO_PORT=7540 34 | ENV TODO_DBFILE=scheduler.db 35 | ENV TODO_PASSWORD=test12345 36 | 37 | EXPOSE 7540 38 | 39 | CMD ["/app/main"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anton Kazachenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.fr.md: -------------------------------------------------------------------------------- 1 | # Go Todo List API 2 | 3 | #### [English](README.md) | Français | [Русский](README.ru.md) 4 | ### Consultez la [démo en ligne](https://go-todo-list-api.onrender.com/) (mot de passe : `test12345`) 5 | 6 | **Veuillez noter que la démo en ligne est hébergée sur un plan gratuit, il va donc falloir un certain temps pour que le serveur se lance lorsque le site est accédé.** 7 | 8 | ## Aperçu du projet 9 | Ce projet est une API simple de liste de tâches construite en Go. Il fournit un service backend permettant aux utilisateurs de créer, lire, 10 | mettre à jour et supprimer des tâches. L'application utilise des JSON Web Tokens (JWT) pour une authentification sécurisée et SQLite pour un stockage persistant des données. En plus de la gestion basique des tâches, l'API prend également en charge la planification des tâches avec des intervalles de répétition personnalisés. 11 | 12 | L'API est construite en utilisant le modèle d'architecture en couches, avec des couches séparées pour l'API. Il y a 4 couches principales : 13 | - **Controller Layer** : Gère les requêtes HTTP entrantes et les dirige vers le gestionnaire approprié situé dans le répertoire `internal/server`. 14 | - **Service Layer** : Contient la logique métier de l'application située dans le répertoire `internal/service`. 15 | - **Repository Layer** : Gère l'interaction avec la base de données située dans le répertoire `internal/storage`. 16 | - **Entities Layer** : Contient les entités de données utilisées par l'application situées dans le répertoire `internal/models`. 17 | 18 | En outre, l'application dispose d'une interface web simple pour interagir avec l'API. L'interface web est construite en utilisant HTML, CSS et JavaScript, qui sont minifiés et situés dans le répertoire `web/`. 19 | 20 | Il y a un Dockerfile dans le répertoire racine du projet qui peut être utilisé pour créer une image Docker de l'application. Le Dockerfile utilise une construction multi-étapes pour créer une image légère avec le binaire de l'application et les fichiers nécessaires. 21 | 22 | ## Fonctionnalités 23 | - Gestion des tâches : Créer, lire, mettre à jour et supprimer des tâches. 24 | - Planification des tâches : Planifier des tâches pour des dates futures avec la possibilité de définir un intervalle de répétition personnalisé. 25 | - Authentification : Connexion sécurisée avec JWT. 26 | - Stockage persistant avec SQLite. 27 | - API RESTful construite avec le routeur Chi. 28 | 29 | ## Dépendances 30 | Le projet utilise les dépendances suivantes : 31 | - **Chi Router** : Routage léger et idiomatique en Go (`github.com/go-chi/chi/v5`) 32 | - **JWT** : Gestion de l'authentification (`github.com/golang-jwt/jwt/v4`) 33 | - **SQLx** : Outils SQL pour Go (`github.com/jmoiron/sqlx`) 34 | - **SQLite3** : Pilote de base de données (`github.com/mattn/go-sqlite3`) 35 | - **Testify** : Utilitaires de test (`github.com/stretchr/testify`) 36 | 37 | **Remarque :** Vous avez besoin de la version **1.22.2** de Go ou supérieure pour exécuter l'application. 38 | 39 | ## Installation 40 | 1. Clonez le dépôt : 41 | ```bash 42 | git clone https://github.com/antonkazachenko/go-todo-list-api.git 43 | ``` 44 | 45 | 2. Accédez au répertoire du projet : 46 | ```bash 47 | cd go-todo-list-api 48 | ``` 49 | 50 | 3. Installez les dépendances : 51 | ```bash 52 | go mod tidy 53 | ``` 54 | 55 | 56 | ## Variables d'environnement 57 | Pour exécuter le serveur localement, vous pouvez configurer les variables d'environnement suivantes : 58 | 59 | - `TODO_DBFILE` : Chemin vers le fichier de base de données SQLite (par défaut : `scheduler.db`) 60 | - `TODO_PORT` : Port sur lequel le serveur s'exécutera (par défaut : `7540`) 61 | - `TODO_PASSWORD` : Mot de passe utilisé pour la signature JWT (par défaut : vide) 62 | 63 | Vous pouvez définir ces variables d'environnement dans votre shell avant d'exécuter l'application : 64 | 65 | ```bash 66 | export TODO_DBFILE="your_db_file.db" 67 | export TODO_PORT="your_port_number" 68 | export TODO_PASSWORD="your_password" 69 | ``` 70 | 71 | Si vous ne définissez pas les variables d'environnement, l'application utilisera les valeurs par défaut. 72 | 73 | ## Utilisation 74 | 1. Construisez et exécutez le projet : 75 | ```bash 76 | go run main.go 77 | ``` 78 | 79 | 2. Accédez à l'API via `http://localhost:PORT/` (Remplacez `PORT` par le port réel spécifié dans votre configuration ou le port par défaut `7540`). 80 | 81 | ## Points de terminaison de l'API 82 | Voici un aperçu des principaux points de terminaison de l'API : 83 | 84 | - **POST /api/task** - Créer une nouvelle tâche. 85 | - **GET /api/tasks** - Obtenir toutes les tâches. 86 | - **GET /api/task** - Obtenir une tâche spécifique. 87 | - **PUT /api/task** - Mettre à jour une tâche spécifique. 88 | - **DELETE /api/task** - Supprimer une tâche spécifique. 89 | - **POST /api/task/done** - Marquer une tâche comme terminée. 90 | - **POST /api/signin** - Connexion utilisateur. 91 | 92 | ## Authentification 93 | L'authentification dans cette application est gérée à l'aide de JSON Web Tokens (JWT). Après une connexion réussie, un JWT est généré et retourné à l'utilisateur. Cette fonctionnalité peut être vue dans l'onglet réseau des outils de développement du navigateur. 94 | 95 | Si vous ne configurez pas la variable d'environnement `TODO_PASSWORD`, l'application n'utilisera pas JWT pour l'authentification. 96 | 97 | ## Tests 98 | - Le projet utilise Testify pour les tests unitaires. 99 | - Les tests sont situés dans le répertoire `tests/`. 100 | - Exécutez les tests avec : 101 | ```bash 102 | go test ./tests 103 | ``` 104 | 105 | 106 | ## Contribution 107 | Les contributions sont les bienvenues ! N'hésitez pas à soumettre une Pull Request. 108 | 109 | ## Licence 110 | Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails. 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Todo List API 2 | 3 | #### English | [Français](README.fr.md) | [Русский](README.ru.md) 4 | ### Checkout the [Live Demo](https://go-todo-list-api.onrender.com/) (password: `test12345`) 5 | 6 | **Please note that the live demo is hosted on a free Render plan, so it may take some time for the server to start up when accessed.** 7 | 8 | ## Project Overview 9 | This project is a simple todo list API built in Go. It provides a backend service that allows users to create, read, 10 | update and delete tasks. The application uses JSON Web Tokens (JWT) for secure authentication and SQLite for persistent 11 | data storage. In addition to basic task management, the API also supports scheduling tasks with custom repeat intervals. 12 | 13 | The API is built using the layered architecture pattern, with separate layers for the API. The are 4 main layers: 14 | - **Controller Layer**: Handles incoming HTTP requests and routes them to the appropriate handler located in the `internal/server` directory. 15 | - **Service Layer**: Contains the business logic for the application located in the `internal/service` directory. 16 | - **Repository Layer**: Handles the interaction with the database located in the `internal/storage` directory. 17 | - **Entities Layer**: Contains the data entities used by the application located in the `internal/models` directory. 18 | 19 | In addition, app has a simple web interface to interact with the API. The web interface is built using HTML, CSS, and JavaScript which 20 | is minified and located in the `web/` directory. 21 | 22 | There is a Dockerfile in the project root directory that can be used to build a Docker image of the application. The dockerfile 23 | uses a multi-stage build to create a lightweight image with the application binary and the necessary files. 24 | 25 | ## Features 26 | - Task management: Create, read, update and delete tasks. 27 | - Task scheduling: Schedule tasks for future dates with the ability to set up a custom repeat interval. 28 | - Authentication: Secure login with JWT. 29 | - Persistent storage using SQLite. 30 | - RESTful API built with the Chi router. 31 | 32 | ## Dependencies 33 | The project uses the following dependencies: 34 | - **Chi Router**: Lightweight and idiomatic routing in Go (`github.com/go-chi/chi/v5`) 35 | - **JWT**: Handling authentication (`github.com/golang-jwt/jwt/v4`) 36 | - **SQLx**: SQL toolkit for Go (`github.com/jmoiron/sqlx`) 37 | - **SQLite3**: Database driver (`github.com/mattn/go-sqlite3`) 38 | - **Testify**: Testing utilities (`github.com/stretchr/testify`) 39 | 40 | **Note:** You need Go version **1.22.2** or higher to run the application. 41 | 42 | ## Installation 43 | 1. Clone the repository: 44 | ```bash 45 | git clone https://github.com/antonkazachenko/go-todo-list-api.git 46 | ``` 47 | 2. Navigate to the project directory: 48 | ```bash 49 | cd go-todo-list-api 50 | ``` 51 | 3. Install the dependencies: 52 | ```bash 53 | go mod tidy 54 | ``` 55 | 56 | ## Environment Variables 57 | To run the server locally, you can set up the following environment variables: 58 | 59 | - `TODO_DBFILE`: Path to the SQLite database file (default: `scheduler.db`) 60 | - `TODO_PORT`: Port on which the server will run (default: `7540`) 61 | - `TODO_PASSWORD`: Password used for JWT signing (default: empty) 62 | 63 | You can set these environment variables in your shell before running the application: 64 | 65 | ```bash 66 | export TODO_DBFILE="your_db_file.db" 67 | export TODO_PORT="your_port_number" 68 | export TODO_PASSWORD="your_password" 69 | ``` 70 | 71 | In case if you don't set the environment variables, the application will use the default values. 72 | 73 | ## Usage 74 | 1. Build and run the project: 75 | ```bash 76 | go run main.go 77 | ``` 78 | 2. Access the API via `http://localhost:PORT/` (Replace `PORT` with the actual port specified in your configuration or the default port `7540`). 79 | 80 | ## API Endpoints 81 | Here is a brief overview of the main API endpoints: 82 | 83 | - **POST /api/task** - Create a new task. 84 | - **GET /api/tasks** - Get all tasks. 85 | - **GET /api/task** - Get a specific task. 86 | - **PUT /api/task** - Update a specific task. 87 | - **DELETE /api/task** - Delete a specific task. 88 | - **POST /api/task/done** - Mark a task as done. 89 | - **POST /api/signin** - User login. 90 | 91 | ## Authentication 92 | Authentication in this application is handled using JSON Web Tokens (JWT). Upon successful login, a JWT is generated and 93 | returned to the user. This functionality can be seen in the network tab in the browser's developer tools. 94 | 95 | If you don't set up the `TODO_PASSWORD` environment variable, the application will not use JWT for authentication. 96 | 97 | 98 | ## Testing 99 | - The project uses Testify for unit testing. 100 | - Tests are located in the `tests/` directory. 101 | - Run the tests using: 102 | ```bash 103 | go test ./tests 104 | ``` 105 | 106 | ## Contributing 107 | Contributions are welcome! Please feel free to submit a Pull Request. 108 | 109 | ## License 110 | This project is licensed under the MIT License. See the `LICENSE` file for details. 111 | -------------------------------------------------------------------------------- /README.ru.md: -------------------------------------------------------------------------------- 1 | # Go Todo List API 2 | 3 | #### [English](README.md) | [Français](README.fr.md) | Русский 4 | ### Посмотрите [демо-версию](https://go-todo-list-api.onrender.com/) (пароль: `test12345`) 5 | 6 | **Обратите внимание, что демо-версия размещена на бесплатном плане Render, поэтому может потребоваться некоторое время для запуска сервера при обращении к нему.** 7 | 8 | ## О проекте 9 | Этот проект представляет собой простую API для управления списком задач, написанную на Go. Она предоставляет бэкенд-сервис, который позволяет пользователям создавать, читать, обновлять и удалять задачи. Приложение использует JSON Web Tokens (JWT) для безопасной аутентификации и SQLite для постоянного хранения данных. В дополнение к базовому управлению задачами, API также поддерживает планирование задач с пользовательскими интервалами повторения. 10 | 11 | API построена с использованием многослойной архитектуры, с разделением на отдельные слои для API. Основные 4 слоя: 12 | - **Слой контроллера**: Обрабатывает входящие HTTP-запросы и направляет их к соответствующему обработчику, находящемуся в каталоге `internal/server`. 13 | - **Слой сервиса**: Содержит бизнес-логику приложения, находящуюся в каталоге `internal/service`. 14 | - **Слой репозитория**: Обрабатывает взаимодействие с базой данных, находящейся в каталоге `internal/storage`. 15 | - **Слой сущностей**: Содержит сущности данных, используемые приложением, находящиеся в каталоге `internal/models`. 16 | 17 | Кроме того, приложение имеет простой веб-интерфейс для взаимодействия с API. Веб-интерфейс создан с использованием HTML, CSS и JavaScript, которые минимизированы и находятся в каталоге `web/`. 18 | 19 | В корневом каталоге проекта находится Dockerfile, который можно использовать для создания Docker-образа приложения. Dockerfile использует многоступенчатую сборку для создания легкого образа с бинарным файлом приложения и необходимыми файлами. 20 | 21 | ## Возможности 22 | - Управление задачами: создание, чтение, обновление и удаление задач. 23 | - Планирование задач: планирование задач на будущие даты с возможностью настройки интервала повторения. 24 | - Аутентификация: безопасный вход с использованием JWT. 25 | - Постоянное хранение данных с использованием SQLite. 26 | - RESTful API, построенное на маршрутизаторе Chi. 27 | 28 | ## Зависимости 29 | Проект использует следующие зависимости: 30 | - **Chi Router**: Легкий и идиоматичный маршрутизатор для Go (`github.com/go-chi/chi/v5`) 31 | - **JWT**: Обработка аутентификации (`github.com/golang-jwt/jwt/v4`) 32 | - **SQLx**: Набор инструментов для работы с SQL в Go (`github.com/jmoiron/sqlx`) 33 | - **SQLite3**: Драйвер для базы данных (`github.com/mattn/go-sqlite3`) 34 | - **Testify**: Утилиты для тестирования (`github.com/stretchr/testify`) 35 | 36 | **Примечание:** Для запуска приложения вам понадобится версия Go **1.22.2** или выше. 37 | 38 | ## Установка 39 | 1. Клонируйте репозиторий: 40 | ```bash 41 | git clone https://github.com/antonkazachenko/go-todo-list-api.git 42 | ``` 43 | 44 | 2. Перейдите в каталог проекта: 45 | ```bash 46 | cd go-todo-list-api 47 | ``` 48 | 49 | 3. Установите зависимости: 50 | ```bash 51 | go mod tidy 52 | ``` 53 | 54 | ## Переменные окружения 55 | Для локального запуска сервера вы можете настроить следующие переменные окружения: 56 | 57 | - `TODO_DBFILE`: Путь к файлу базы данных SQLite (по умолчанию: `scheduler.db`) 58 | - `TODO_PORT`: Порт, на котором будет работать сервер (по умолчанию: `7540`) 59 | - `TODO_PASSWORD`: Пароль, используемый для подписи JWT (по умолчанию: пустой) 60 | 61 | Вы можете установить эти переменные окружения в вашем шелле перед запуском приложения: 62 | 63 | ```bash 64 | export TODO_DBFILE="your_db_file.db" 65 | export TODO_PORT="your_port_number" 66 | export TODO_PASSWORD="your_password" 67 | ``` 68 | 69 | Если вы не установите переменные окружения, приложение будет использовать значения по умолчанию. 70 | 71 | ## Использование 72 | 1. Соберите и запустите проект: 73 | ```bash 74 | go run main.go 75 | ``` 76 | 77 | 2. Доступ к API осуществляется по адресу `http://localhost:PORT/` (Замените `PORT` на фактический порт, указанный в вашей конфигурации, или используйте порт по умолчанию `7540`). 78 | 79 | ## Эндпоинты API 80 | Вот краткий обзор основных конечных точек API: 81 | 82 | - **POST /api/task** - Создать новую задачу. 83 | - **GET /api/tasks** - Получить все задачи. 84 | - **GET /api/task** - Получить конкретную задачу. 85 | - **PUT /api/task** - Обновить конкретную задачу. 86 | - **DELETE /api/task** - Удалить конкретную задачу. 87 | - **POST /api/task/done** - Отметить задачу как выполненную. 88 | - **POST /api/signin** - Вход пользователя. 89 | 90 | ## Аутентификация 91 | Аутентификация в этом приложении осуществляется с помощью JSON Web Tokens (JWT). После успешного входа JWT генерируется и возвращается пользователю. Эта функциональность может быть видна на вкладке сети в инструментах разработчика браузера. 92 | 93 | Если вы не настроите переменную окружения `TODO_PASSWORD`, приложение не будет использовать JWT для аутентификации. 94 | 95 | ## Тестирование 96 | - Проект использует Testify для модульного тестирования. 97 | - Тесты находятся в каталоге `tests/`. 98 | - Запустите тесты с помощью: 99 | ```bash 100 | go test ./tests 101 | ``` 102 | 103 | ## Помощь и улучшения 104 | Приветствуются любые ваши замечания! Пожалуйста, не стесняйтесь cоздавать и отправлять Pull Request. 105 | 106 | ## Лицензия 107 | Этот проект лицензирован под лицензией MIT. См. файл `LICENSE` для получения подробной информации. 108 | -------------------------------------------------------------------------------- /config/settings.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var ( 8 | TODO_DBFILE = getEnv("TODO_DBFILE", "scheduler.db") 9 | TODO_PORT = getEnv("TODO_PORT", "7540") 10 | TODO_PASS = getEnv("TODO_PASSWORD", "") 11 | ) 12 | 13 | func getEnv(key, defaultValue string) string { 14 | if value, exists := os.LookupEnv(key); exists { 15 | return value 16 | } 17 | return defaultValue 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/antonkazachenko/go-todo-list-api 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.1.0 7 | github.com/golang-jwt/jwt/v4 v4.5.0 8 | github.com/jmoiron/sqlx v1.4.0 9 | github.com/mattn/go-sqlite3 v1.14.22 10 | github.com/stretchr/testify v1.9.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 6 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 7 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 8 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 9 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 10 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 11 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 12 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 13 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 14 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 20 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /internal/entities/task.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | type Task struct { 4 | ID string `json:"id"` 5 | Date string `json:"date,omitempty"` 6 | Title string `json:"title"` 7 | Comment string `json:"comment,omitempty"` 8 | Repeat string `json:"repeat,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /internal/server/auth.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/antonkazachenko/go-todo-list-api/config" 10 | "github.com/antonkazachenko/go-todo-list-api/models" 11 | "github.com/antonkazachenko/go-todo-list-api/utils" 12 | "github.com/golang-jwt/jwt/v4" 13 | ) 14 | 15 | func (h *Handlers) HandleSignIn(res http.ResponseWriter, req *http.Request) { 16 | pass := config.TODO_PASS 17 | if len(pass) == 0 { 18 | utils.SendErrorResponse(res, "пароль не установлен", http.StatusInternalServerError) 19 | return 20 | } 21 | 22 | var buf bytes.Buffer 23 | _, err := buf.ReadFrom(req.Body) 24 | if err != nil { 25 | utils.SendErrorResponse(res, "ошибка чтения тела запроса", http.StatusBadRequest) 26 | return 27 | } 28 | 29 | var body map[string]string 30 | err = json.Unmarshal(buf.Bytes(), &body) 31 | if err != nil { 32 | utils.SendErrorResponse(res, "ошибка декодирования JSON", http.StatusBadRequest) 33 | return 34 | } 35 | 36 | if body["password"] != pass { 37 | utils.SendErrorResponse(res, "неверный пароль", http.StatusUnauthorized) 38 | return 39 | } 40 | 41 | token := jwt.New(jwt.SigningMethodHS256) 42 | tokenString, err := token.SignedString([]byte(pass)) 43 | if err != nil { 44 | utils.SendErrorResponse(res, "ошибка создания токена", http.StatusInternalServerError) 45 | return 46 | } 47 | 48 | var resp models.AuthResponse 49 | resp.Token = tokenString 50 | respBytes, err := json.Marshal(resp) 51 | if err != nil { 52 | http.Error(res, "ошибка при сериализации ответа", http.StatusInternalServerError) 53 | return 54 | } 55 | res.Header().Set("Content-Type", "application/json") 56 | res.WriteHeader(http.StatusOK) 57 | _, err = res.Write(respBytes) 58 | if err != nil { 59 | fmt.Println("ошибка записи ответа в HandleSignIn", err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/server/task.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/antonkazachenko/go-todo-list-api/internal/entities" 13 | "github.com/antonkazachenko/go-todo-list-api/internal/service" 14 | "github.com/antonkazachenko/go-todo-list-api/models" 15 | "github.com/antonkazachenko/go-todo-list-api/utils" 16 | ) 17 | 18 | type Handlers struct { 19 | TaskService *service.TaskService 20 | } 21 | 22 | func NewHandlers(taskService *service.TaskService) *Handlers { 23 | return &Handlers{TaskService: taskService} 24 | } 25 | 26 | func (h *Handlers) HandleAddTask(res http.ResponseWriter, req *http.Request) { 27 | var task entities.Task 28 | if err := parseRequestBody(req, &task); err != nil { 29 | utils.SendErrorResponse(res, "ошибка декодирования JSON", http.StatusBadRequest) 30 | return 31 | } 32 | 33 | if task.Title == "" { 34 | utils.SendErrorResponse(res, "отсутствует обязательное поле title", http.StatusBadRequest) 35 | return 36 | } 37 | 38 | if err := h.validateAndUpdateDate(&task); err != nil { 39 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 40 | return 41 | } 42 | 43 | id, err := h.TaskService.Repo.AddTask(task) 44 | if err != nil { 45 | utils.SendErrorResponse(res, "ошибка запроса к базе данных", http.StatusInternalServerError) 46 | return 47 | } 48 | 49 | sendJSONResponse(res, http.StatusOK, models.IDResponse{ID: id}) 50 | } 51 | 52 | func (h *Handlers) HandleGetTasks(res http.ResponseWriter, req *http.Request) { 53 | searchTerm := req.URL.Query().Get("search") 54 | limit := 100 55 | 56 | tasks, err := h.TaskService.Repo.GetTasks(searchTerm, limit) 57 | if err != nil { 58 | utils.SendErrorResponse(res, "ошибка запроса к базе данных", http.StatusInternalServerError) 59 | return 60 | } 61 | 62 | if tasks == nil || len(tasks) == 0 { 63 | tasks = []entities.Task{} 64 | } 65 | 66 | sendJSONResponse(res, http.StatusOK, map[string][]entities.Task{"tasks": tasks}) 67 | } 68 | 69 | func (h *Handlers) HandleGetTask(res http.ResponseWriter, req *http.Request) { 70 | taskID, err := parseAndValidateID(req.URL.Query().Get("id")) 71 | if err != nil { 72 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 73 | return 74 | } 75 | 76 | task, err := h.TaskService.Repo.GetTaskByID(taskID) 77 | if err != nil { 78 | utils.SendErrorResponse(res, "задача с указанным id не найдена", http.StatusNotFound) 79 | return 80 | } 81 | 82 | sendJSONResponse(res, http.StatusOK, task) 83 | } 84 | 85 | func (h *Handlers) HandlePutTask(res http.ResponseWriter, req *http.Request) { 86 | var taskUpdates map[string]interface{} 87 | if err := parseRequestBody(req, &taskUpdates); err != nil { 88 | utils.SendErrorResponse(res, "ошибка декодирования JSON", http.StatusBadRequest) 89 | return 90 | } 91 | 92 | _, err := h.validateAndExtractID(taskUpdates) 93 | if err != nil { 94 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 95 | return 96 | } 97 | 98 | if err := h.validateTaskUpdates(taskUpdates); err != nil { 99 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 100 | return 101 | } 102 | 103 | if _, err := h.TaskService.Repo.UpdateTask(taskUpdates); err != nil { 104 | utils.SendErrorResponse(res, "ошибка запроса к базе данных", http.StatusInternalServerError) 105 | return 106 | } 107 | 108 | sendJSONResponse(res, http.StatusOK, map[string]interface{}{}) 109 | } 110 | 111 | func (h *Handlers) HandleDeleteTask(res http.ResponseWriter, req *http.Request) { 112 | taskID, err := parseAndValidateID(req.URL.Query().Get("id")) 113 | if err != nil { 114 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 115 | return 116 | } 117 | 118 | if err := h.deleteTaskIfExists(taskID); err != nil { 119 | utils.SendErrorResponse(res, err.Error(), http.StatusInternalServerError) 120 | return 121 | } 122 | 123 | sendJSONResponse(res, http.StatusOK, map[string]interface{}{}) 124 | } 125 | 126 | func (h *Handlers) HandleDoneTask(res http.ResponseWriter, req *http.Request) { 127 | taskID, err := parseAndValidateID(req.URL.Query().Get("id")) 128 | if err != nil { 129 | utils.SendErrorResponse(res, err.Error(), http.StatusBadRequest) 130 | return 131 | } 132 | 133 | task, err := h.TaskService.Repo.GetTaskByID(taskID) 134 | if err != nil { 135 | utils.SendErrorResponse(res, "задача с указанным id не найдена", http.StatusNotFound) 136 | return 137 | } 138 | 139 | if task.Repeat == "" { 140 | if _, err := h.TaskService.Repo.DeleteTask(taskID); err != nil { 141 | utils.SendErrorResponse(res, "ошибка запроса к базе данных", http.StatusInternalServerError) 142 | return 143 | } 144 | } else { 145 | if err := h.markTaskAsDone(taskID, task); err != nil { 146 | utils.SendErrorResponse(res, err.Error(), http.StatusInternalServerError) 147 | return 148 | } 149 | } 150 | 151 | sendJSONResponse(res, http.StatusOK, map[string]interface{}{}) 152 | } 153 | 154 | func (h *Handlers) HandleNextDate(res http.ResponseWriter, req *http.Request) { 155 | nowParam := req.URL.Query().Get("now") 156 | date := req.URL.Query().Get("date") 157 | repeat := req.URL.Query().Get("repeat") 158 | 159 | now, err := time.Parse(service.Format, nowParam) 160 | if err != nil { 161 | http.Error(res, "Неправильный формат парамeтра now", http.StatusBadRequest) 162 | return 163 | } 164 | 165 | newDate, err := h.TaskService.NextDate(now, date, repeat) 166 | if err != nil { 167 | http.Error(res, err.Error(), http.StatusBadRequest) 168 | return 169 | } 170 | 171 | res.Header().Set("Content-Type", "text/plain") 172 | res.WriteHeader(http.StatusOK) 173 | _, err = res.Write([]byte(newDate)) 174 | if err != nil { 175 | fmt.Printf("Error in writing a response for /api/nextdate GET request,\n %v", err) 176 | return 177 | } 178 | } 179 | 180 | func parseRequestBody(req *http.Request, target interface{}) error { 181 | var buf bytes.Buffer 182 | if _, err := buf.ReadFrom(req.Body); err != nil { 183 | return fmt.Errorf("ошибка чтения тела запроса") 184 | } 185 | return json.Unmarshal(buf.Bytes(), target) 186 | } 187 | 188 | func sendJSONResponse(res http.ResponseWriter, statusCode int, data interface{}) { 189 | respBytes, err := json.Marshal(data) 190 | if err != nil { 191 | utils.SendErrorResponse(res, "ошибка при сериализации ответа", http.StatusInternalServerError) 192 | return 193 | } 194 | res.Header().Set("Content-Type", "application/json") 195 | res.WriteHeader(statusCode) 196 | _, err = res.Write(respBytes) 197 | if err != nil { 198 | utils.SendErrorResponse(res, "ошибка записи ответа", http.StatusInternalServerError) 199 | return 200 | } 201 | } 202 | 203 | func parseAndValidateID(idStr string) (string, error) { 204 | if idStr == "" { 205 | return "", fmt.Errorf("не передан идентификатор") 206 | } 207 | 208 | if _, err := strconv.ParseInt(idStr, 10, 64); err != nil { 209 | return "", fmt.Errorf("id должен быть числом") 210 | } 211 | return idStr, nil 212 | } 213 | 214 | func (h *Handlers) validateAndExtractID(taskUpdates map[string]interface{}) (string, error) { 215 | id, ok := taskUpdates["id"].(string) 216 | if !ok || id == "" { 217 | return "", fmt.Errorf("отсутствует обязательное поле id") 218 | } 219 | 220 | if _, err := strconv.ParseInt(id, 10, 64); err != nil { 221 | return "", fmt.Errorf("id должен быть числом") 222 | } 223 | 224 | if _, err := h.TaskService.Repo.GetTaskByID(id); err != nil { 225 | return "", fmt.Errorf("задача с указанным id не найдена") 226 | } 227 | 228 | return id, nil 229 | } 230 | 231 | func (h *Handlers) validateTaskUpdates(taskUpdates map[string]interface{}) error { 232 | date, dateOk := taskUpdates["date"].(string) 233 | if title, ok := taskUpdates["title"].(string); !ok || strings.TrimSpace(title) == "" { 234 | return fmt.Errorf("отсутствует обязательное поле title") 235 | } 236 | 237 | if !dateOk || strings.TrimSpace(date) == "" { 238 | return fmt.Errorf("отсутствует обязательное поле date") 239 | } 240 | 241 | if _, err := time.Parse(service.Format, date); err != nil { 242 | return fmt.Errorf("недопустимый формат date") 243 | } 244 | 245 | if repeat, ok := taskUpdates["repeat"].(string); ok && strings.TrimSpace(repeat) != "" { 246 | repeatParts := strings.SplitN(repeat, " ", 2) 247 | repeatType := repeatParts[0] 248 | if !isValidRepeatType(repeatType) { 249 | return fmt.Errorf("недопустимый символ") 250 | } 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func (h *Handlers) validateAndUpdateDate(task *entities.Task) error { 257 | var dateInTime time.Time 258 | var err error 259 | 260 | if task.Date != "" { 261 | dateInTime, err = time.Parse(service.Format, task.Date) 262 | if err != nil { 263 | return fmt.Errorf("недопустимый формат date") 264 | } 265 | } else { 266 | task.Date = time.Now().Format(service.Format) 267 | dateInTime = time.Now() 268 | } 269 | 270 | if time.Now().After(dateInTime) { 271 | if task.Repeat == "" { 272 | task.Date = time.Now().Format(service.Format) 273 | dateInTime = time.Now() 274 | } else { 275 | task.Date, err = h.TaskService.NextDate(time.Now(), task.Date, task.Repeat) 276 | if err != nil { 277 | return err 278 | } 279 | } 280 | } 281 | 282 | return nil 283 | } 284 | 285 | func (h *Handlers) markTaskAsDone(taskID string, task *entities.Task) error { 286 | parsedDate, err := time.Parse(service.Format, task.Date) 287 | if err != nil { 288 | return fmt.Errorf("недопустимый формат date") 289 | } 290 | 291 | if parsedDate.Format(service.Format) == time.Now().Format(service.Format) { 292 | parsedDate = parsedDate.AddDate(0, 0, -1) 293 | task.Date, err = h.TaskService.NextDate(parsedDate, task.Date, task.Repeat) 294 | } else { 295 | task.Date, err = h.TaskService.NextDate(time.Now(), task.Date, task.Repeat) 296 | } 297 | 298 | if err != nil { 299 | return err 300 | } 301 | 302 | if err := h.TaskService.Repo.MarkTaskAsDone(taskID, task.Date); err != nil { 303 | return fmt.Errorf("ошибка при обновлении задачи") 304 | } 305 | 306 | return nil 307 | } 308 | 309 | func (h *Handlers) deleteTaskIfExists(taskID string) error { 310 | if _, err := h.TaskService.Repo.GetTaskByID(taskID); err != nil { 311 | return fmt.Errorf("задача с указанным id не найдена") 312 | } 313 | 314 | if _, err := h.TaskService.Repo.DeleteTask(taskID); err != nil { 315 | return fmt.Errorf("ошибка запроса к базе данных") 316 | } 317 | 318 | return nil 319 | } 320 | 321 | func isValidRepeatType(repeatType string) bool { 322 | validTypes := []string{"d", "w", "m", "y"} 323 | for _, v := range validTypes { 324 | if repeatType == v { 325 | return true 326 | } 327 | } 328 | return false 329 | } 330 | -------------------------------------------------------------------------------- /internal/service/task.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | storage "github.com/antonkazachenko/go-todo-list-api/internal/storage/sqlite" 10 | ) 11 | 12 | const Format = "20060102" 13 | 14 | type TaskService struct { 15 | Repo *storage.SQLiteTaskRepository 16 | } 17 | 18 | func NewTaskService(repo *storage.SQLiteTaskRepository) *TaskService { 19 | return &TaskService{Repo: repo} 20 | } 21 | 22 | func (s *TaskService) NextDate(now time.Time, date string, repeat string) (string, error) { 23 | parsedDate, err := time.Parse(Format, date) 24 | if err != nil { 25 | return "", errors.New("недопустимый формат date") 26 | } 27 | 28 | repeatType, repeatRule := parseRepeatRule(repeat) 29 | 30 | switch repeatType { 31 | case "d": 32 | return calculateDailyRepeat(now, parsedDate, repeatRule) 33 | case "y": 34 | return calculateYearlyRepeat(now, parsedDate) 35 | case "w": 36 | return calculateWeeklyRepeat(now, parsedDate, repeatRule) 37 | case "m": 38 | return calculateMonthlyRepeat(now, parsedDate, repeatRule) 39 | default: 40 | return "", errors.New("недопустимый символ") 41 | } 42 | } 43 | 44 | func parseRepeatRule(repeat string) (string, string) { 45 | repeatParts := strings.SplitN(repeat, " ", 2) 46 | repeatType := "" 47 | repeatRule := "" 48 | 49 | if len(repeatParts) > 0 { 50 | repeatType = repeatParts[0] 51 | } 52 | if len(repeatParts) > 1 { 53 | repeatRule = repeatParts[1] 54 | } 55 | 56 | return repeatType, repeatRule 57 | } 58 | 59 | func calculateDailyRepeat(now, parsedDate time.Time, repeatRule string) (string, error) { 60 | if repeatRule == "" { 61 | return "", errors.New("не указан интервал в днях") 62 | } 63 | 64 | numberOfDays, err := strconv.Atoi(repeatRule) 65 | if err != nil { 66 | return "", errors.New("некорректно указано правило repeat") 67 | } 68 | 69 | if numberOfDays > 400 { 70 | return "", errors.New("превышен максимально допустимый интервал") 71 | } 72 | 73 | if now.Format(Format) != parsedDate.Format(Format) { 74 | if now.After(parsedDate) { 75 | for now.After(parsedDate) || now.Format(Format) == parsedDate.Format(Format) { 76 | parsedDate = parsedDate.AddDate(0, 0, numberOfDays) 77 | } 78 | } else { 79 | parsedDate = parsedDate.AddDate(0, 0, numberOfDays) 80 | } 81 | } 82 | 83 | return parsedDate.Format(Format), nil 84 | } 85 | 86 | func calculateYearlyRepeat(now, parsedDate time.Time) (string, error) { 87 | parsedDate = parsedDate.AddDate(1, 0, 0) 88 | for now.After(parsedDate) { 89 | parsedDate = parsedDate.AddDate(1, 0, 0) 90 | } 91 | return parsedDate.Format(Format), nil 92 | } 93 | 94 | func calculateWeeklyRepeat(now, parsedDate time.Time, repeatRule string) (string, error) { 95 | daysOfWeek, err := parseDaysOfWeek(repeatRule) 96 | if err != nil { 97 | return "", err 98 | } 99 | 100 | if now.Before(parsedDate) { 101 | for { 102 | parsedDate = parsedDate.AddDate(0, 0, 1) 103 | if daysOfWeek[int(parsedDate.Weekday())] { 104 | break 105 | } 106 | } 107 | } else { 108 | for { 109 | if daysOfWeek[int(parsedDate.Weekday())] { 110 | if now.Before(parsedDate) { 111 | break 112 | } 113 | } 114 | parsedDate = parsedDate.AddDate(0, 0, 1) 115 | } 116 | } 117 | 118 | return parsedDate.Format(Format), nil 119 | } 120 | 121 | func parseDaysOfWeek(repeatRule string) (map[int]bool, error) { 122 | daysOfWeek := make(map[int]bool) 123 | substrings := strings.Split(repeatRule, ",") 124 | 125 | for _, value := range substrings { 126 | number, err := strconv.Atoi(value) 127 | if err != nil { 128 | return nil, errors.New("ошибка конвертации значения дня недели") 129 | } 130 | if number < 1 || number > 7 { 131 | return nil, errors.New("недопустимое значение дня недели") 132 | } 133 | if number == 7 { 134 | number = 0 135 | } 136 | daysOfWeek[number] = true 137 | } 138 | return daysOfWeek, nil 139 | } 140 | 141 | func calculateMonthlyRepeat(now, parsedDate time.Time, repeatRule string) (string, error) { 142 | daysPart, monthsPart := splitMonthRule(repeatRule) 143 | dayMap, err := parseDays(daysPart) 144 | if err != nil { 145 | return "", err 146 | } 147 | monthMap, err := parseMonths(monthsPart) 148 | if err != nil { 149 | return "", err 150 | } 151 | 152 | if now.Before(parsedDate) { 153 | for { 154 | parsedDate = parsedDate.AddDate(0, 0, 1) 155 | if isValidDateForMonthlyRepeat(parsedDate, dayMap, monthMap) { 156 | break 157 | } 158 | } 159 | } else { 160 | for { 161 | if isValidDateForMonthlyRepeat(parsedDate, dayMap, monthMap) { 162 | if now.Before(parsedDate) { 163 | break 164 | } 165 | } 166 | parsedDate = parsedDate.AddDate(0, 0, 1) 167 | } 168 | } 169 | 170 | return parsedDate.Format(Format), nil 171 | } 172 | 173 | func isValidDateForMonthlyRepeat(parsedDate time.Time, dayMap map[int]bool, monthMap map[int]bool) bool { 174 | month := int(parsedDate.Month()) 175 | 176 | if len(monthMap) > 0 && !monthMap[month] { 177 | return false 178 | } 179 | 180 | lastDayOfMonth := time.Date(parsedDate.Year(), parsedDate.Month()+1, 0, 0, 0, 0, 0, parsedDate.Location()).Day() 181 | for targetDay := range dayMap { 182 | if targetDay > 0 { 183 | if parsedDate.Day() == targetDay { 184 | return true 185 | } 186 | } else if targetDay < 0 { 187 | if parsedDate.Day() == lastDayOfMonth+targetDay+1 { 188 | return true 189 | } 190 | } 191 | } 192 | 193 | return false 194 | } 195 | 196 | func splitMonthRule(repeatRule string) (string, string) { 197 | repeatParts := strings.Split(repeatRule, " ") 198 | daysPart := repeatParts[0] 199 | monthsPart := "" 200 | if len(repeatParts) > 1 { 201 | monthsPart = repeatParts[1] 202 | } 203 | return daysPart, monthsPart 204 | } 205 | 206 | func parseDays(daysPart string) (map[int]bool, error) { 207 | dayMap := make(map[int]bool) 208 | days := strings.Split(daysPart, ",") 209 | 210 | for _, dayStr := range days { 211 | day, err := strconv.Atoi(dayStr) 212 | if err != nil { 213 | return nil, errors.New("ошибка конвертации значения дня месяца") 214 | } 215 | if day < -2 || day > 31 || day == 0 { 216 | return nil, errors.New("недопустимое значение дня месяца") 217 | } 218 | dayMap[day] = true 219 | } 220 | return dayMap, nil 221 | } 222 | 223 | func parseMonths(monthsPart string) (map[int]bool, error) { 224 | monthMap := make(map[int]bool) 225 | months := strings.Split(monthsPart, ",") 226 | 227 | for _, monthStr := range months { 228 | if monthStr != "" { 229 | month, err := strconv.Atoi(monthStr) 230 | if err != nil { 231 | return nil, errors.New("ошибка конвертации значения месяца") 232 | } 233 | if month < 1 || month > 12 { 234 | return nil, errors.New("недопустимое значение месяца") 235 | } 236 | monthMap[month] = true 237 | } 238 | } 239 | return monthMap, nil 240 | } 241 | -------------------------------------------------------------------------------- /internal/storage/sqlite/database.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | 7 | "github.com/antonkazachenko/go-todo-list-api/config" 8 | _ "github.com/mattn/go-sqlite3" 9 | ) 10 | 11 | func InitDB() *sql.DB { 12 | db, err := sql.Open("sqlite3", config.TODO_DBFILE) 13 | if err != nil { 14 | log.Fatalf("Failed to open database: %v", err) 15 | } 16 | 17 | _, err = db.Exec(`CREATE TABLE IF NOT EXISTS scheduler ( 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | date TEXT NOT NULL, 20 | title TEXT NOT NULL CHECK(LENGTH(title) <= 255), 21 | comment TEXT CHECK(LENGTH(comment) <= 1024), 22 | repeat TEXT CHECK(LENGTH(repeat) <= 255) 23 | )`) 24 | if err != nil { 25 | log.Fatalf("Failed to create table: %v", err) 26 | } 27 | 28 | _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_scheduler_date ON scheduler (date)`) 29 | if err != nil { 30 | log.Fatalf("Failed to create index: %v", err) 31 | } 32 | 33 | return db 34 | } 35 | -------------------------------------------------------------------------------- /internal/storage/sqlite/task.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/antonkazachenko/go-todo-list-api/internal/entities" 10 | ) 11 | 12 | type SQLiteTaskRepository struct { 13 | DB *sql.DB 14 | } 15 | 16 | func NewSQLiteTaskRepository(db *sql.DB) *SQLiteTaskRepository { 17 | return &SQLiteTaskRepository{DB: db} 18 | } 19 | 20 | func (r *SQLiteTaskRepository) AddTask(task entities.Task) (int64, error) { 21 | result, err := r.DB.Exec("INSERT INTO scheduler (date, title, comment, repeat) VALUES (?, ?, ?, ?)", 22 | task.Date, task.Title, task.Comment, task.Repeat) 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | return result.LastInsertId() 28 | } 29 | 30 | func (r *SQLiteTaskRepository) GetTasks(searchTerm string, limit int) ([]entities.Task, error) { 31 | query := "SELECT id, date, title, comment, repeat FROM scheduler" 32 | args := []interface{}{} 33 | 34 | parsedDate, dateErr := time.Parse("02.01.2006", searchTerm) 35 | switch { 36 | case dateErr == nil: 37 | formattedDate := parsedDate.Format("20060102") 38 | query += " WHERE date = ? ORDER BY date LIMIT ?" 39 | args = append(args, formattedDate, limit) 40 | case searchTerm != "": 41 | query += " WHERE title LIKE ? OR comment LIKE ? ORDER BY date LIMIT ?" 42 | searchTerm = "%" + searchTerm + "%" 43 | args = append(args, searchTerm, searchTerm, limit) 44 | default: 45 | query += " ORDER BY date LIMIT ?" 46 | args = append(args, limit) 47 | } 48 | 49 | rows, err := r.DB.Query(query, args...) 50 | if err != nil { 51 | return nil, err 52 | } 53 | defer rows.Close() 54 | 55 | var tasks []entities.Task 56 | for rows.Next() { 57 | var task entities.Task 58 | err = rows.Scan(&task.ID, &task.Date, &task.Title, &task.Comment, &task.Repeat) 59 | if err != nil { 60 | return nil, err 61 | } 62 | tasks = append(tasks, task) 63 | } 64 | 65 | return tasks, nil 66 | } 67 | 68 | func (r *SQLiteTaskRepository) GetTaskByID(id string) (*entities.Task, error) { 69 | row := r.DB.QueryRow("SELECT id, date, title, comment, repeat FROM scheduler WHERE id = ?", id) 70 | var task entities.Task 71 | err := row.Scan(&task.ID, &task.Date, &task.Title, &task.Comment, &task.Repeat) 72 | if err != nil { 73 | if err == sql.ErrNoRows { 74 | return nil, errors.New("task not found") 75 | } 76 | return nil, err 77 | } 78 | return &task, nil 79 | } 80 | 81 | func (r *SQLiteTaskRepository) UpdateTask(taskUpdates map[string]interface{}) (int64, error) { 82 | query := "UPDATE scheduler SET " 83 | args := []interface{}{} 84 | i := 0 85 | 86 | for key, value := range taskUpdates { 87 | if key != "id" { 88 | if i > 0 { 89 | query += ", " 90 | } 91 | query += fmt.Sprintf("%s = ?", key) 92 | args = append(args, value) 93 | i++ 94 | } 95 | } 96 | 97 | query += " WHERE id = ?" 98 | args = append(args, taskUpdates["id"]) 99 | 100 | result, err := r.DB.Exec(query, args...) 101 | if err != nil { 102 | return 0, err 103 | } 104 | 105 | return result.RowsAffected() 106 | } 107 | 108 | func (r *SQLiteTaskRepository) DeleteTask(id string) (int64, error) { 109 | result, err := r.DB.Exec("DELETE FROM scheduler WHERE id = ?", id) 110 | if err != nil { 111 | return 0, err 112 | } 113 | 114 | return result.RowsAffected() 115 | } 116 | 117 | func (r *SQLiteTaskRepository) MarkTaskAsDone(id, date string) error { 118 | _, err := r.DB.Exec("UPDATE scheduler SET date = ? WHERE id = ?", date, id) 119 | return err 120 | } 121 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "path/filepath" 8 | 9 | "github.com/antonkazachenko/go-todo-list-api/config" 10 | "github.com/antonkazachenko/go-todo-list-api/internal/service" 11 | storage "github.com/antonkazachenko/go-todo-list-api/internal/storage/sqlite" 12 | "github.com/antonkazachenko/go-todo-list-api/routes" 13 | ) 14 | 15 | func main() { 16 | db := storage.InitDB() 17 | defer db.Close() 18 | 19 | taskRepo := storage.NewSQLiteTaskRepository(db) 20 | 21 | taskService := service.NewTaskService(taskRepo) 22 | 23 | router := routes.RegisterRoutes(taskService) 24 | 25 | fileServer := http.FileServer(http.Dir("./web")) 26 | router.Get("/*", func(w http.ResponseWriter, r *http.Request) { 27 | if filepath.Ext(r.URL.Path) == ".css" { 28 | w.Header().Set("Content-Type", "text/css") 29 | } 30 | fileServer.ServeHTTP(w, r) 31 | }) 32 | 33 | address := fmt.Sprintf(":%s", config.TODO_PORT) 34 | log.Printf("Starting server on %s", address) 35 | if err := http.ListenAndServe(address, router); err != nil { 36 | log.Fatalf("Failed to start server: %v", err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/antonkazachenko/go-todo-list-api/config" 7 | "github.com/golang-jwt/jwt/v4" 8 | ) 9 | 10 | func Auth(next http.HandlerFunc) http.HandlerFunc { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | pass := config.TODO_PASS 13 | if len(pass) > 0 { 14 | var jwtToken string 15 | cookie, err := r.Cookie("token") 16 | if err == nil { 17 | jwtToken = cookie.Value 18 | } 19 | 20 | var valid bool 21 | if jwtToken != "" { 22 | token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) { 23 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 24 | return nil, http.ErrAbortHandler 25 | } 26 | return []byte(pass), nil 27 | }) 28 | 29 | if err == nil && token.Valid { 30 | valid = true 31 | } 32 | } 33 | 34 | if !valid { 35 | http.Error(w, "Authentication required", http.StatusUnauthorized) 36 | return 37 | } 38 | } 39 | next(w, r) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /models/task.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type AuthResponse struct { 4 | Token string `json:"token"` 5 | } 6 | 7 | type IDResponse struct { 8 | ID int64 `json:"id"` 9 | } 10 | 11 | type ErrorResponse struct { 12 | Error string `json:"error"` 13 | } 14 | -------------------------------------------------------------------------------- /routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | handlers "github.com/antonkazachenko/go-todo-list-api/internal/server" 5 | "github.com/antonkazachenko/go-todo-list-api/internal/service" 6 | "github.com/antonkazachenko/go-todo-list-api/middleware" 7 | "github.com/go-chi/chi/v5" 8 | ) 9 | 10 | func RegisterRoutes(taskService *service.TaskService) *chi.Mux { 11 | r := chi.NewRouter() 12 | 13 | h := handlers.NewHandlers(taskService) 14 | 15 | r.Get("/api/nextdate", h.HandleNextDate) 16 | r.Post("/api/task", middleware.Auth(h.HandleAddTask)) 17 | r.Get("/api/tasks", middleware.Auth(h.HandleGetTasks)) 18 | r.Get("/api/task", middleware.Auth(h.HandleGetTask)) 19 | r.Put("/api/task", middleware.Auth(h.HandlePutTask)) 20 | r.Delete("/api/task", middleware.Auth(h.HandleDeleteTask)) 21 | r.Post("/api/task/done", middleware.Auth(h.HandleDoneTask)) 22 | r.Post("/api/signin", h.HandleSignIn) 23 | 24 | return r 25 | } 26 | -------------------------------------------------------------------------------- /tests/addtask_4_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "strconv" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func requestJSON(apipath string, values map[string]any, method string) ([]byte, error) { 18 | var ( 19 | data []byte 20 | err error 21 | ) 22 | 23 | if len(values) > 0 { 24 | data, err = json.Marshal(values) 25 | if err != nil { 26 | return nil, err 27 | } 28 | } 29 | var resp *http.Response 30 | 31 | req, err := http.NewRequest(method, getURL(apipath), bytes.NewBuffer(data)) 32 | if err != nil { 33 | return nil, err 34 | } 35 | req.Header.Set("Content-Type", "application/json") 36 | 37 | client := &http.Client{} 38 | if len(Token) > 0 { 39 | jar, err := cookiejar.New(nil) 40 | if err != nil { 41 | return nil, err 42 | } 43 | jar.SetCookies(req.URL, []*http.Cookie{ 44 | { 45 | Name: "token", 46 | Value: Token, 47 | }, 48 | }) 49 | client.Jar = jar 50 | } 51 | 52 | resp, err = client.Do(req) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | if resp.Body != nil { 58 | defer resp.Body.Close() 59 | } 60 | return io.ReadAll(resp.Body) 61 | } 62 | 63 | func postJSON(apipath string, values map[string]any, method string) (map[string]any, error) { 64 | var ( 65 | m map[string]any 66 | err error 67 | ) 68 | 69 | body, err := requestJSON(apipath, values, method) 70 | if err != nil { 71 | return nil, err 72 | } 73 | err = json.Unmarshal(body, &m) 74 | return m, err 75 | } 76 | 77 | type task struct { 78 | date string 79 | title string 80 | comment string 81 | repeat string 82 | } 83 | 84 | func TestAddTask(t *testing.T) { 85 | db := openDB(t) 86 | defer db.Close() 87 | 88 | tbl := []task{ 89 | {"20240129", "", "", ""}, 90 | {"20240192", "Qwerty", "", ""}, 91 | {"28.01.2024", "Заголовок", "", ""}, 92 | {"20240112", "Заголовок", "", "w"}, 93 | {"20240212", "Заголовок", "", "ooops"}, 94 | } 95 | for _, v := range tbl { 96 | m, err := postJSON("api/task", map[string]any{ 97 | "date": v.date, 98 | "title": v.title, 99 | "comment": v.comment, 100 | "repeat": v.repeat, 101 | }, http.MethodPost) 102 | assert.NoError(t, err) 103 | 104 | e, ok := m["error"] 105 | assert.False(t, !ok || len(fmt.Sprint(e)) == 0, 106 | "Ожидается ошибка для задачи %v", v) 107 | } 108 | 109 | now := time.Now() 110 | 111 | check := func() { 112 | for _, v := range tbl { 113 | today := v.date == "today" 114 | if today { 115 | v.date = now.Format(`20060102`) 116 | } 117 | m, err := postJSON("api/task", map[string]any{ 118 | "date": v.date, 119 | "title": v.title, 120 | "comment": v.comment, 121 | "repeat": v.repeat, 122 | }, http.MethodPost) 123 | assert.NoError(t, err) 124 | 125 | e, ok := m["error"] 126 | if ok && len(fmt.Sprint(e)) > 0 { 127 | t.Errorf("Неожиданная ошибка %v для задачи %v", e, v) 128 | continue 129 | } 130 | var task Task 131 | var mid any 132 | mid, ok = m["id"] 133 | if !ok { 134 | t.Errorf("Не возвращён id для задачи %v", v) 135 | continue 136 | } 137 | id := fmt.Sprint(mid) 138 | 139 | err = db.Get(&task, `SELECT * FROM scheduler WHERE id=?`, id) 140 | assert.NoError(t, err) 141 | assert.Equal(t, id, strconv.FormatInt(task.ID, 10)) 142 | 143 | assert.Equal(t, v.title, task.Title) 144 | assert.Equal(t, v.comment, task.Comment) 145 | assert.Equal(t, v.repeat, task.Repeat) 146 | if task.Date < now.Format(`20060102`) { 147 | t.Errorf("Дата не может быть меньше сегодняшней %v", v) 148 | continue 149 | } 150 | if today && task.Date != now.Format(`20060102`) { 151 | t.Errorf("Дата должна быть сегодняшняя %v", v) 152 | } 153 | } 154 | } 155 | 156 | tbl = []task{ 157 | {"", "Заголовок", "", ""}, 158 | {"20231220", "Сделать что-нибудь", "Хорошо отдохнуть", ""}, 159 | {"20240108", "Уроки", "", "d 10"}, 160 | {"20240102", "Отдых в Сочи", "На лыжах", "y"}, 161 | {"today", "Фитнес", "", "d 1"}, 162 | {"today", "Шмитнес", "", ""}, 163 | } 164 | check() 165 | if FullNextDate { 166 | tbl = []task{ 167 | {"20240129", "Сходить в магазин", "", "w 1,3,5"}, 168 | } 169 | check() 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/app_1_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func getURL(path string) string { 17 | port := Port 18 | envPort := os.Getenv("TODO_PORT") 19 | if len(envPort) > 0 { 20 | if eport, err := strconv.ParseInt(envPort, 10, 32); err == nil { 21 | port = int(eport) 22 | } 23 | } 24 | path = strings.TrimPrefix(strings.ReplaceAll(path, `\`, `/`), `../web/`) 25 | return fmt.Sprintf("http://localhost:%d/%s", port, path) 26 | } 27 | 28 | func getBody(path string) ([]byte, error) { 29 | resp, err := http.Get(getURL(path)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer resp.Body.Close() 34 | body, err := io.ReadAll(resp.Body) 35 | return body, err 36 | } 37 | 38 | func walkDir(path string, f func(fname string) error) error { 39 | dirs, err := os.ReadDir(path) 40 | if err != nil { 41 | return err 42 | } 43 | for _, v := range dirs { 44 | fname := filepath.Join(path, v.Name()) 45 | if v.IsDir() { 46 | if err = walkDir(fname, f); err != nil { 47 | return err 48 | } 49 | continue 50 | } 51 | if err = f(fname); err != nil { 52 | return err 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | func TestApp(t *testing.T) { 59 | cmp := func(fname string) error { 60 | fbody, err := os.ReadFile(fname) 61 | if err != nil { 62 | return err 63 | } 64 | body, err := getBody(fname) 65 | if err != nil { 66 | return err 67 | } 68 | assert.Equal(t, len(fbody), len(body), `сервер возвращает для %s данные другого размера`, fname) 69 | return nil 70 | } 71 | assert.NoError(t, walkDir("../web", cmp)) 72 | } 73 | -------------------------------------------------------------------------------- /tests/db_2_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/jmoiron/sqlx" 9 | _ "github.com/mattn/go-sqlite3" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type Task struct { 14 | ID int64 `db:"id"` 15 | Date string `db:"date"` 16 | Title string `db:"title"` 17 | Comment string `db:"comment"` 18 | Repeat string `db:"repeat"` 19 | } 20 | 21 | func count(db *sqlx.DB) (int, error) { 22 | var count int 23 | return count, db.Get(&count, `SELECT count(id) FROM scheduler`) 24 | } 25 | 26 | func openDB(t *testing.T) *sqlx.DB { 27 | dbfile := DBFile 28 | envFile := os.Getenv("TODO_DBFILE") 29 | if len(envFile) > 0 { 30 | dbfile = envFile 31 | } 32 | db, err := sqlx.Connect("sqlite3", dbfile) 33 | assert.NoError(t, err) 34 | return db 35 | } 36 | 37 | func TestDB(t *testing.T) { 38 | db := openDB(t) 39 | defer db.Close() 40 | 41 | before, err := count(db) 42 | assert.NoError(t, err) 43 | 44 | today := time.Now().Format(`20060102`) 45 | 46 | res, err := db.Exec(`INSERT INTO scheduler (date, title, comment, repeat) 47 | VALUES (?, 'Todo', 'Комментарий', '')`, today) 48 | assert.NoError(t, err) 49 | 50 | id, err := res.LastInsertId() 51 | 52 | var task Task 53 | err = db.Get(&task, `SELECT * FROM scheduler WHERE id=?`, id) 54 | assert.NoError(t, err) 55 | assert.Equal(t, id, task.ID) 56 | assert.Equal(t, `Todo`, task.Title) 57 | assert.Equal(t, `Комментарий`, task.Comment) 58 | 59 | _, err = db.Exec(`DELETE FROM scheduler WHERE id = ?`, id) 60 | assert.NoError(t, err) 61 | 62 | after, err := count(db) 63 | assert.NoError(t, err) 64 | 65 | assert.Equal(t, before, after) 66 | } 67 | -------------------------------------------------------------------------------- /tests/nextdate_3_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type nextDate struct { 14 | date string 15 | repeat string 16 | want string 17 | } 18 | 19 | func TestNextDate(t *testing.T) { 20 | tbl := []nextDate{ 21 | {"20240126", "", ""}, 22 | {"20240126", "k 34", ""}, 23 | {"20240126", "ooops", ""}, 24 | {"15000156", "y", ""}, 25 | {"ooops", "y", ""}, 26 | {"16890220", "y", `20240220`}, 27 | {"20250701", "y", `20260701`}, 28 | {"20240101", "y", `20250101`}, 29 | {"20231231", "y", `20241231`}, 30 | {"20240229", "y", `20250301`}, 31 | {"20240301", "y", `20250301`}, 32 | {"20240113", "d", ""}, 33 | {"20240113", "d 7", `20240127`}, 34 | {"20240120", "d 20", `20240209`}, 35 | {"20240202", "d 30", `20240303`}, 36 | {"20240320", "d 401", ""}, 37 | {"20231225", "d 12", `20240130`}, 38 | {"20240228", "d 1", "20240229"}, 39 | } 40 | check := func() { 41 | for _, v := range tbl { 42 | urlPath := fmt.Sprintf("api/nextdate?now=20240126&date=%s&repeat=%s", 43 | url.QueryEscape(v.date), url.QueryEscape(v.repeat)) 44 | get, err := getBody(urlPath) 45 | assert.NoError(t, err) 46 | next := strings.TrimSpace(string(get)) 47 | _, err = time.Parse("20060102", next) 48 | if err != nil && len(v.want) == 0 { 49 | continue 50 | } 51 | assert.Equal(t, v.want, next, `{%q, %q, %q}`, 52 | v.date, v.repeat, v.want) 53 | } 54 | } 55 | check() 56 | if !FullNextDate { 57 | return 58 | } 59 | tbl = []nextDate{ 60 | {"20231106", "m 13", "20240213"}, 61 | {"20240120", "m 40,11,19", ""}, 62 | {"20240116", "m 16,5", "20240205"}, 63 | {"20240126", "m 25,26,7", "20240207"}, 64 | {"20240409", "m 31", "20240531"}, 65 | {"20240329", "m 10,17 12,8,1", "20240810"}, 66 | {"20230311", "m 07,19 05,6", "20240507"}, 67 | {"20230311", "m 1 1,2", "20240201"}, 68 | {"20240127", "m -1", "20240131"}, 69 | {"20240222", "m -2", "20240228"}, 70 | {"20240222", "m -2,-3", ""}, 71 | {"20240326", "m -1,-2", "20240330"}, 72 | {"20240201", "m -1,18", "20240218"}, 73 | {"20240125", "w 1,2,3", "20240129"}, 74 | {"20240126", "w 7", "20240128"}, 75 | {"20230126", "w 4,5", "20240201"}, 76 | {"20230226", "w 8,4,5", ""}, 77 | } 78 | check() 79 | } 80 | -------------------------------------------------------------------------------- /tests/settings.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/golang-jwt/jwt/v4" 7 | ) 8 | 9 | var Port = 7540 10 | var DBFile = "../scheduler.db" 11 | var FullNextDate = true 12 | var Search = true 13 | var Token = generateTestToken() 14 | 15 | func generateTestToken() string { 16 | pass := "test12345" 17 | 18 | token := jwt.New(jwt.SigningMethodHS256) 19 | 20 | claims := token.Claims.(jwt.MapClaims) 21 | claims["exp"] = time.Now().Add(time.Hour * 24).Unix() 22 | 23 | tokenString, err := token.SignedString([]byte(pass)) 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | return tokenString 29 | } 30 | -------------------------------------------------------------------------------- /tests/task_6_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestTask(t *testing.T) { 15 | db := openDB(t) 16 | defer db.Close() 17 | 18 | now := time.Now() 19 | 20 | task := task{ 21 | date: now.Format(`20060102`), 22 | title: "Созвон в 16:00", 23 | comment: "Обсуждение планов", 24 | repeat: "d 5", 25 | } 26 | 27 | todo := addTask(t, task) 28 | 29 | body, err := requestJSON("api/task", nil, http.MethodGet) 30 | assert.NoError(t, err) 31 | var m map[string]string 32 | err = json.Unmarshal(body, &m) 33 | assert.NoError(t, err) 34 | 35 | e, ok := m["error"] 36 | assert.False(t, !ok || len(fmt.Sprint(e)) == 0, 37 | "Ожидается ошибка для вызова /api/task") 38 | 39 | body, err = requestJSON("api/task?id="+todo, nil, http.MethodGet) 40 | assert.NoError(t, err) 41 | err = json.Unmarshal(body, &m) 42 | assert.NoError(t, err) 43 | 44 | assert.Equal(t, todo, m["id"]) 45 | assert.Equal(t, task.date, m["date"]) 46 | assert.Equal(t, task.title, m["title"]) 47 | assert.Equal(t, task.comment, m["comment"]) 48 | assert.Equal(t, task.repeat, m["repeat"]) 49 | } 50 | 51 | type fulltask struct { 52 | id string 53 | task 54 | } 55 | 56 | func TestEditTask(t *testing.T) { 57 | db := openDB(t) 58 | defer db.Close() 59 | 60 | now := time.Now() 61 | 62 | tsk := task{ 63 | date: now.Format(`20060102`), 64 | title: "Заказать пиццу", 65 | comment: "в 17:00", 66 | repeat: "", 67 | } 68 | 69 | id := addTask(t, tsk) 70 | 71 | tbl := []fulltask{ 72 | {"", task{"20240129", "Тест", "", ""}}, 73 | {"abc", task{"20240129", "Тест", "", ""}}, 74 | {"7645346343", task{"20240129", "Тест", "", ""}}, 75 | {id, task{"20240129", "", "", ""}}, 76 | {id, task{"20240192", "Qwerty", "", ""}}, 77 | {id, task{"28.01.2024", "Заголовок", "", ""}}, 78 | {id, task{"20240212", "Заголовок", "", "ooops"}}, 79 | } 80 | for _, v := range tbl { 81 | m, err := postJSON("api/task", map[string]any{ 82 | "id": v.id, 83 | "date": v.date, 84 | "title": v.title, 85 | "comment": v.comment, 86 | "repeat": v.repeat, 87 | }, http.MethodPut) 88 | assert.NoError(t, err) 89 | 90 | var errVal string 91 | e, ok := m["error"] 92 | if ok { 93 | errVal = fmt.Sprint(e) 94 | } 95 | assert.NotEqual(t, len(errVal), 0, "Ожидается ошибка для значения %v", v) 96 | } 97 | 98 | updateTask := func(newVals map[string]any) { 99 | mupd, err := postJSON("api/task", newVals, http.MethodPut) 100 | assert.NoError(t, err) 101 | 102 | e, ok := mupd["error"] 103 | assert.False(t, ok && fmt.Sprint(e) != "") 104 | 105 | var task Task 106 | err = db.Get(&task, `SELECT * FROM scheduler WHERE id=?`, id) 107 | assert.NoError(t, err) 108 | 109 | assert.Equal(t, id, strconv.FormatInt(task.ID, 10)) 110 | assert.Equal(t, newVals["title"], task.Title) 111 | if _, is := newVals["comment"]; !is { 112 | newVals["comment"] = "" 113 | } 114 | if _, is := newVals["repeat"]; !is { 115 | newVals["repeat"] = "" 116 | } 117 | assert.Equal(t, newVals["comment"], task.Comment) 118 | assert.Equal(t, newVals["repeat"], task.Repeat) 119 | now := time.Now().Format(`20060102`) 120 | if task.Date < now { 121 | t.Errorf("Дата не может быть меньше сегодняшней") 122 | } 123 | } 124 | 125 | updateTask(map[string]any{ 126 | "id": id, 127 | "date": now.Format(`20060102`), 128 | "title": "Заказать хинкали", 129 | "comment": "в 18:00", 130 | "repeat": "d 7", 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /tests/task_7_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func notFoundTask(t *testing.T, id string) { 13 | body, err := requestJSON("api/task?id="+id, nil, http.MethodGet) 14 | assert.NoError(t, err) 15 | var m map[string]any 16 | err = json.Unmarshal(body, &m) 17 | assert.NoError(t, err) 18 | _, ok := m["error"] 19 | assert.True(t, ok) 20 | } 21 | 22 | func TestDone(t *testing.T) { 23 | db := openDB(t) 24 | defer db.Close() 25 | 26 | now := time.Now() 27 | id := addTask(t, task{ 28 | date: now.Format(`20060102`), 29 | title: "Свести баланс", 30 | }) 31 | 32 | ret, err := postJSON("api/task/done?id="+id, nil, http.MethodPost) 33 | assert.NoError(t, err) 34 | assert.Empty(t, ret) 35 | notFoundTask(t, id) 36 | 37 | id = addTask(t, task{ 38 | title: "Проверить работу /api/task/done", 39 | repeat: "d 3", 40 | }) 41 | 42 | for i := 0; i < 3; i++ { 43 | ret, err := postJSON("api/task/done?id="+id, nil, http.MethodPost) 44 | assert.NoError(t, err) 45 | assert.Empty(t, ret) 46 | 47 | var task Task 48 | err = db.Get(&task, `SELECT * FROM scheduler WHERE id=?`, id) 49 | assert.NoError(t, err) 50 | now = now.AddDate(0, 0, 3) 51 | assert.Equal(t, task.Date, now.Format(`20060102`)) 52 | } 53 | } 54 | 55 | func TestDelTask(t *testing.T) { 56 | db := openDB(t) 57 | defer db.Close() 58 | 59 | id := addTask(t, task{ 60 | title: "Временная задача", 61 | repeat: "d 3", 62 | }) 63 | ret, err := postJSON("api/task?id="+id, nil, http.MethodDelete) 64 | assert.NoError(t, err) 65 | assert.Empty(t, ret) 66 | 67 | notFoundTask(t, id) 68 | 69 | ret, err = postJSON("api/task", nil, http.MethodDelete) 70 | assert.NoError(t, err) 71 | assert.NotEmpty(t, ret) 72 | ret, err = postJSON("api/task?id=wjhgese", nil, http.MethodDelete) 73 | assert.NoError(t, err) 74 | assert.NotEmpty(t, ret) 75 | } 76 | -------------------------------------------------------------------------------- /tests/tasks_5_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func addTask(t *testing.T, task task) string { 14 | ret, err := postJSON("api/task", map[string]any{ 15 | "date": task.date, 16 | "title": task.title, 17 | "comment": task.comment, 18 | "repeat": task.repeat, 19 | }, http.MethodPost) 20 | assert.NoError(t, err) 21 | assert.NotNil(t, ret["id"]) 22 | id := fmt.Sprint(ret["id"]) 23 | assert.NotEmpty(t, id) 24 | return id 25 | } 26 | 27 | func getTasks(t *testing.T, search string) []map[string]string { 28 | url := "api/tasks" 29 | if Search { 30 | url += "?search=" + search 31 | } 32 | body, err := requestJSON(url, nil, http.MethodGet) 33 | assert.NoError(t, err) 34 | 35 | var m map[string][]map[string]string 36 | err = json.Unmarshal(body, &m) 37 | assert.NoError(t, err) 38 | return m["tasks"] 39 | } 40 | 41 | func TestTasks(t *testing.T) { 42 | db := openDB(t) 43 | defer db.Close() 44 | 45 | now := time.Now() 46 | _, err := db.Exec("DELETE FROM scheduler") 47 | assert.NoError(t, err) 48 | 49 | tasks := getTasks(t, "") 50 | assert.NotNil(t, tasks) 51 | assert.Empty(t, tasks) 52 | 53 | addTask(t, task{ 54 | date: now.Format(`20060102`), 55 | title: "Просмотр фильма", 56 | comment: "с попкорном", 57 | repeat: "", 58 | }) 59 | now = now.AddDate(0, 0, 1) 60 | date := now.Format(`20060102`) 61 | addTask(t, task{ 62 | date: date, 63 | title: "Сходить в бассейн", 64 | comment: "", 65 | repeat: "", 66 | }) 67 | addTask(t, task{ 68 | date: date, 69 | title: "Оплатить коммуналку", 70 | comment: "", 71 | repeat: "d 30", 72 | }) 73 | tasks = getTasks(t, "") 74 | assert.Equal(t, len(tasks), 3) 75 | 76 | now = now.AddDate(0, 0, 2) 77 | date = now.Format(`20060102`) 78 | addTask(t, task{ 79 | date: date, 80 | title: "Поплавать", 81 | comment: "Бассейн с тренером", 82 | repeat: "d 7", 83 | }) 84 | addTask(t, task{ 85 | date: date, 86 | title: "Позвонить в УК", 87 | comment: "Разобраться с горячей водой", 88 | repeat: "", 89 | }) 90 | addTask(t, task{ 91 | date: date, 92 | title: "Встретится с Васей", 93 | comment: "в 18:00", 94 | repeat: "", 95 | }) 96 | 97 | tasks = getTasks(t, "") 98 | assert.Equal(t, len(tasks), 6) 99 | 100 | if !Search { 101 | return 102 | } 103 | tasks = getTasks(t, "УК") 104 | assert.Equal(t, len(tasks), 1) 105 | tasks = getTasks(t, now.Format(`02.01.2006`)) 106 | assert.Equal(t, len(tasks), 3) 107 | 108 | } 109 | -------------------------------------------------------------------------------- /utils/responses.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/antonkazachenko/go-todo-list-api/models" 8 | ) 9 | 10 | func SendErrorResponse(res http.ResponseWriter, errorMessage string, statusCode int) { 11 | resp := models.ErrorResponse{Error: errorMessage} 12 | respBytes, err := json.Marshal(resp) 13 | if err != nil { 14 | http.Error(res, "ошибка при сериализации ответа", http.StatusInternalServerError) 15 | return 16 | } 17 | http.Error(res, string(respBytes), statusCode) 18 | } 19 | -------------------------------------------------------------------------------- /web/css/style.css: -------------------------------------------------------------------------------- 1 | .datepicker.svelte-xxli2i td.svelte-xxli2i,.datepicker.svelte-xxli2i th.svelte-xxli2i{width:2em;text-align:center;border-radius:0.25em;line-height:1.5em;margin:0;padding:0}td.past.svelte-xxli2i.svelte-xxli2i,td.future.svelte-xxli2i.svelte-xxli2i{opacity:0.5}.datebtn.svelte-xxli2i.svelte-xxli2i{cursor:pointer}.datebtn.svelte-xxli2i.svelte-xxli2i:hover{background:var(--gray-300);color:var(--font-color)}.dateselected.svelte-xxli2i.svelte-xxli2i{color:var(--pbtn-color);font-weight:600;background-color:var(--pbtn-bg);border-color:var(--pbtn-bg)}.today.svelte-xxli2i.svelte-xxli2i{color:var(--sbtn-color);font-weight:600;background-color:var(--sbtn-bg);border-color:var(--sbtn-bg)} 2 | .form-input{display:flex;flex-direction:column;margin:0.5em 0.3em 0em}.form-label{color:var(--gray-700);margin-bottom:0.5em;font-size:0.9em;font-weight:600} 3 | .app.svelte-6zk4ms.svelte-6zk4ms{height:100vh;display:flex;flex-direction:column}.body.svelte-6zk4ms.svelte-6zk4ms{flex-grow:1;display:flex;flex-direction:column;min-height:0;position:relative}.topnav.svelte-6zk4ms.svelte-6zk4ms{background-color:var(--cardbg-color);border-bottom:var(--border-width) solid var(--card-border-color);top:0;width:100%;display:flex;flex-direction:row;justify-content:center;align-items:center;padding:0.5em 1em;column-gap:1em}.notelist{margin:1em 0;columns:20em}.notecard{padding-bottom:1em;break-inside:avoid}.note{position:relative;cursor:default;font-size:0.9em;padding:0.5em 1em;break-inside:avoid}.notetitle{font-weight:600;padding-bottom:0.5em}.notebtns{display:flex;align-items:center;justify-content:right;column-gap:0.5em;visibility:hidden;fill:var(--gray-700)}.note:hover .notebtns{visibility:visible}.fav{position:absolute;top:0.5em;right:0.5em}.day.svelte-6zk4ms.svelte-6zk4ms{display:flex;align-items:center;column-gap:0.5em;font-size:1.2em;font-weight:600;padding:0.25em 0em;border-bottom:2px dotted var(--gray-500)}.tocheck.svelte-6zk4ms.svelte-6zk4ms{width:1.5em;height:1.5em;fill:var(--font-color)}.tocheck.svelte-6zk4ms.svelte-6zk4ms:hover{fill:var(--primary)}.tocheck.svelte-6zk4ms:hover path.svelte-6zk4ms{d:path( 4 | "M20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4C12.76,4 13.5,4.11 14.2,4.31L15.77,2.74C14.61,2.26 13.34,2 12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z" 5 | );d:"M20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4C12.76,4 13.5,4.11 14.2,4.31L15.77,2.74C14.61,2.26 13.34,2 12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"}.todo.svelte-6zk4ms.svelte-6zk4ms{display:flex;align-items:center;column-gap:0.4em} 6 | -------------------------------------------------------------------------------- /web/css/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --mainbg-color: #edf1f8; 3 | --cardbg-color: #fff; 4 | --font-color: #344050; 5 | --card-border-color: rgba(0, 0, 0, 0.125); 6 | --input-bg: #fff; 7 | --input-color: #344050; 8 | --input-border-color: #d8e2ef; 9 | --input-placeholder-color: #b6c1d2; 10 | --input-focus-border-color: #96bdf2; 11 | --gray-200: #edf2f9; 12 | --gray-300: #d8e2ef; 13 | --gray-400: #b6c1d2; 14 | --gray-500: #9da9bb; 15 | --gray-700: #5e6e82; 16 | --gray-800: #4d5969; 17 | --gray-900: #344050; 18 | --ibtn-color: #748194; 19 | --ibtn-hover-color: #fff; 20 | 21 | --box-shadow: 0 7px 14px 0 rgba(65, 69, 88, 0.1), 0 3px 6px 0 rgba(0, 0, 0, 0.07); 22 | --box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); 23 | --btn-box-shadow: inset 0 1px 0 hsla(0, 0%, 100%, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); 24 | 25 | --h-color: #344050; 26 | --modal-bg-color: #fff; 27 | --modal-hf-color: var(--mainbg-color); 28 | --border-color: #d8e2ef; 29 | --close-hover-color: #3F3F40; 30 | --contrast: rgba(0, 0, 0, 0.221); 31 | --dropdown-bg: #F9FAFD; 32 | --dropdown-link-color: #344050; 33 | --dropdown-hover-color: #edf2f9; 34 | --check-input-border-color: #b6c1d2; 35 | 36 | --blue-cursor: #dcf0fd; 37 | --btn-border-color: var(--gray-300); 38 | --btn-background: #fff; 39 | --btn-color: #4d5969; 40 | --btn-hover-color: #404a57; 41 | --btn-hover-box-shadow: 0 0 0 1px rgba(43, 45, 80, 0.1), 0 2px 5px 0 rgba(43, 45, 80, 0.08), 0 1px 1.5px 0 rgba(0, 0, 0, 0.07), 0 1px 2px 0 rgba(0, 0, 0, 0.08); 42 | 43 | --alert-success: #007e49; 44 | --alert-success-bg: #ccf6e4; 45 | --alert-success-border: #b3f2d7; 46 | --alert-warning: #934d25; 47 | --alert-warning-bg: #fde6d8; 48 | --alert-warning-border: #fcd9c5; 49 | 50 | /* common */ 51 | --blue: #3273DC; 52 | --green: #23D160; 53 | --yellow: #FFDD57; 54 | --red: #FE421D; 55 | --primary: #2c7be5; 56 | --tag-color: #fff; 57 | --tag-bg: rgb(116, 129, 148); 58 | --tag-radius: 0.25em; 59 | 60 | --alert-success-icon: #00D27A; 61 | --alert-warning-icon: #c46632; 62 | 63 | --border-width: 1px; 64 | --border-radius: 0.4em; 65 | --btn-border-radius: 0.25em; 66 | 67 | --sbtn-color: #fff; 68 | --sbtn-bg: #748194; 69 | --sbtn-hover-color: #fff; 70 | --sbtn-hover-bg: #636e7e; 71 | --sbtn-hover-border-color: #5d6776; 72 | 73 | --sbtn-active-color: #fff; 74 | --sbtn-active-bg: #5d6776; 75 | --sbtn-active-border-color: #57616f; 76 | --sbtn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 77 | --sbtn-disabled-color: #fff; 78 | --sbtn-disabled-bg: #748194; 79 | --sbtn-disabled-border-color: #748194; 80 | 81 | --pbtn-color: #fff; 82 | --pbtn-bg: #2c7be5; 83 | --pbtn-hover-color: #fff; 84 | --pbtn-hover-bg: #2569c3; 85 | --pbtn-hover-border-color: #2362b7; 86 | 87 | --pbtn-active-color: #fff; 88 | --pbtn-active-bg: #2362b7; 89 | --pbtn-active-border-color: #215cac; 90 | --pbtn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 91 | 92 | --link-color: #2c7be5; 93 | --link-hover-color: #2362b7; 94 | 95 | --close-color: #748194; 96 | } 97 | 98 | [data-size="small"] { 99 | --font-size: 0.9em; 100 | } 101 | 102 | [data-size="normal"] { 103 | --font-size: 1em; 104 | } 105 | 106 | [data-size="big"] { 107 | --font-size: 1.1em; 108 | } 109 | 110 | * { 111 | box-sizing: border-box; 112 | margin: 0; 113 | padding: 0; 114 | } 115 | 116 | html { 117 | height: 100%; 118 | overflow-y: auto; 119 | } 120 | 121 | body { 122 | -webkit-font-smoothing: antialiased; 123 | background-color: var(--mainbg-color); 124 | color: var(--font-color); 125 | fill: var(--font-color); 126 | font-size: var(--font-size); 127 | height: 100%; 128 | font-family: var(--font-family), "Segoe UI", "Roboto", "Ubuntu", "Arial", sans-serif; 129 | } 130 | 131 | input, 132 | select, 133 | textarea, 134 | label, 135 | button { 136 | font-family: var(--font-family), "Segoe UI", "Roboto", "Ubuntu", "Arial", sans-serif; 137 | } 138 | 139 | input[type="file"] { 140 | display: none; 141 | } 142 | 143 | h1, 144 | h2, 145 | h3, 146 | h4, 147 | h5 { 148 | font-weight: 600; 149 | line-height: 1.2; 150 | color: var(--h-color); 151 | } 152 | 153 | h1 { 154 | margin: 0.5em 0; 155 | font-size: 1.9em; 156 | } 157 | 158 | h2 { 159 | margin: 0.5em 0; 160 | font-size: 1.7em; 161 | } 162 | 163 | h3 { 164 | margin: 0.5em 0; 165 | font-size: 1.5em; 166 | } 167 | 168 | h4 { 169 | margin: 0.5em 0; 170 | font-size: 1.4em; 171 | } 172 | 173 | h5 { 174 | margin: 0.5em 0; 175 | font-size: 1.2em; 176 | } 177 | 178 | a { 179 | color: var(--link-color); 180 | text-decoration: none; 181 | cursor: pointer; 182 | } 183 | 184 | a:hover { 185 | color: var(--link-hover-color); 186 | text-decoration: underline; 187 | } 188 | 189 | .card { 190 | background-color: var(--cardbg-color); 191 | border: var(--border-width) solid var(--card-border-color); 192 | border-radius: var(--border-radius); 193 | box-shadow: var(--box-shadow); 194 | display: flex; 195 | flex-direction: column; 196 | word-wrap: break-word; 197 | padding: 1em 2em; 198 | } 199 | 200 | .input { 201 | font-size: 1em; 202 | padding: 0.2em 0.8em; 203 | font-weight: 400; 204 | line-height: 1.5; 205 | color: var(--font-color); 206 | background-color: var(--input-bg); 207 | background-clip: padding-box; 208 | border: 1px solid var(--input-border-color); 209 | appearance: none; 210 | border-radius: 0.25em; 211 | box-shadow: var(--box-shadow-inset); 212 | transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; 213 | } 214 | 215 | .input:focus { 216 | outline: none !important; 217 | border-color: var(--input-focus-border-color); 218 | box-shadow: 0 0 10px var(--input-focus-border-color); 219 | } 220 | 221 | .input:disabled { 222 | color: var(--gray-700); 223 | background-color: var(--gray-200); 224 | } 225 | 226 | ::placeholder { 227 | /* Chrome, Firefox, Opera, Safari 10.1+ */ 228 | color: var(--input-placeholder-color); 229 | opacity: 1; 230 | /* Firefox */ 231 | } 232 | 233 | ::-ms-input-placeholder { 234 | /* Microsoft Edge */ 235 | color: var(--input-placeholder-color); 236 | } 237 | 238 | .input-right-btn { 239 | fill: var(--ibtn-color); 240 | display: flex; 241 | align-items: center; 242 | border: var(--border-width) solid var(--input-border-color); 243 | border-left: 0; 244 | padding: 0em 0.5em; 245 | background-color: var(--gray-200); 246 | border-top-right-radius: 0.25em; 247 | border-bottom-right-radius: 0.25em; 248 | cursor: pointer; 249 | } 250 | 251 | .input-right-btn:hover { 252 | fill: var(--ibtn-hover-color); 253 | background-color: var(--ibtn-color); 254 | } 255 | 256 | .no-right-radius { 257 | border-top-right-radius: 0; 258 | border-bottom-right-radius: 0 259 | } 260 | 261 | .btn { 262 | display: inline-block; 263 | padding: 0.32em 1em; 264 | font-size: 1em; 265 | font-weight: 600; 266 | /*500;*/ 267 | /*line-height: 1.5;*/ 268 | min-height: 2.1em; 269 | text-align: center; 270 | vertical-align: middle; 271 | cursor: pointer; 272 | user-select: none; 273 | border-radius: 0.25em; 274 | 275 | border: var(--border-width) solid var(--btn-border-color); 276 | color: var(--btn-color); 277 | background-color: var(--btn-background); 278 | box-shadow: var(--btn-box-shadow); 279 | fill: var(--btn-color); 280 | } 281 | 282 | .btn:hover { 283 | color: var(--btn-hover-color); 284 | fill: var(--btn-hover-color); 285 | box-shadow: var(--btn-hover-box-shadow); 286 | } 287 | 288 | .btn:active { 289 | background-color: var(--gray-300); 290 | box-shadow: none; 291 | } 292 | 293 | 294 | .smallbtn { 295 | font-size: 0.9em; 296 | min-height: 2.1em; 297 | padding: 0em 0.6em; 298 | font-weight: 400; 299 | display: inline-block; 300 | } 301 | 302 | .secondary { 303 | color: var(--sbtn-color); 304 | border: var(--border-width) solid var(--sbtn-bg); 305 | background-color: var(--sbtn-bg); 306 | box-shadow: var(--btn-box-shadow); 307 | } 308 | 309 | .secondary:hover { 310 | color: var(--sbtn-hover-color); 311 | border: var(--border-width) solid var(--sbtn-hover-border-color); 312 | background-color: var(--sbtn-hover-bg); 313 | } 314 | 315 | .secondary:active { 316 | color: var(--sbtn-active-color); 317 | border: var(--border-width) solid var(--sbtn-active-border-color); 318 | background-color: var(--sbtn-active-bg); 319 | box-shadow: var(--sbtn-active-shadow); 320 | } 321 | 322 | .primary { 323 | color: var(--pbtn-color); 324 | border: var(--border-width) solid var(--pbtn-bg); 325 | background-color: var(--pbtn-bg); 326 | box-shadow: var(--btn-box-shadow); 327 | } 328 | 329 | .primary:hover { 330 | color: var(--pbtn-hover-color); 331 | border: var(--border-width) solid var(--pbtn-hover-border-color); 332 | background-color: var(--pbtn-hover-bg); 333 | } 334 | 335 | .primary:active { 336 | color: var(--pbtn-active-color); 337 | border: var(--border-width) solid var(--pbtn-active-border-color); 338 | background-color: var(--pbtn-active-bg); 339 | box-shadow: var(--pbtn-active-shadow); 340 | } 341 | 342 | .btn:disabled { 343 | opacity: .5; 344 | pointer-events: none; 345 | } 346 | 347 | /* modal window */ 348 | 349 | .modal { 350 | position: fixed; 351 | top: 0; 352 | left: 0; 353 | z-index: 5000; 354 | display: none; 355 | width: 100%; 356 | height: 100%; 357 | overflow: auto; 358 | outline: 0; 359 | background-color: rgba(0, 0, 0, 0.6); 360 | justify-content: center; 361 | align-items: center; 362 | } 363 | 364 | .dialog { 365 | max-height: 90vh; 366 | max-width: 90vw; 367 | box-shadow: var(--box-shadow); 368 | border-radius: var(--border-radius); 369 | margin: 0; 370 | padding: 0; 371 | background-color: var(--modal-hf-color); 372 | display: flex; 373 | flex-direction: column; 374 | } 375 | 376 | .bottom-radius { 377 | border-bottom-left-radius: var(--border-radius); 378 | border-bottom-right-radius: var(--border-radius); 379 | } 380 | 381 | .dialog-header { 382 | padding: 0em 1.5em; 383 | display: flex; 384 | justify-content: space-between; 385 | align-items: center; 386 | } 387 | 388 | .hflex { 389 | display: flex; 390 | justify-content: space-between; 391 | align-items: center; 392 | } 393 | 394 | .dialog-content { 395 | overflow: auto; 396 | background-color: var(--modal-bg-color); 397 | border-bottom: var(--border-width) solid var(--border-color); 398 | border-top: var(--border-width) solid var(--border-color); 399 | padding: 0.75em 1.5em; 400 | } 401 | 402 | .dialog-footer { 403 | padding: 1em 1.5em; 404 | display: flex; 405 | justify-content: right; 406 | align-items: center; 407 | } 408 | 409 | .btnicon { 410 | border: 0; 411 | border-radius: 50%; 412 | padding: 0.1em; 413 | line-height: 0; 414 | background-color: rgba(0, 0, 0, 0); 415 | cursor: pointer; 416 | fill: var(--close-color); 417 | } 418 | 419 | .btnicon:hover { 420 | /*background-color: var(--sbtn-bg); 421 | background-color: var(--card-border-color);*/ 422 | fill: var(--close-hover-color); 423 | } 424 | 425 | .bigger { 426 | font-size: 1.1em; 427 | } 428 | 429 | .smaller { 430 | font-size: 0.9em; 431 | } 432 | 433 | .center { 434 | display: flex; 435 | justify-content: center; 436 | align-items: center; 437 | } 438 | 439 | .loader { 440 | border: 0.5em solid var(--cardbg-color); 441 | border-top: 0.5em solid var(--pbtn-bg); 442 | border-radius: 50%; 443 | width: 4em; 444 | height: 4em; 445 | animation: spin 1.5s linear infinite; 446 | } 447 | 448 | @keyframes spin { 449 | 0% { 450 | transform: rotate(0deg); 451 | } 452 | 453 | 100% { 454 | transform: rotate(360deg); 455 | } 456 | } 457 | 458 | .dropdown { 459 | /*float: right;*/ 460 | position: relative; 461 | /*display: inline-block;*/ 462 | } 463 | 464 | .bottom100 { 465 | bottom: 100%; 466 | } 467 | 468 | .top100 { 469 | top: 100%; 470 | } 471 | 472 | .right { 473 | right: 0; 474 | } 475 | 476 | .left { 477 | left: 0; 478 | } 479 | 480 | .dropdown-menu { 481 | margin-top: 0.1em; 482 | font-size: 0.9em; 483 | border-radius: 0.375rem; 484 | box-shadow: var(--box-shadow); 485 | z-index: 100; 486 | display: block; 487 | min-width: 10em; 488 | overflow: auto; 489 | padding: 0.7em 0em; 490 | text-align: left; 491 | list-style: none; 492 | background-color: var(--dropdown-bg); 493 | background-clip: padding-box; 494 | border: var(--border-width) solid var(--border-color); 495 | } 496 | 497 | hr { 498 | margin: 1em 0; 499 | color: var(--border-color); 500 | border: 0; 501 | opacity: 1; 502 | } 503 | 504 | .dropdown-divider { 505 | height: 0; 506 | margin: 0.5em 0; 507 | overflow: hidden; 508 | /*color: var(--border-color);*/ 509 | border-top: 1px solid var(--border-color); 510 | opacity: 1; 511 | } 512 | 513 | .dropdown-item { 514 | display: flex; 515 | align-items: center; 516 | width: 100%; 517 | padding: 0.3em 1em; 518 | clear: both; 519 | font-weight: 400; 520 | color: var(--dropdown-link-color); 521 | fill: var(--dropdown-link-color); 522 | white-space: nowrap; 523 | } 524 | 525 | .dropdown-item svg { 526 | margin-right: 0.3em; 527 | } 528 | 529 | .dropdown-item:hover { 530 | text-decoration: none; 531 | color: var(--dropdown-link-color); 532 | background-color: var(--dropdown-hover-color); 533 | } 534 | 535 | 536 | .form-check-label { 537 | margin-left: 0.5em; 538 | /*min-height: 1.5em; 539 | line-height: 1.45em;*/ 540 | } 541 | 542 | label { 543 | font-size: 0.83em; 544 | font-weight: 600; 545 | /* letter-spacing: 0.02em; 546 | margin-bottom: 0.5em;*/ 547 | display: inline-block; 548 | } 549 | 550 | .form-check-input[type=radio] { 551 | border-radius: 50%; 552 | } 553 | 554 | .form-check-input[type=checkbox] { 555 | border-radius: 0.25em; 556 | } 557 | 558 | 559 | .form-check-input { 560 | appearance: none; 561 | -webkit-appearance: none; 562 | width: 1.2em; 563 | height: 1.2em; 564 | margin-top: 0.15em; 565 | margin-right: 0.4em; 566 | vertical-align: top; 567 | background-color: initial; 568 | background-repeat: no-repeat; 569 | background-position: 50%; 570 | background-size: contain; 571 | border: 1px solid var(--check-input-border-color); 572 | } 573 | 574 | .form-check-input:checked[type=radio] { 575 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='2' fill='%23fff'/%3E%3C/svg%3E"); 576 | } 577 | 578 | .form-check-input:checked[type=checkbox] { 579 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3 6-6'/%3E%3C/svg%3E"); 580 | } 581 | 582 | .form-check-input:checked { 583 | background-color: #2c7be5; 584 | border-color: #2c7be5; 585 | } 586 | 587 | .form-check-input:focus { 588 | border-color: var(--input-focus-border-color); 589 | box-shadow: 0 0 10px var(--input-focus-border-color); 590 | } 591 | 592 | .form-check-input:disabled { 593 | pointer-events: none; 594 | filter: none; 595 | opacity: .5; 596 | } 597 | 598 | .form-check-label:disabled, 599 | .form-check-label[disabled] { 600 | cursor: default; 601 | opacity: .5; 602 | } 603 | 604 | .form-check-input:disabled~.form-check-label, 605 | .form-check-input[disabled]~.form-check-label { 606 | cursor: default; 607 | opacity: .5; 608 | } 609 | 610 | .w-normal { 611 | max-width: 30em; 612 | } 613 | 614 | .panel { 615 | position: relative; 616 | display: flex; 617 | flex-direction: column; 618 | min-width: 0; 619 | word-wrap: break-word; 620 | background-color: var(--cardbg-color); 621 | background-clip: initial; 622 | border: var(--border-width) solid var(--card-border-color); 623 | border-radius: var(--border-radius); 624 | box-shadow: var(--box-shadow); 625 | } 626 | 627 | .panel-header { 628 | padding: 0.6em 1.25em; 629 | margin: 0; 630 | font-weight: bold; 631 | color: var(--font-color); 632 | border-bottom: var(--border-width) solid var(--card-border-color); 633 | } 634 | 635 | .panel-body { 636 | border-bottom-left-radius: var(--border-radius); 637 | border-bottom-right-radius: var(--border-radius); 638 | background-color: var(--dropdown-bg); 639 | padding: 1.2em 1.25em; 640 | } 641 | 642 | .form-select { 643 | appearance: none; 644 | -webkit-appearance: none; 645 | } 646 | 647 | .form-select { 648 | font-size: 1em; 649 | padding: 0.2em 3em 0.2em 0.8em; 650 | font-weight: 400; 651 | line-height: 1.5; 652 | color: var(--font-color); 653 | border: 1px solid var(--input-border-color); 654 | -webkit-appearance: none; 655 | appearance: none; 656 | border-radius: 0.25em; 657 | box-shadow: var(--box-shadow-inset); 658 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 659 | -moz-padding-start: calc(1em - 3px); 660 | background-color: var(--input-bg); 661 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%234d5969' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E"); 662 | background-repeat: no-repeat; 663 | background-position: right 1rem center; 664 | background-size: 16px 12px; 665 | } 666 | 667 | .form-select:focus { 668 | outline: none !important; 669 | border-color: var(--input-focus-border-color); 670 | box-shadow: 0 0 10px var(--input-focus-border-color); 671 | } 672 | 673 | .alert { 674 | position: relative; 675 | padding: 1em 1em; 676 | margin: 1em 0em; 677 | border-radius: 0.3em; 678 | } 679 | 680 | .alert-success { 681 | color: var(--alert-success); 682 | background-color: var(--alert-success-bg); 683 | border: var(--border-width) solid var(--alert-success-border); 684 | } 685 | 686 | .alert-warning { 687 | color: var(--alert-warning); 688 | background-color: var(--alert-warning-bg); 689 | border: var(--border-width) solid var(--alert-warning-border); 690 | } 691 | 692 | 693 | .nav-tabs { 694 | border-bottom: 2px solid var(--border-color); 695 | display: flex; 696 | flex-wrap: wrap; 697 | padding-left: 0; 698 | margin-bottom: 0; 699 | list-style: none; 700 | } 701 | 702 | .tab-link { 703 | margin-bottom: -2px; 704 | border: var(--border-color); 705 | color: var(--gray-800); 706 | font-weight: 600; 707 | padding: 1em 1.5em; 708 | background: none; 709 | cursor: pointer; 710 | } 711 | 712 | .tab-active { 713 | color: var(--primary); 714 | background-color: initial; 715 | border-bottom: 2px solid var(--primary); 716 | } 717 | 718 | .tags { 719 | display: flex; 720 | font-size: 0.9em; 721 | column-gap: 0.5em; 722 | row-gap: 0.5em; 723 | /* padding: 0.5em 0em;*/ 724 | flex-wrap: wrap; 725 | } 726 | 727 | .droptags { 728 | font-size: 1em; 729 | padding: 0.5em 1em; 730 | } 731 | 732 | .droptags a:hover { 733 | color: var(--tag-color); 734 | } 735 | 736 | .tag { 737 | padding-right: 0.2em; 738 | fill: var(--tag-color); 739 | font-weight: 600; 740 | white-space: nowrap; 741 | background-color: var(--tag-bg); 742 | color: var(--tag-color); 743 | border-radius: var(--tag-radius); 744 | } 745 | 746 | .tag span { 747 | padding: 0.1em 0.2em 0.1em 0.4em; 748 | } 749 | 750 | .star { 751 | color: var(--red); 752 | padding-left: 0.25em; 753 | } -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonkazachenko/go-todo-list-api/d377945b13bdef31e25a53c4467dcaa3e66b2322/web/favicon.ico -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |