Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc1cc3d65
|
||
|
|
3b591ac85e
|
||
|
|
b1cece9138
|
||
|
|
1ff3ba964c | ||
|
|
eea8313f30
|
||
|
|
419a33f7fc | ||
|
|
169f07801a | ||
|
|
a55a80e916 | ||
|
|
6272650ef9 | ||
|
|
54eeb66f0d | ||
|
|
a9f2b7b3a4
|
||
|
|
d6a7ee34ef
|
@@ -48,7 +48,9 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: easynode-debian
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
has_changes: ${{ steps.check-changes.outputs.has_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -115,59 +115,17 @@ jobs:
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: easynode-debian
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://tasknote.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
if: steps.check-changes.outputs.has_changes == 'true'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -92,49 +92,9 @@ jobs:
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: easynode-debian
|
||||
needs: terraform-plan-stg
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://tasknote-stg.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform-stg
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -31,14 +31,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
run: docker buildx inspect --bootstrap
|
||||
|
||||
@@ -63,11 +55,24 @@ jobs:
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version from image
|
||||
id: version
|
||||
run: |
|
||||
docker pull rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
|
||||
VITE_BUILD=$(docker inspect rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }} \
|
||||
--format '{{ range .Config.Env }}{{ println . }}{{ end }}' \
|
||||
| grep '^VITE_BUILD=' | cut -d= -f2)
|
||||
if [ -z "$VITE_BUILD" ]; then
|
||||
echo "Could not extract VITE_BUILD from image" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=${VITE_BUILD}" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: ${VITE_BUILD}"
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag rmcampos/tasknote-app:latest \
|
||||
--tag rmcampos/tasknote-app:${{ steps.version.outputs.tag }} \
|
||||
rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
|
||||
@@ -109,7 +109,9 @@ jobs:
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
|
||||
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
|
||||
|
||||
+14
-2
@@ -7,13 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## api-v29 && app
|
||||
## app-v2026.06.24.? - 2026-06-25
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.1.0
|
||||
- Bumped client minor and major dependencies.
|
||||
|
||||
### Docker images
|
||||
- `docker.io/rmcampos/tasknote-app:app-v2026.06.24.?`
|
||||
|
||||
## api-v32 && app-v2026.06.15.97 - 2026-06-15
|
||||
|
||||
### Changed
|
||||
- Bumped Spring Boot to 4.0.7
|
||||
- CI/CD workflow files updated to run on Gitea.
|
||||
- Container registry switched to Docker Hub.
|
||||
|
||||
### Docker images
|
||||
- [rmcampos/tasknote-api:32](https://hub.docker.com/layers/rmcampos/tasknote-api/32/images/sha256-4b719a08dbed4a9d4a6eece0059573954ee5193ab8247787fb0e30c037f6b1c6)
|
||||
- [rmcampos/tasknote-app:app-v2026.06.15.97](https://hub.docker.com/layers/rmcampos/tasknote-app/app-v2026.06.15.97/images/sha256-945a215a7105e34f97ab8e43094092e157156c0b557364260c019c4036cf845d)
|
||||
|
||||
## [app-v2026.06.08.22](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.22) - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# TaskNote
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
|
||||
[](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-frontend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-backend.yml)
|
||||
[](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=cd-main.yml)
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -102,113 +101,50 @@ tasknote/
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- **Docker & Docker Compose** (recommended for easy setup)
|
||||
- **Node.js 20+** and **npm** (for frontend development)
|
||||
- **Java 25+** and **Maven 3.6+** (for backend development)
|
||||
- **PostgreSQL 15+** (if running without Docker)
|
||||
- [Docker](https://docs.docker.com/engine/install/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Task](https://taskfile.dev) (`brew install go-task` / `npm install -g @go-task/cli`)
|
||||
- [Doppler CLI](https://docs.doppler.com/docs/install-cli) (`brew install dopplerhq/cli/doppler`)
|
||||
|
||||
### Quick Start with Docker
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/rmcampos/tasknote.git
|
||||
cd tasknote
|
||||
```
|
||||
### Setup
|
||||
|
||||
2. **Start the database**
|
||||
```bash
|
||||
bash tools/run-docker-db.sh
|
||||
```
|
||||
|
||||
3. **Start the backend server**
|
||||
```bash
|
||||
bash tools/run-docker-server.sh
|
||||
```
|
||||
|
||||
4. **Start the frontend application**
|
||||
```bash
|
||||
bash tools/run-docker-client.sh
|
||||
```
|
||||
|
||||
5. **Access the application**
|
||||
- Frontend: http://localhost:5000
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd client
|
||||
npm install # Install dependencies
|
||||
npm start # Start development server (port 5000)
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Fix ESLint issues
|
||||
# 1. Authenticate with Doppler and link the project
|
||||
doppler login
|
||||
doppler setup # uses doppler.yaml to link to the shell-whats project
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
cd server
|
||||
./mvnw spring-boot:run # Start development server
|
||||
./mvnw clean compile # Compile sources
|
||||
./mvnw spring-boot:build-image # Build Docker image
|
||||
./mvnw clean verify -Pnative # Build GraalVM native image
|
||||
task dev-run
|
||||
```
|
||||
|
||||
### Quality Checks
|
||||
Run quality checks before submitting changes:
|
||||
This exports the public vars from the `dev_tokens` Doppler config and starts the server in watch mode with secrets injected from `dev_secrets`. No `.env` file needed.
|
||||
|
||||
## Building the Docker images
|
||||
|
||||
```bash
|
||||
bash tools/check-frontend.sh # Frontend linting, testing, coverage
|
||||
bash tools/check-backend.sh # Backend compilation, tests, checkstyle
|
||||
# Build the backend
|
||||
task docker-build-api
|
||||
|
||||
# Build the frontend
|
||||
task docker-build-web
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
## 🧪 Testing & Checks
|
||||
|
||||
### Frontend Testing
|
||||
- **Framework**: Vitest with React Testing Library
|
||||
- **Coverage**: Comprehensive test coverage with reports in `client/coverage/`
|
||||
- **Commands**:
|
||||
```bash
|
||||
npm test # Run tests in watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
```bash
|
||||
./tools/check-frontend.sh
|
||||
```
|
||||
|
||||
### Backend Testing
|
||||
- **Unit Tests**: Fast, isolated tests with mocked dependencies
|
||||
- **Integration Tests**: Full application context with test database
|
||||
- **Coverage**: JaCoCo reporting with 75% minimum requirement
|
||||
- **Commands**:
|
||||
```bash
|
||||
./mvnw test # Unit tests only
|
||||
./mvnw clean verify -Ptests # All tests with coverage
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Production Deployment
|
||||
The application supports multiple deployment strategies:
|
||||
|
||||
1. **Docker Containers** (recommended)
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
2. **Traditional JAR Deployment**
|
||||
```bash
|
||||
cd server && ./mvnw clean package
|
||||
java -jar target/tasknote-api.jar
|
||||
```
|
||||
|
||||
3. **GraalVM Native Image** (for optimal performance)
|
||||
```bash
|
||||
cd server && ./mvnw clean verify -Pnative
|
||||
./target/tasknote-api
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
- Database connection via environment variables
|
||||
- JWT secret configuration for production
|
||||
- Email service configuration for notifications
|
||||
- CORS settings for frontend domain
|
||||
```bash
|
||||
./tools/check-backend.sh
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -250,34 +186,6 @@ We welcome contributions from the community! This project follows the **Fork & M
|
||||
|
||||
For detailed setup instructions and development workflows, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 👨💻 Developer
|
||||
|
||||
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
|
||||
|
||||
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
|
||||
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
|
||||
|
||||
### About the Developer
|
||||
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
|
||||
|
||||
- **Frontend Development**: React, TypeScript, modern CSS, responsive design
|
||||
- **Backend Development**: Java, Spring Boot, RESTful APIs, microservices
|
||||
- **DevOps & Infrastructure**: Docker, CI/CD, cloud deployment, monitoring
|
||||
- **Software Quality**: Testing strategies, code coverage, static analysis
|
||||
- **Open Source**: Community engagement, documentation, maintainership
|
||||
|
||||
The TaskNote project represents a commitment to clean code, comprehensive testing, and user-centered design principles.
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
For questions, suggestions, or collaboration opportunities:
|
||||
|
||||
- **Email**: Contact via GitHub issues or discussions
|
||||
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
|
||||
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
|
||||
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Generated
+1355
-1113
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -13,12 +13,12 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"bootstrap": "^5.3.8",
|
||||
"dompurify": "^3.4.8",
|
||||
"i18next": "^26.3.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"i18next": "^26.3.2",
|
||||
"react": "^19.2.7",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-bootstrap-icons": "^1.11.6",
|
||||
@@ -27,11 +27,11 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.17.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite --host",
|
||||
@@ -75,21 +75,21 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-router-bootstrap": "^0.26.8",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"cypress": "^15.16.0",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"cypress": "^15.18.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-jsdoc": "^62.9.0",
|
||||
"eslint-plugin-import-x": "^4.17.0",
|
||||
"eslint-plugin-jsdoc": "^63.0.7",
|
||||
"eslint-plugin-n": "^18.1.0",
|
||||
"eslint-plugin-promise": "^7.3.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.100.0",
|
||||
"prettier": "^3.8.4",
|
||||
"sass": "^1.101.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vitest": "^4.1.8"
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,19 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signed) return;
|
||||
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
|
||||
const intervalId = setInterval(() => {
|
||||
checkCurrentAuthUser(window.location.pathname).catch(() => {
|
||||
setSigned(false);
|
||||
setUser(undefined);
|
||||
localStorage.clear();
|
||||
});
|
||||
}, TWENTY_FIVE_MINUTES);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [signed]);
|
||||
|
||||
const updateUser = (userUpdated: UserResponse): void => {
|
||||
setUser(userUpdated);
|
||||
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
@@ -23,6 +24,13 @@ import ContentHeader from '../../components/ContentHeader';
|
||||
|
||||
type NoteAction = 'add' | 'edit';
|
||||
|
||||
interface NoteDraft {
|
||||
title: string;
|
||||
content: string;
|
||||
noteUrl: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NoteAdd component for adding and editing notes.
|
||||
*
|
||||
@@ -41,10 +49,15 @@ function NoteAdd(): React.ReactNode {
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [action, setAction] = useState<NoteAction>('add');
|
||||
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -84,7 +97,6 @@ function NoteAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -115,28 +127,84 @@ function NoteAdd(): React.ReactNode {
|
||||
setNoteContent('');
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: NoteDraft = JSON.parse(raw);
|
||||
setNoteTitle(draft.title ?? '');
|
||||
setNoteContent(draft.content ?? '');
|
||||
setNoteUrl(draft.noteUrl ?? '');
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags([...selectedTags, normalized]);
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, noteUrl, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -149,7 +217,6 @@ function NoteAdd(): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current tag if not empty before submitting
|
||||
const finalTags = [...selectedTags];
|
||||
if (currentTag.trim()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
@@ -172,6 +239,7 @@ function NoteAdd(): React.ReactNode {
|
||||
|
||||
const added: boolean = await addNote(payload);
|
||||
if (added) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -191,6 +259,7 @@ function NoteAdd(): React.ReactNode {
|
||||
|
||||
const edited: boolean = await submitEditNote(payload);
|
||||
if (edited) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -207,6 +276,7 @@ function NoteAdd(): React.ReactNode {
|
||||
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
|
||||
setNoteFromServer(noteToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -238,16 +308,16 @@ function NoteAdd(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
const setNoteFromServer = (noteContent: NoteResponse) => {
|
||||
setNoteId(noteContent.id);
|
||||
setNoteTitle(noteContent.title);
|
||||
if (noteContent.url) {
|
||||
setNoteUrl(noteContent.url);
|
||||
const setNoteFromServer = (noteData: NoteResponse) => {
|
||||
setNoteId(noteData.id);
|
||||
setNoteTitle(noteData.title);
|
||||
if (noteData.url) {
|
||||
setNoteUrl(noteData.url);
|
||||
}
|
||||
if (noteContent.tags) {
|
||||
setSelectedTags(noteContent.tags);
|
||||
if (noteData.tags) {
|
||||
setSelectedTags(noteData.tags);
|
||||
}
|
||||
setNoteContent(noteContent.description);
|
||||
setNoteContent(noteData.description);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -271,6 +341,10 @@ function NoteAdd(): React.ReactNode {
|
||||
checkEditUrl();
|
||||
checkCloneUrl();
|
||||
|
||||
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -280,6 +354,7 @@ function NoteAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -304,6 +379,22 @@ function NoteAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
@@ -321,6 +412,8 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteTitle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteTitle(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -337,6 +430,8 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNoteUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -438,6 +533,8 @@ function NoteAdd(): React.ReactNode {
|
||||
value={noteContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNoteContent(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
|
||||
}}
|
||||
data-testid="note-content-input-area"
|
||||
/>
|
||||
@@ -454,6 +551,7 @@ function NoteAdd(): React.ReactNode {
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
@@ -23,6 +24,14 @@ import AlertError from '../../components/AlertError';
|
||||
|
||||
type TaskAction = 'add' | 'edit';
|
||||
|
||||
interface TaskDraft {
|
||||
description: string;
|
||||
taskUrl: string;
|
||||
dueDate: string | null;
|
||||
highPriority: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAdd component for adding and editing tasks.
|
||||
*
|
||||
@@ -42,10 +51,15 @@ function TaskAdd(): React.ReactNode {
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
|
||||
const [draftBanner, setDraftBanner] = useState<boolean>(false);
|
||||
const { i18n, t } = useTranslation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const tagContainerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasUserEdited = useRef<boolean>(false);
|
||||
|
||||
const draftKey = params?.id ? `draft:task:edit:${params.id}` : 'draft:task:new';
|
||||
|
||||
const loadTags = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -85,7 +99,6 @@ function TaskAdd(): React.ReactNode {
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -118,28 +131,112 @@ function TaskAdd(): React.ReactNode {
|
||||
setHighPriority(false);
|
||||
setCurrentTag('');
|
||||
setSelectedTags([]);
|
||||
|
||||
setAction('add');
|
||||
setValidated(false);
|
||||
};
|
||||
|
||||
const saveDraft = (
|
||||
description: string,
|
||||
taskUrl: string,
|
||||
due: Date | null,
|
||||
priority: boolean,
|
||||
draftTags: string[]
|
||||
): void => {
|
||||
if (!hasUserEdited.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const draft: TaskDraft = {
|
||||
description,
|
||||
taskUrl,
|
||||
dueDate: due ? due.toISOString() : null,
|
||||
highPriority: priority,
|
||||
tags: draftTags
|
||||
};
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const clearDraft = (): void => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
localStorage.removeItem(draftKey);
|
||||
};
|
||||
|
||||
const applyDraft = (): void => {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft: TaskDraft = JSON.parse(raw);
|
||||
setTaskDescription(draft.description ?? '');
|
||||
setTaskUrl(draft.taskUrl ?? '');
|
||||
const parsedDate = draft.dueDate ? new Date(draft.dueDate) : null;
|
||||
setDueDate(parsedDate && !isNaN(parsedDate.getTime()) ? parsedDate : null);
|
||||
setHighPriority(draft.highPriority ?? false);
|
||||
setSelectedTags(draft.tags ?? []);
|
||||
setDraftBanner(true);
|
||||
}
|
||||
catch {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
};
|
||||
|
||||
const setTaskFromServer = (task: TaskResponse): void => {
|
||||
setTaskId(task.id);
|
||||
setTaskDescription(task.description);
|
||||
setTaskUrl(task.urls.length ? task.urls[0] : '');
|
||||
setTaskDone(task.done);
|
||||
if (task.dueDateFmt) {
|
||||
setDueDate(new Date(task.dueDate));
|
||||
}
|
||||
setHighPriority(task.highPriority);
|
||||
if (task.tags) {
|
||||
setSelectedTags(task.tags);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (): Promise<void> => {
|
||||
setDraftBanner(false);
|
||||
if (params?.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskFromServer(taskToEdit);
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
finally {
|
||||
clearDraft();
|
||||
}
|
||||
}
|
||||
else {
|
||||
clearDraft();
|
||||
resetInputs();
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (tagName: string): void => {
|
||||
const normalized = tagName.trim().toLowerCase();
|
||||
let newTags = [...selectedTags];
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags([...selectedTags, normalized]);
|
||||
newTags = [...selectedTags, normalized];
|
||||
setSelectedTags(newTags);
|
||||
}
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
setCurrentTag('');
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string): void => {
|
||||
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
|
||||
const newTags = selectedTags.filter(t => t !== tagToRemove);
|
||||
setSelectedTags(newTags);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the form submission.
|
||||
*
|
||||
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
|
||||
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
|
||||
*/
|
||||
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
@@ -157,7 +254,6 @@ function TaskAdd(): React.ReactNode {
|
||||
dueDateFormatted = dueDate.toISOString().substring(0, 10);
|
||||
}
|
||||
|
||||
// Add current tag if not empty before submitting
|
||||
const finalTags = [...selectedTags];
|
||||
if (currentTag.trim()) {
|
||||
const normalized = currentTag.trim().toLowerCase();
|
||||
@@ -177,6 +273,7 @@ function TaskAdd(): React.ReactNode {
|
||||
|
||||
const added: boolean = await addTask(addPayload);
|
||||
if (added) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -197,6 +294,7 @@ function TaskAdd(): React.ReactNode {
|
||||
|
||||
const edited: boolean = await submitEditTask(editPayload);
|
||||
if (edited) {
|
||||
clearDraft();
|
||||
form.reset();
|
||||
resetInputs();
|
||||
navigate('/home');
|
||||
@@ -211,18 +309,9 @@ function TaskAdd(): React.ReactNode {
|
||||
if (params.id) {
|
||||
try {
|
||||
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
|
||||
setTaskId(taskToEdit.id);
|
||||
setTaskDescription(taskToEdit.description);
|
||||
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
|
||||
setTaskDone(taskToEdit.done);
|
||||
if (taskToEdit.dueDateFmt) {
|
||||
setDueDate(new Date(taskToEdit.dueDate));
|
||||
}
|
||||
setHighPriority(taskToEdit.highPriority);
|
||||
if (taskToEdit.tags) {
|
||||
setSelectedTags(taskToEdit.tags);
|
||||
}
|
||||
setTaskFromServer(taskToEdit);
|
||||
setAction('edit');
|
||||
applyDraft();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
@@ -234,6 +323,10 @@ function TaskAdd(): React.ReactNode {
|
||||
loadTags();
|
||||
checkEditUrl();
|
||||
|
||||
if (!params?.id) {
|
||||
applyDraft();
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
|
||||
setShowTagDropdown(false);
|
||||
@@ -243,6 +336,7 @@ function TaskAdd(): React.ReactNode {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -268,6 +362,22 @@ function TaskAdd(): React.ReactNode {
|
||||
onClose={() => setErrorMessage('')}
|
||||
/>
|
||||
|
||||
{draftBanner && (
|
||||
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
|
||||
Draft restored from a previous session.
|
||||
{' '}
|
||||
<Alert.Link
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
void handleDiscardDraft();
|
||||
}}
|
||||
>
|
||||
Discard draft
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
noValidate
|
||||
validated={validated}
|
||||
@@ -285,6 +395,8 @@ function TaskAdd(): React.ReactNode {
|
||||
value={taskDescription}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskDescription(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -301,6 +413,8 @@ function TaskAdd(): React.ReactNode {
|
||||
value={taskUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskUrl(e.target.value);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, e.target.value, dueDate, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -316,6 +430,8 @@ function TaskAdd(): React.ReactNode {
|
||||
valueDate={dueDate}
|
||||
onChangeDate={(date: Date | null) => {
|
||||
setDueDate(date);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, date, highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -402,7 +518,11 @@ function TaskAdd(): React.ReactNode {
|
||||
className="mb-3"
|
||||
name="highPriority"
|
||||
checked={highPriority}
|
||||
onChange={() => setHighPriority(!highPriority)}
|
||||
onChange={() => {
|
||||
setHighPriority(!highPriority);
|
||||
hasUserEdited.current = true;
|
||||
saveDraft(taskDescription, taskUrl, dueDate, !highPriority, selectedTags);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
@@ -416,6 +536,7 @@ function TaskAdd(): React.ReactNode {
|
||||
type="button"
|
||||
className="ms-2 home-new-item-secondary task-note-btn"
|
||||
onClick={() => {
|
||||
clearDraft();
|
||||
navigate('/home');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
sourcemap: mode === 'development'
|
||||
},
|
||||
server: {
|
||||
port: 5000
|
||||
|
||||
@@ -23,13 +23,13 @@ services:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_HOST: tasknote-db
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: production
|
||||
SECURITY_KEY: ${SECURITY_KEY:-default-security-key}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY:-default-mailgun-apikey}
|
||||
SECURITY_KEY: ${SECURITY_KEY}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
|
||||
ports: ["8585:8585"]
|
||||
image: ghcr.io/rmcampos/tasknote/api:latest
|
||||
healthcheck:
|
||||
@@ -47,8 +47,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
ports: ["5432:5432"]
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
healthcheck:
|
||||
test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1'
|
||||
interval: 1m30s
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>32</version>
|
||||
<version>33</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/rest/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
.denyAll())
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(
|
||||
@@ -77,7 +77,7 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.TaskEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -11,6 +12,8 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
|
||||
|
||||
List<TaskEntity> findAllByUser_id(Long userId);
|
||||
|
||||
Optional<TaskEntity> findByIdAndUser_id(Long id, Long userId);
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select distinct t
|
||||
|
||||
@@ -78,12 +78,8 @@ public class LoginRequest {
|
||||
+ "email='"
|
||||
+ email
|
||||
+ '\''
|
||||
+ ", password='"
|
||||
+ password
|
||||
+ '\''
|
||||
+ ", passwordAgain='"
|
||||
+ passwordAgain
|
||||
+ '\''
|
||||
+ ", password='[REDACTED]'"
|
||||
+ ", passwordAgain='[REDACTED]'"
|
||||
+ ", lang='"
|
||||
+ lang
|
||||
+ '\''
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note patch payload. */
|
||||
public record NotePatchRequest(
|
||||
String title,
|
||||
String description,
|
||||
@Pattern(
|
||||
@Size(max = 100) String title,
|
||||
@Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
|
||||
@@ -2,13 +2,15 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a note request to be created. */
|
||||
public record NoteRequest(
|
||||
@NotNull String title,
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
@NotNull @Size(max = 100) String title,
|
||||
@NotNull @Size(max = 50000) String description,
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
String url,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task patch payload. */
|
||||
public record TaskPatchRequest(
|
||||
String description,
|
||||
@Size(max = 2000) String description,
|
||||
Boolean done,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
|
||||
@@ -3,12 +3,14 @@ package br.com.tasknoteapp.server.request;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task request to be created. */
|
||||
public record TaskRequest(
|
||||
@NotNull @NotEmpty String description,
|
||||
@NotNull @NotEmpty @Size(max = 2000) String description,
|
||||
List<
|
||||
@Size(max = 200)
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with https:// or #")
|
||||
|
||||
@@ -2,4 +2,10 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
/** This record represents a user patch payload. */
|
||||
public record UserPatchRequest(
|
||||
String name, String email, String password, String passwordAgain, String lang) {}
|
||||
String name,
|
||||
String email,
|
||||
String password,
|
||||
String passwordAgain,
|
||||
String lang,
|
||||
String currentPassword) {}
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ public class AuthService {
|
||||
|
||||
String token = jwtService.generateToken(currentUser);
|
||||
|
||||
logger.info("User refreshed! Token {}", token);
|
||||
logger.info("User refreshed! Token {}...", token.substring(0, 6));
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -329,11 +329,26 @@ public class AuthService {
|
||||
boolean shouldUpdate = false;
|
||||
boolean emailChanged = false;
|
||||
|
||||
boolean changingEmail =
|
||||
!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank();
|
||||
boolean changingPassword =
|
||||
!Objects.isNull(patchRequest.password()) && !patchRequest.password().isBlank();
|
||||
|
||||
if (changingEmail || changingPassword) {
|
||||
if (Objects.isNull(patchRequest.currentPassword())
|
||||
|| patchRequest.currentPassword().isBlank()) {
|
||||
throw new BadPasswordException("Current password is required to change email or password");
|
||||
}
|
||||
if (!passwordEncoder.matches(patchRequest.currentPassword(), currentUser.getPassword())) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) {
|
||||
currentUser.setName(patchRequest.name().trim());
|
||||
shouldUpdate = true;
|
||||
}
|
||||
if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) {
|
||||
if (changingEmail) {
|
||||
currentUser.setEmail(patchRequest.email().trim());
|
||||
shouldUpdate = true;
|
||||
emailChanged = true;
|
||||
@@ -344,8 +359,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
boolean updatePassword =
|
||||
!Objects.isNull(patchRequest.password())
|
||||
&& !patchRequest.password().isBlank()
|
||||
changingPassword
|
||||
&& !Objects.isNull(patchRequest.passwordAgain())
|
||||
&& !patchRequest.passwordAgain().isBlank();
|
||||
|
||||
@@ -563,11 +577,11 @@ public class AuthService {
|
||||
|
||||
// if it's more than 3 times in the last 10 minutes, raise timer of 3 hours.
|
||||
if (userPwdList.size() >= 3) {
|
||||
UserPwdLimitEntity mostRecent = userPwdList.getFirst();
|
||||
logger.warn("Oldest: {}", mostRecent.getWhenHappened());
|
||||
Duration duration = Duration.between(mostRecent.getWhenHappened(), LocalDateTime.now());
|
||||
UserPwdLimitEntity oldest = userPwdList.getLast();
|
||||
logger.warn("Oldest failed attempt: {}", oldest.getWhenHappened());
|
||||
Duration duration = Duration.between(oldest.getWhenHappened(), LocalDateTime.now());
|
||||
if (duration.toMinutes() <= 3L) {
|
||||
logger.warn("Wait more {}", 3L - duration.toMinutes());
|
||||
logger.warn("Account locked, minutes remaining: {}", 3L - duration.toMinutes());
|
||||
throw new MaxLoginLimitAttemptException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ public class TaskService {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
@@ -152,7 +152,7 @@ public class TaskService {
|
||||
|
||||
logger.info("Patching task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
@@ -198,7 +198,7 @@ public class TaskService {
|
||||
|
||||
logger.info("Deleting task ID {} to user ID {}", taskId, user.getId());
|
||||
|
||||
Optional<TaskEntity> task = taskRepository.findById(taskId);
|
||||
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
|
||||
if (task.isEmpty()) {
|
||||
throw new TaskNotFoundException();
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package br.com.tasknoteapp.server.service.impl;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.service.JwtService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -29,7 +29,7 @@ class JwtServiceImpl implements JwtService {
|
||||
private static final long MINUTE = SECOND * 60;
|
||||
private static final long HOUR = MINUTE * 60;
|
||||
private static final long DAY = HOUR * 24;
|
||||
private static final long EXPIRATION_TIME = DAY * 7;
|
||||
private static final long EXPIRATION_TIME = MINUTE * 30;
|
||||
private final SecretKey key;
|
||||
|
||||
public JwtServiceImpl(@Value("${br.com.tasknote.server.jwt-secret}") String secretKey) {
|
||||
@@ -122,7 +122,7 @@ class JwtServiceImpl implements JwtService {
|
||||
try {
|
||||
return Optional.of(
|
||||
Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload());
|
||||
} catch (MalformedJwtException me) {
|
||||
} catch (JwtException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,17 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.BadAlgorithmException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** This class provides method to handle UUIDs. */
|
||||
public class UuidUtil {
|
||||
private final UUID namespaceUrl = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
|
||||
|
||||
/**
|
||||
* Generated a unique UUID to a given email.
|
||||
* Generates a cryptographically random UUID for use as an email confirmation token.
|
||||
*
|
||||
* @param email The email to create the UUID.
|
||||
* @return The generated UUID.
|
||||
* @param email The user email (unused; kept for API compatibility).
|
||||
* @return A random UUID.
|
||||
*/
|
||||
public UUID generateEmailUuid(String email) {
|
||||
return generateUuidFromName(namespaceUrl, email.toLowerCase().trim());
|
||||
}
|
||||
|
||||
private UUID generateUuidFromName(UUID namespace, String name) {
|
||||
// SHA-1 digest of namespace UUID + name
|
||||
byte[] namespaceBytes = toBytes(namespace);
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] combined = new byte[namespaceBytes.length + nameBytes.length];
|
||||
System.arraycopy(namespaceBytes, 0, combined, 0, namespaceBytes.length);
|
||||
System.arraycopy(nameBytes, 0, combined, namespaceBytes.length, nameBytes.length);
|
||||
|
||||
byte[] sha1 = sha1(combined);
|
||||
|
||||
// Manipulate bits to make it UUID v5 (version 5, SHA-1)
|
||||
sha1[6] &= 0x0f;
|
||||
sha1[6] |= 0x50;
|
||||
sha1[8] &= 0x3f;
|
||||
sha1[8] |= (byte) 0x80;
|
||||
|
||||
return bytesToUuid(sha1);
|
||||
}
|
||||
|
||||
private byte[] toBytes(UUID uuid) {
|
||||
long msb = uuid.getMostSignificantBits();
|
||||
long lsb = uuid.getLeastSignificantBits();
|
||||
byte[] bytes = new byte[16];
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
bytes[i] = (byte) ((msb >>> (8 * (7 - i))) & 0xFF);
|
||||
bytes[8 + i] = (byte) ((lsb >>> (8 * (7 - i))) & 0xFF);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private byte[] sha1(byte[] input) {
|
||||
try {
|
||||
return java.security.MessageDigest.getInstance("SHA-1").digest(input);
|
||||
} catch (Exception e) {
|
||||
throw new BadAlgorithmException("SHA-1 algorithm not available");
|
||||
}
|
||||
}
|
||||
|
||||
private UUID bytesToUuid(byte[] hash) {
|
||||
long msb = 0;
|
||||
long lsb = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
msb = (msb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
for (int i = 8; i < 16; i++) {
|
||||
lsb = (lsb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
return new UUID(msb, lsb);
|
||||
return UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlServerUntrustedCertificateSqlException",
|
||||
"allDeclaredMethods": true,
|
||||
"allDeclaredConstructors": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,14 +2,14 @@ br:
|
||||
com:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:empty}
|
||||
jwt-secret: ${SECURITY_KEY}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
|
||||
logging:
|
||||
level:
|
||||
root: ${ROOT_LOG_LEVEL:INFO}
|
||||
br.com.tasknoteapp: TRACE
|
||||
br.com.tasknoteapp: INFO
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
@@ -19,7 +19,7 @@ mailgun:
|
||||
server:
|
||||
port: 8585
|
||||
error:
|
||||
include-message: always
|
||||
include-message: never
|
||||
servlet:
|
||||
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
|
||||
spring:
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
name: tasknote-api
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
password: ${POSTGRES_PASSWORD:default}
|
||||
password: ${POSTGRES_PASSWORD}
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
|
||||
username: ${POSTGRES_USER:tasknoteuser}
|
||||
flyway:
|
||||
|
||||
@@ -2,14 +2,14 @@ br:
|
||||
com:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:empty}
|
||||
jwt-secret: ${SECURITY_KEY}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
|
||||
logging:
|
||||
level:
|
||||
root: ${ROOT_LOG_LEVEL:INFO}
|
||||
br.com.tasknoteapp: TRACE
|
||||
br.com.tasknoteapp: INFO
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
@@ -19,7 +19,7 @@ mailgun:
|
||||
server:
|
||||
port: 8585
|
||||
error:
|
||||
include-message: always
|
||||
include-message: never
|
||||
servlet:
|
||||
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
|
||||
spring:
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
name: tasknote-api
|
||||
datasource:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
password: ${POSTGRES_PASSWORD:default}
|
||||
password: ${POSTGRES_PASSWORD}
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
|
||||
username: ${POSTGRES_USER:tasknoteuser}
|
||||
flyway:
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD CONSTRAINT chk_notes_description_max_length CHECK (length(description) <= 50000) NOT VALID;
|
||||
@@ -103,7 +103,8 @@ class UserControllerTest {
|
||||
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
|
||||
UserResponse response =
|
||||
new UserResponse(1L, "John", "email@example.com", false, null, null, null, null);
|
||||
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null);
|
||||
UserPatchRequest request =
|
||||
new UserPatchRequest("John Doe", response.email(), null, null, null, null);
|
||||
when(authService.patchUserInfo(request)).thenReturn(response);
|
||||
|
||||
String jsonString =
|
||||
|
||||
@@ -264,9 +264,11 @@ class AuthServiceTest {
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
UserPwdLimitEntity limit1 = new UserPwdLimitEntity();
|
||||
limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1));
|
||||
limit1.setWhenHappened(LocalDateTime.now().minusSeconds(30));
|
||||
UserPwdLimitEntity limit2 = new UserPwdLimitEntity();
|
||||
limit2.setWhenHappened(LocalDateTime.now().minusMinutes(1));
|
||||
UserPwdLimitEntity limit3 = new UserPwdLimitEntity();
|
||||
limit3.setWhenHappened(LocalDateTime.now().minusMinutes(2));
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of(limit1, limit2, limit3));
|
||||
|
||||
@@ -417,12 +419,15 @@ class AuthServiceTest {
|
||||
existing.setName(null);
|
||||
existing.setEmail(email);
|
||||
existing.setAdmin(false);
|
||||
existing.setPassword("hashedCurrentPassword");
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
String currentPassword = "currentPw123@";
|
||||
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
|
||||
|
||||
UserPatchRequest patchRequest =
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null);
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null, currentPassword);
|
||||
UserResponse response = authService.patchUserInfo(patchRequest);
|
||||
|
||||
Assertions.assertNotNull(response);
|
||||
@@ -441,13 +446,17 @@ class AuthServiceTest {
|
||||
existing.setName(null);
|
||||
existing.setEmail(email);
|
||||
existing.setAdmin(false);
|
||||
existing.setPassword("hashedCurrentPassword");
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
String currentPassword = "currentPw123@";
|
||||
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
|
||||
|
||||
String newPassword = "TestHackedPw@difficult!#:)";
|
||||
UserPatchRequest patchRequest =
|
||||
new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword, "en");
|
||||
new UserPatchRequest(
|
||||
"Kong", "newemail@domain.com", newPassword, newPassword, "en", currentPassword);
|
||||
|
||||
when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty());
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class TaskServiceTest {
|
||||
taskEntity.setHighPriority(true);
|
||||
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
|
||||
taskEntity.setUser(userEntity);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
|
||||
|
||||
TaskResponse taskResponse = taskService.getTaskById(taskId);
|
||||
|
||||
@@ -108,7 +108,7 @@ class TaskServiceTest {
|
||||
|
||||
Long taskId = 9976L;
|
||||
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(TaskNotFoundException.class, () -> taskService.getTaskById(taskId));
|
||||
}
|
||||
@@ -312,7 +312,7 @@ class TaskServiceTest {
|
||||
taskEntity.setHighPriority(true);
|
||||
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
|
||||
taskEntity.setUser(userEntity);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
|
||||
|
||||
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
|
||||
|
||||
@@ -335,7 +335,7 @@ class TaskServiceTest {
|
||||
|
||||
Long taskId = 2526L;
|
||||
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(TaskNotFoundException.class, () -> taskService.deleteTask(taskId));
|
||||
}
|
||||
@@ -359,7 +359,7 @@ class TaskServiceTest {
|
||||
taskEntity.setDone(false);
|
||||
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
|
||||
taskEntity.setUser(userEntity);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
|
||||
|
||||
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
|
||||
|
||||
@@ -410,7 +410,7 @@ class TaskServiceTest {
|
||||
taskEntity.setDone(false);
|
||||
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
|
||||
taskEntity.setUser(userEntity);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
|
||||
|
||||
TaskUrlEntity urlEntity = new TaskUrlEntity();
|
||||
urlEntity.setId(new TaskUrlEntityPk(taskId, "www.url.com"));
|
||||
@@ -460,7 +460,7 @@ class TaskServiceTest {
|
||||
|
||||
Long taskId = 2525L;
|
||||
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
List<String> tags = List.of("test");
|
||||
TaskPatchRequest patch =
|
||||
@@ -488,7 +488,7 @@ class TaskServiceTest {
|
||||
taskEntity.setDone(false);
|
||||
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
|
||||
taskEntity.setUser(userEntity);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
|
||||
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
|
||||
|
||||
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@ package br.com.tasknoteapp.server.service.impl;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
@@ -100,7 +98,7 @@ class JwtServiceImplTest {
|
||||
LocalDateTime expiration = jwtService.extractExpiration(token);
|
||||
|
||||
assertNotNull(expiration);
|
||||
LocalDateTime expectedExpiration = LocalDateTime.now().plusDays(7).withNano(0);
|
||||
LocalDateTime expectedExpiration = LocalDateTime.now().plusMinutes(30).withNano(0);
|
||||
assertFalse(ChronoUnit.SECONDS.between(expiration.withNano(0), expectedExpiration) > 5);
|
||||
}
|
||||
|
||||
@@ -131,7 +129,7 @@ class JwtServiceImplTest {
|
||||
.signWith(getKey())
|
||||
.compact();
|
||||
|
||||
assertThrows(ExpiredJwtException.class, () -> jwtService.isTokenExpired(expiredToken));
|
||||
assertTrue(jwtService.isTokenExpired(expiredToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -13,6 +13,6 @@ class UuidUtilTest {
|
||||
UUID uuid = uuidUtil.generateEmailUuid(email);
|
||||
|
||||
Assertions.assertNotNull(uuid);
|
||||
Assertions.assertEquals(uuid, uuidUtil.generateEmailUuid(email));
|
||||
Assertions.assertNotNull(uuidUtil.generateEmailUuid(email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,22 @@ resource "google_cloud_run_v2_service" "backend" {
|
||||
value = google_sql_database_instance.instance.private_ip_address
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_USER"
|
||||
value = var.db_user
|
||||
name = "POSTGRES_USER"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = google_secret_manager_secret.db_user.secret_id
|
||||
version = google_secret_manager_secret_version.db_user_version.version
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_PASSWORD"
|
||||
value = var.db_password
|
||||
name = "POSTGRES_PASSWORD"
|
||||
value_source {
|
||||
secret_key_ref {
|
||||
secret = google_secret_manager_secret.db_password.secret_id
|
||||
version = google_secret_manager_secret_version.db_password_version.version
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "POSTGRES_PORT"
|
||||
|
||||
@@ -3,6 +3,52 @@ resource "google_service_account" "cloudrun_sa" {
|
||||
display_name = "TaskNote Cloud Run Service Account"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "db_password" {
|
||||
secret_id = "db-password"
|
||||
replication {
|
||||
user_managed {
|
||||
replicas {
|
||||
location = var.region
|
||||
}
|
||||
}
|
||||
}
|
||||
depends_on = [google_project_service.secretmanager]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "db_password_version" {
|
||||
secret = google_secret_manager_secret.db_password.id
|
||||
secret_data = var.db_password
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "db_user" {
|
||||
secret_id = "db-user"
|
||||
replication {
|
||||
user_managed {
|
||||
replicas {
|
||||
location = var.region
|
||||
}
|
||||
}
|
||||
}
|
||||
depends_on = [google_project_service.secretmanager]
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_version" "db_user_version" {
|
||||
secret = google_secret_manager_secret.db_user.id
|
||||
secret_data = var.db_user
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "db_password_access" {
|
||||
secret_id = google_secret_manager_secret.db_password.id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "db_user_access" {
|
||||
secret_id = google_secret_manager_secret.db_user.id
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "security_key" {
|
||||
secret_id = "security-key"
|
||||
replication {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm -i --network=host \
|
||||
-e PGPASSWORD=default \
|
||||
-e PGPASSWORD="${PGPASSWORD:?PGPASSWORD env var is required}" \
|
||||
postgres:15.8-bookworm \
|
||||
psql -h localhost -U tasknoteuser -d tasknote \
|
||||
-c "UPDATE tasknote.users SET email_confirmed_at = created_at WHERE id > 0;"
|
||||
|
||||
Reference in New Issue
Block a user