14 Commits

Author SHA1 Message Date
aovantsev
73ed514a34 refactor
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-10 19:56:06 +03:00
aovantsev
f081c9d947 fix drone ci
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-10 19:39:28 +03:00
aovantsev
ef352736f0 test
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-10 19:26:15 +03:00
aovantsev
b2fe26fe96 fix logs
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-10 19:16:46 +03:00
aovantsev
6a2d827d1e fix
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-09 07:53:40 +03:00
aovantsev
a6eb3a2088 fix logger
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-09 07:37:41 +03:00
aovantsev
97771b1023 fix prefix
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-09 07:31:00 +03:00
aovantsev
4f9182c2db revert
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-08 21:15:50 +03:00
aovantsev
2299803050 try fix
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-08 21:06:58 +03:00
aovantsev
4ae5bf6a81 add logging
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-08 21:00:23 +03:00
aovantsev
e2cf730474 fix drone yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-07 21:34:09 +03:00
aovantsev
9bb4c382b6 fix go mod
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-05 10:02:22 +03:00
aovantsev
85221dfb1f add monitoring
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-05 09:58:13 +03:00
ebf4bdeede basic frontend (#3)
All checks were successful
continuous-integration/drone/push Build is passing
Co-authored-by: aovantsev <aovantsev@avito.ru>
Reviewed-on: #3
2025-10-03 16:25:14 +00:00
70 changed files with 22327 additions and 53 deletions

View File

@@ -19,8 +19,8 @@ steps:
- docker stop counter-app || true - docker stop counter-app || true
- docker rm counter-app || true - docker rm counter-app || true
# Run the new container # Run the new container with environment variables
- docker run -d --name counter-app -p 8080:8080 localhost:5000/counter:latest - docker run -d --name counter-app -e ENVIRONMENT=production -e JWT_SECRET=your-super-secure-production-secret -e DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable -e GIN_MODE=release -e LOG_LEVEL=warn -p 8080:8080 -v app_logs:/app/logs --network infra_backend_nw localhost:5000/counter:latest
volumes: volumes:
- name: dockersock - name: dockersock
@@ -31,3 +31,10 @@ trigger:
branch: branch:
- main - main
- feature/* - feature/*
event:
- push
exclude:
event:
- pull_request
- pull_request_closed
- pull_request_synchronize

7
.env Normal file
View File

@@ -0,0 +1,7 @@
# Base Configuration
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
PORT=8080
LOG_LEVEL=info
GIN_MODE=debug
METRICS_PORT=9090
JWT_SECRET=your-super-secure-development-secret-key-here

8
.env.production Normal file
View File

@@ -0,0 +1,8 @@
# Production Environment
ENVIRONMENT=production
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
JWT_SECRET=super-secure-production-secret-change-this
PORT=8080
GIN_MODE=release
LOG_LEVEL=warn
METRICS_PORT=9090

78
.gitignore vendored
View File

@@ -17,13 +17,31 @@
# Go workspace file # Go workspace file
go.work go.work
# IDE files # Environment variables (keeping all env files in git as requested)
# .env
# .env.local
# .env.development.local
# .env.test.local
# .env.production.local
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# React build
frontend/build/
frontend/dist/
# IDE
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~
# OS generated files # OS
.DS_Store .DS_Store
.DS_Store? .DS_Store?
._* ._*
@@ -31,3 +49,59 @@ go.work
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage for Go backend
FROM golang:1.21-alpine AS builder FROM golang:1.21-alpine AS go-builder
WORKDIR /app WORKDIR /app
@@ -9,11 +9,31 @@ COPY go.mod go.sum ./
# Download dependencies # Download dependencies
RUN go mod download RUN go mod download
# Copy source code # Copy source code and environment files
COPY . . COPY *.go ./
COPY internal/ ./internal/
COPY cmd/ ./cmd/
COPY .env* ./
# Build the application # Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api
# Build stage for React frontend
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy package files
COPY frontend/package*.json ./
# Install dependencies (including dev dependencies for build)
RUN npm ci
# Copy source code
COPY frontend/ ./
# Build the React app
RUN npm run build
# Final stage # Final stage
FROM alpine:latest FROM alpine:latest
@@ -23,8 +43,17 @@ RUN apk --no-cache add ca-certificates
WORKDIR /root/ WORKDIR /root/
# Copy the binary from builder stage # Copy the Go binary from go-builder stage
COPY --from=builder /app/main . COPY --from=go-builder /app/main .
# Copy environment files from go-builder stage
COPY --from=go-builder /app/.env* ./
# Copy the React build from frontend-builder stage
COPY --from=frontend-builder /app/frontend/build ./frontend/build
# Create log directory with proper permissions
RUN mkdir -p /app/logs && chmod 755 /app/logs
# Expose port # Expose port
EXPOSE 8080 EXPOSE 8080

230
README.md Normal file
View File

@@ -0,0 +1,230 @@
# Counter - Habit Tracking Application
A full-stack web application for tracking habits and activities with counters. Users can create counters, increment/decrement them, and view statistics. The app supports both authenticated users (with database persistence) and anonymous users (with local storage).
## Features
- **User Authentication**: Register and login with JWT tokens
- **Anonymous Usage**: Work without registration using browser local storage
- **Counter Management**: Create, edit, delete, and manage counters
- **Increment/Decrement**: Track positive and negative changes
- **Date-based Tracking**: Counters store entries grouped by dates
- **Statistics**: View today, week, month, and total values
- **Search & Filter**: Find counters by name or description
- **Responsive Design**: Modern UI that works on all devices
## Tech Stack
- **Backend**: Go with Gin framework
- **Frontend**: React with TypeScript
- **Database**: PostgreSQL
- **Authentication**: JWT tokens
- **Deployment**: Docker with multi-stage builds
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Go 1.21+ (for local development)
- Node.js 18+ (for local development)
### Using Docker Compose (Recommended)
1. Clone the repository:
```bash
git clone <repository-url>
cd counter
```
2. Start the application:
```bash
docker-compose up --build
```
3. Open your browser and navigate to `http://localhost:8080`
The application will automatically:
- Start PostgreSQL database
- Build and run the Go backend
- Build and serve the React frontend
- Create necessary database tables
### Local Development
#### Backend Setup
1. Install dependencies:
```bash
go mod download
```
2. Start PostgreSQL (using Docker):
```bash
docker run --name counter-postgres -e POSTGRES_DB=counter_db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15-alpine
```
3. Set environment variables:
```bash
export DATABASE_URL="postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
export JWT_SECRET="your-secret-key"
```
4. Run the backend:
```bash
go run .
```
#### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
4. Open `http://localhost:3000` in your browser
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Login user
- `GET /api/v1/auth/me` - Get current user (protected)
### Counters (Protected)
- `GET /api/v1/counters` - Get all counters
- `POST /api/v1/counters` - Create a new counter
- `GET /api/v1/counters/:id` - Get specific counter
- `PUT /api/v1/counters/:id` - Update counter
- `DELETE /api/v1/counters/:id` - Delete counter
- `POST /api/v1/counters/:id/increment` - Increment/decrement counter
- `GET /api/v1/counters/:id/entries` - Get counter entries
- `GET /api/v1/counters/:id/stats` - Get counter statistics
## Database Schema
### Users Table
- `id` (SERIAL PRIMARY KEY)
- `username` (VARCHAR(50) UNIQUE)
- `email` (VARCHAR(255) UNIQUE)
- `password` (VARCHAR(255))
- `created_at` (TIMESTAMP)
- `updated_at` (TIMESTAMP)
### Counters Table
- `id` (SERIAL PRIMARY KEY)
- `user_id` (INTEGER REFERENCES users(id))
- `name` (VARCHAR(100))
- `description` (TEXT)
- `created_at` (TIMESTAMP)
- `updated_at` (TIMESTAMP)
### Counter Entries Table
- `id` (SERIAL PRIMARY KEY)
- `counter_id` (INTEGER REFERENCES counters(id))
- `value` (INTEGER)
- `date` (DATE)
- `created_at` (TIMESTAMP)
## Anonymous User Support
Anonymous users can use the application without registration:
- Counters are stored in browser's localStorage
- Data persists across browser sessions
- No server-side storage for anonymous users
- Seamless transition to authenticated user (data remains in localStorage)
## Environment Variables
Create a `.env` file based on `env.example`:
```bash
# Database Configuration
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# Server Configuration
PORT=8080
GIN_MODE=release
```
## Deployment
### Production Deployment
1. Set environment variables for production
2. Use a production PostgreSQL database
3. Set a strong JWT secret
4. Build and deploy using Docker:
```bash
docker build -t counter-app .
docker run -p 8080:8080 \
-e DATABASE_URL="your-production-db-url" \
-e JWT_SECRET="your-production-secret" \
counter-app
```
### Docker Compose Production
Update `docker-compose.yml` with production settings:
- Use external PostgreSQL database
- Set production environment variables
- Configure proper networking and volumes
## Development
### Project Structure
```
counter/
├── main.go # Main application entry point
├── models.go # Data models and DTOs
├── database.go # Database connection and setup
├── auth.go # Authentication handlers and middleware
├── counters.go # Counter management handlers
├── docker-compose.yml # Local development setup
├── Dockerfile # Multi-stage build for production
├── go.mod # Go dependencies
├── go.sum # Go dependencies checksum
└── frontend/ # React frontend
├── src/
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── services/ # API and localStorage services
│ ├── types/ # TypeScript type definitions
│ └── App.tsx # Main React component
├── package.json # Node.js dependencies
└── public/ # Static assets
```
### Adding New Features
1. **Backend**: Add new handlers in appropriate files
2. **Frontend**: Create components in `src/components/`
3. **API Integration**: Update services in `src/services/`
4. **Types**: Add TypeScript interfaces in `src/types/`
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
This project is licensed under the MIT License.

10
_.env.development Normal file
View File

@@ -0,0 +1,10 @@
# Development Environment
ENVIRONMENT=development
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
JWT_SECRET=dev-secret-key-change-in-production
PORT=8080
METRICS_PORT=9090
GIN_MODE=debug
LOG_LEVEL=debug
LOG_DIR=/app/logs
LOG_VOLUME=counter_logs

7
_.env.staging Normal file
View File

@@ -0,0 +1,7 @@
# Staging Environment
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:password@postgres:5432/counter_db?sslmode=disable
JWT_SECRET=staging-secret-key-change-this
PORT=8080
GIN_MODE=release
LOG_LEVEL=info

BIN
api Executable file

Binary file not shown.

76
cmd/api/main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"counter/internal/delivery/http"
"counter/internal/delivery/http/handlers"
"counter/internal/infrastructure/config"
"counter/internal/infrastructure/database/postgres"
"counter/internal/infrastructure/logging"
"counter/internal/infrastructure/metrics"
"counter/internal/infrastructure/security"
"counter/internal/usecase/auth"
"counter/internal/usecase/counter"
"github.com/sirupsen/logrus"
)
func main() {
// 1. Load configuration
cfg := config.LoadConfig()
// 2. Initialize logger
logger, err := logging.InitLogger(cfg)
if err != nil {
logrus.Fatal("Failed to initialize logger:", err)
}
// 3. Initialize database
dbConn, err := postgres.NewConnection(cfg, logger)
if err != nil {
logger.WithError(err).Fatal("Failed to initialize database")
}
defer dbConn.Close()
// 4. Initialize repositories (infrastructure implementations)
userRepo := postgres.NewUserRepository(dbConn.GetDB())
counterRepo := postgres.NewCounterRepository(dbConn.GetDB())
// 5. Initialize services (infrastructure)
passwordService := security.NewPasswordService()
jwtService := security.NewJWTService(cfg.JWTSecret)
metricsService := metrics.NewPrometheusMetricsService()
// 6. Initialize use cases (business logic)
authService := auth.NewAuthService(userRepo, passwordService, jwtService)
counterService := counter.NewCounterService(counterRepo)
// 7. Initialize handlers (delivery)
authHandler := handlers.NewAuthHandler(authService)
counterHandler := handlers.NewCounterHandler(counterService)
// 8. Initialize router
router := http.NewRouter(authHandler, counterHandler, cfg, logger, metricsService, jwtService)
router.SetupRoutes()
// 9. Start metrics server
metricsService.StartMetricsServer(cfg.MetricsPort)
// 10. Start HTTP server
port := cfg.Port
logger.WithFields(logrus.Fields{
"port": port,
"metrics_port": cfg.MetricsPort,
"log_dir": cfg.LogDir,
"log_volume": cfg.LogVolume,
}).Info("🚀 Starting Counter Application Server")
logger.WithFields(logrus.Fields{
"health_url": "http://localhost:" + port + "/health",
"api_url": "http://localhost:" + port + "/api/v1",
"frontend_url": "http://localhost:" + port + "/",
"metrics_url": "http://localhost:" + cfg.MetricsPort + "/metrics",
}).Info("✅ Server is ready and accepting connections")
logger.Fatal(router.GetRouter().Run(":" + port))
}

6
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
app:
environment:
- ENVIRONMENT=production
env_file:
- .env.production

View File

@@ -0,0 +1,6 @@
services:
app:
environment:
- ENVIRONMENT=staging
env_file:
- .env.staging

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8080:8080"
environment:
- ENVIRONMENT=development
- LOG_VOLUME=${LOG_VOLUME:-counter_logs}
env_file:
- .env.development
depends_on:
postgres:
condition: service_healthy
volumes:
- ./frontend/build:/app/frontend/build
- ${LOG_VOLUME:-counter_logs}:/app/logs

18
env.example Normal file
View File

@@ -0,0 +1,18 @@
# Database Configuration
DATABASE_URL=postgres://postgres:password@localhost:5432/counter_db?sslmode=disable
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# Server Configuration
PORT=8080
METRICS_PORT=9090
GIN_MODE=release
# Logging Configuration
LOG_LEVEL=info
LOG_DIR=/app/logs
LOG_VOLUME=counter_logs
# Frontend Configuration (for development)
REACT_APP_API_URL=http://localhost:8080/api/v1

4
frontend/.env Normal file
View File

@@ -0,0 +1,4 @@
# Base Frontend Configuration
REACT_APP_API_URL=http://localhost:8080/api/v1
REACT_APP_ENVIRONMENT=development
REACT_APP_DEBUG=true

5
frontend/.env.production Normal file
View File

@@ -0,0 +1,5 @@
# Production Frontend Configuration
REACT_APP_API_URL=/api/v1
REACT_APP_ENVIRONMENT=production
REACT_APP_DEBUG=false
REACT_APP_LOG_LEVEL=warn

View File

@@ -0,0 +1,5 @@
# Development Frontend Configuration
REACT_APP_API_URL=http://localhost:8080/api/v1
REACT_APP_ENVIRONMENT=development
REACT_APP_DEBUG=true
REACT_APP_LOG_LEVEL=debug

5
frontend/_.env.staging Normal file
View File

@@ -0,0 +1,5 @@
# Staging Frontend Configuration
REACT_APP_API_URL=https://staging-api.yourdomain.com/api/v1
REACT_APP_ENVIRONMENT=staging
REACT_APP_DEBUG=false
REACT_APP_LOG_LEVEL=info

17624
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
frontend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "counter-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/node": "^16.18.0",
"autoprefixer": "^10.4.0",
"axios": "^1.6.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"postcss": "^8.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.0",
"react-query": "^3.39.0",
"react-router-dom": "^6.8.0",
"react-scripts": "5.0.1",
"tailwindcss": "^3.3.0",
"typescript": "^4.9.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/jest": "^27.5.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0"
},
"proxy": "http://localhost:8080"
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Counter - Track your habits and activities"
/>
<title>Counter</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

43
frontend/src/App.css Normal file
View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-white rounded-lg shadow-md border border-gray-200;
}
.counter-card {
@apply card p-6 hover:shadow-lg transition-shadow duration-200;
}
}

44
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './hooks/useAuth';
import { CountersProvider } from './contexts/CountersContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Layout } from './components/Layout';
import { Login } from './components/Login';
import { Register } from './components/Register';
import { Dashboard } from './components/Dashboard';
import { CounterDetail } from './components/CounterDetail';
import './App.css';
function App() {
return (
<AuthProvider>
<CountersProvider>
<Router>
<div className="App">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/counter/:id" element={<CounterDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
</CountersProvider>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { CounterWithStats } from '../types';
import { Plus, Minus, Edit, Trash2, MoreVertical } from 'lucide-react';
interface CounterCardProps {
counter: CounterWithStats;
onIncrement: (id: number | string, value: number) => Promise<void>;
onDelete: (id: number | string) => Promise<void>;
}
export const CounterCard: React.FC<CounterCardProps> = ({ counter, onIncrement, onDelete }) => {
const [isLoading, setIsLoading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const handleIncrement = async (value: number) => {
setIsLoading(true);
try {
await onIncrement(counter.id, value);
console.log('Counter incremented:', counter.id, value);
} catch (error) {
console.error('Failed to increment counter:', error);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this counter?')) {
try {
await onDelete(counter.id);
} catch (error) {
console.error('Failed to delete counter:', error);
}
}
};
return (
<div className="counter-card">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1">
{counter.name}
</h3>
{counter.description && (
<p className="text-sm text-gray-600 mb-2">{counter.description}</p>
)}
</div>
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<MoreVertical className="h-5 w-5" />
</button>
{showMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-10 border border-gray-200">
<Link
to={`/counter/${counter.id}`}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowMenu(false)}
>
<Edit className="h-4 w-4 mr-2" />
View Details
</Link>
<button
onClick={() => {
handleDelete();
setShowMenu(false);
}}
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</button>
</div>
)}
</div>
</div>
{/* Counter Value */}
<div className="text-center mb-6">
<div className="text-4xl font-bold text-primary-600 mb-2">
{counter.total_value}
</div>
<div className="text-sm text-gray-500">Total</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-4 mb-6 text-center">
<div>
<div className="text-lg font-semibold text-gray-900">{counter.today_value}</div>
<div className="text-xs text-gray-500">Today</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{counter.week_value}</div>
<div className="text-xs text-gray-500">Week</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{counter.month_value}</div>
<div className="text-xs text-gray-500">Month</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={() => handleIncrement(-1)}
disabled={isLoading}
className="btn btn-secondary flex-1 flex items-center justify-center disabled:opacity-50"
>
<Minus className="h-4 w-4" />
</button>
<button
onClick={() => handleIncrement(1)}
disabled={isLoading}
className="btn btn-primary flex-1 flex items-center justify-center disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Entry Count */}
<div className="mt-4 text-center">
<div className="text-xs text-gray-500">
{counter.entry_count} entries
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useCountersContext } from '../contexts/CountersContext';
import { useAuth } from '../hooks/useAuth';
import { CounterEntry } from '../types';
import { ArrowLeft, Plus, Minus, Trash2, Calendar } from 'lucide-react';
import { format } from 'date-fns';
export const CounterDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const { counters, incrementCounter, deleteCounter, getCounterEntries } = useCountersContext();
// Get the current counter from the useCounters hook
const counter = counters.find(c => c.id.toString() === id) || null;
const [entries, setEntries] = useState<CounterEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isIncrementing, setIsIncrementing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load entries when component mounts or counter changes
useEffect(() => {
const loadEntries = async () => {
if (!id || !counter) return;
setIsLoading(true);
setError(null);
try {
const counterEntries = await getCounterEntries(id);
setEntries(counterEntries);
} catch (err: any) {
setError(err.message || 'Failed to load counter entries');
} finally {
setIsLoading(false);
}
};
loadEntries();
}, [id, counter, getCounterEntries]);
const handleIncrement = async (value: number) => {
if (!counter) return;
setIsIncrementing(true);
try {
await incrementCounter(counter.id, value);
console.log('Counter incremented:', counter.id, value);
// Entries will be reloaded automatically when counter changes
} catch (error) {
console.error('Failed to increment counter:', error);
} finally {
setIsIncrementing(false);
}
};
const handleDelete = async () => {
if (!counter) return;
if (window.confirm('Are you sure you want to delete this counter?')) {
try {
await deleteCounter(counter.id);
navigate('/');
} catch (error) {
console.error('Failed to delete counter:', error);
}
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
if (error || !counter) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">{error || 'Counter not found'}</div>
<button
onClick={() => navigate('/')}
className="btn btn-primary"
>
Back to Dashboard
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/')}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{counter.name}</h1>
{counter.description && (
<p className="text-gray-600 mt-1">{counter.description}</p>
)}
</div>
</div>
<button
onClick={handleDelete}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">Total Value</p>
<p className="text-2xl font-bold text-gray-900">{counter.total_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">Today</p>
<p className="text-2xl font-bold text-gray-900">{counter.today_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">This Week</p>
<p className="text-2xl font-bold text-gray-900">{counter.week_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">This Month</p>
<p className="text-2xl font-bold text-gray-900">{counter.month_value}</p>
</div>
</div>
</div>
{/* Increment Controls */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div className="flex space-x-4">
<button
onClick={() => handleIncrement(-1)}
disabled={isIncrementing}
className="btn btn-secondary flex items-center space-x-2 disabled:opacity-50"
>
<Minus className="h-5 w-5" />
<span>-1</span>
</button>
<button
onClick={() => handleIncrement(1)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+1</span>
</button>
<button
onClick={() => handleIncrement(5)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+5</span>
</button>
<button
onClick={() => handleIncrement(10)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+10</span>
</button>
</div>
</div>
{/* Recent Entries */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<Calendar className="h-5 w-5 mr-2" />
Recent Entries
</h2>
{(entries?.length || 0) === 0 ? (
<div className="text-center py-8 text-gray-500">
No entries yet. Start by incrementing your counter!
</div>
) : (
<div className="space-y-3">
{entries?.slice(0, 10).map((entry) => (
<div
key={entry.id}
className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg"
>
<div className="flex items-center space-x-3">
<div className="text-sm text-gray-600">
{format(new Date(entry.date), 'MMM dd, yyyy')}
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`font-medium ${entry.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{entry.value > 0 ? '+' : ''}{entry.value}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useCountersContext } from '../contexts/CountersContext';
import { CreateCounterRequest } from '../types';
import { X } from 'lucide-react';
interface CreateCounterModalProps {
onClose: () => void;
}
export const CreateCounterModal: React.FC<CreateCounterModalProps> = ({ onClose }) => {
const { createCounter } = useCountersContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateCounterRequest>();
const onSubmit = async (data: CreateCounterRequest) => {
setIsLoading(true);
setError(null);
try {
await createCounter(data);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to create counter');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Create New Counter</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
</label>
<input
{...register('name', {
required: 'Name is required',
maxLength: { value: 100, message: 'Name must be less than 100 characters' }
})}
type="text"
className="input mt-1"
placeholder="e.g., Cigarettes Smoked"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
{...register('description', {
maxLength: { value: 500, message: 'Description must be less than 500 characters' }
})}
rows={3}
className="input mt-1"
placeholder="Optional description..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Counter'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { useCountersContext } from '../contexts/CountersContext';
import { useAuth } from '../hooks/useAuth';
import { CreateCounterModal } from './CreateCounterModal';
import { CounterCard } from './CounterCard';
import { Plus, Search, TrendingUp, Calendar, Clock } from 'lucide-react';
export const Dashboard: React.FC = () => {
console.log('Dashboard: Component rendering...');
const { counters, isLoading, error, searchCounters, version, incrementCounter, deleteCounter } = useCountersContext();
const { isAuthenticated } = useAuth();
const [showCreateModal, setShowCreateModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = (query: string) => {
setSearchQuery(query);
searchCounters(query);
};
useEffect(() => {
console.log('Dashboard: Counters changed:', counters, 'version:', version);
}, [counters, version]);
console.log('Dashboard: Current counters:', counters, 'version:', version);
const totalCounters = counters?.length || 0;
const totalValue = counters?.reduce((sum, counter) => sum + counter.total_value, 0) || 0;
const todayValue = counters?.reduce((sum, counter) => sum + counter.today_value, 0) || 0;
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">
{isAuthenticated
? 'Maaaanage your counters and track your progress'
: 'Track your habits and activities (data stored locally)'
}
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary flex items-center space-x-2"
>
<Plus className="h-5 w-5" />
<span>New Counter</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-primary-100 rounded-lg">
<TrendingUp className="h-6 w-6 text-primary-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Counters</p>
<p className="text-2xl font-bold text-gray-900">{totalCounters}</p>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<Calendar className="h-6 w-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Value</p>
<p className="text-2xl font-bold text-gray-900">{totalValue}</p>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<Clock className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Today's Value</p>
<p className="text-2xl font-bold text-gray-900">{todayValue}</p>
</div>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search counters..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="input pl-10"
/>
</div>
{/* Counters Grid */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
) : (counters?.length || 0) === 0 ? (
<div className="text-center py-12">
<TrendingUp className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No counters</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating your first counter.
</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary"
>
<Plus className="h-5 w-5 mr-2" />
New Counter
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{counters?.map((counter) => (
<CounterCard
key={counter.id}
counter={counter}
onIncrement={incrementCounter}
onDelete={deleteCounter}
/>
))}
</div>
)}
{/* Create Counter Modal */}
{showCreateModal && (
<CreateCounterModal
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { LogOut, User, Plus, Search } from 'lucide-react';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/" className="flex items-center">
<div className="text-2xl font-bold text-primary-600">Counter</div>
</Link>
{/* Navigation */}
<nav className="flex items-center space-x-4">
<Link
to="/"
className="text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
>
Dashboard
</Link>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
>
<User className="h-5 w-5" />
<span>{isAuthenticated ? user?.username : 'Anonymous'}</span>
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
{isAuthenticated ? (
<>
<div className="px-4 py-2 text-sm text-gray-700 border-b border-gray-200">
{user?.email}
</div>
<button
onClick={handleLogout}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Login
</Link>
<Link
to="/register"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Register
</Link>
</>
)}
</div>
)}
</div>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{children}
</main>
</div>
);
};

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useAuth } from '../hooks/useAuth';
import { LoginRequest } from '../types';
import { Eye, EyeOff } from 'lucide-react';
export const Login: React.FC = () => {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginRequest>();
// Redirect if already authenticated
React.useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const onSubmit = async (data: LoginRequest) => {
setIsLoading(true);
setError(null);
try {
await login(data);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
{...register('username', { required: 'Username is required' })}
type="text"
className="input mt-1"
placeholder="Enter your username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password', { required: 'Password is required' })}
type={showPassword ? 'text' : 'password'}
className="input pr-10"
placeholder="Enter your password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="text-center">
<Link
to="/"
className="text-sm text-primary-600 hover:text-primary-500"
>
Continue as anonymous user
</Link>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
// Allow access to authenticated users or anonymous users
// The app works for both authenticated and anonymous users
return <>{children}</>;
};

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useAuth } from '../hooks/useAuth';
import { RegisterRequest } from '../types';
import { Eye, EyeOff } from 'lucide-react';
export const Register: React.FC = () => {
const { register: registerUser, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterRequest & { confirmPassword: string }>();
const password = watch('password');
// Redirect if already authenticated
React.useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const onSubmit = async (data: RegisterRequest & { confirmPassword: string }) => {
setIsLoading(true);
setError(null);
try {
await registerUser({
username: data.username,
email: data.email,
password: data.password,
});
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Username must be at least 3 characters' },
maxLength: { value: 50, message: 'Username must be less than 50 characters' }
})}
type="text"
className="input mt-1"
placeholder="Choose a username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
})}
type="email"
className="input mt-1"
placeholder="Enter your email"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password', {
required: 'Password is required',
minLength: { value: 6, message: 'Password must be at least 6 characters' }
})}
type={showPassword ? 'text' : 'password'}
className="input pr-10"
placeholder="Create a password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: value => value === password || 'Passwords do not match'
})}
type="password"
className="input mt-1"
placeholder="Confirm your password"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div>
<div className="text-center">
<Link
to="/"
className="text-sm text-primary-600 hover:text-primary-500"
>
Continue as anonymous user
</Link>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
// Environment configuration for React frontend
export type Environment = 'development' | 'staging' | 'production';
export interface AppConfig {
environment: Environment;
apiUrl: string;
debug: boolean;
logLevel: string;
}
// Get environment from process.env
export const getEnvironment = (): Environment => {
const env = process.env.REACT_APP_ENVIRONMENT as Environment;
// Fallback to NODE_ENV if REACT_APP_ENVIRONMENT is not set
if (!env) {
const nodeEnv = process.env.NODE_ENV;
switch (nodeEnv) {
case 'production':
return 'production';
case 'development':
return 'development';
default:
return 'development';
}
}
return env;
};
// Get API URL based on environment
export const getApiUrl = (): string => {
const apiUrl = process.env.REACT_APP_API_URL;
if (apiUrl) {
return apiUrl;
}
// Fallback based on environment
const env = getEnvironment();
switch (env) {
case 'production':
return '/api/v1'; // Relative URL for production
case 'staging':
return 'https://staging-api.yourdomain.com/api/v1';
case 'development':
default:
return 'http://localhost:8080/api/v1';
}
};
// Get debug flag
export const isDebugMode = (): boolean => {
const debug = process.env.REACT_APP_DEBUG;
if (debug !== undefined) {
return debug === 'true';
}
// Fallback based on environment
return getEnvironment() === 'development';
};
// Get log level
export const getLogLevel = (): string => {
return process.env.REACT_APP_LOG_LEVEL || 'info';
};
// Get complete app configuration
export const getAppConfig = (): AppConfig => {
return {
environment: getEnvironment(),
apiUrl: getApiUrl(),
debug: isDebugMode(),
logLevel: getLogLevel(),
};
};
// Environment checks
export const isDevelopment = (): boolean => {
return getEnvironment() === 'development';
};
export const isStaging = (): boolean => {
return getEnvironment() === 'staging';
};
export const isProduction = (): boolean => {
return getEnvironment() === 'production';
};
// Log configuration (only in development)
export const logConfig = (): void => {
if (isDevelopment()) {
const config = getAppConfig();
console.log('🚀 Frontend Configuration:', {
environment: config.environment,
apiUrl: config.apiUrl,
debug: config.debug,
logLevel: config.logLevel,
});
}
};

View File

@@ -0,0 +1,41 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { CounterWithStats, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
import { useCounters } from '../hooks/useCounters';
interface CountersContextType {
counters: CounterWithStats[];
isLoading: boolean;
error: string | null;
version: number;
createCounter: (data: CreateCounterRequest) => Promise<any>;
updateCounter: (id: number | string, data: UpdateCounterRequest) => Promise<any>;
deleteCounter: (id: number | string) => Promise<void>;
incrementCounter: (id: number | string, value: number) => Promise<any>;
getCounterEntries: (id: number | string) => Promise<CounterEntry[]>;
searchCounters: (search: string) => void;
refreshCounters: () => Promise<void>;
}
const CountersContext = createContext<CountersContextType | undefined>(undefined);
interface CountersProviderProps {
children: ReactNode;
}
export const CountersProvider: React.FC<CountersProviderProps> = ({ children }) => {
const countersData = useCounters();
return (
<CountersContext.Provider value={countersData}>
{children}
</CountersContext.Provider>
);
};
export const useCountersContext = (): CountersContextType => {
const context = useContext(CountersContext);
if (context === undefined) {
throw new Error('useCountersContext must be used within a CountersProvider');
}
return context;
};

View File

@@ -0,0 +1,111 @@
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { User, AuthResponse, RegisterRequest, LoginRequest } from '../types';
import { authAPI } from '../services/api';
import { localStorageService } from '../services/localStorage';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (data: LoginRequest) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isAuthenticated = !!user;
useEffect(() => {
const initAuth = async () => {
const token = localStorageService.getAuthToken();
if (token) {
try {
const response = await authAPI.getCurrentUser();
setUser(response.data);
} catch (error) {
console.error('Failed to get current user:', error);
localStorageService.clearAuth();
}
}
setIsLoading(false);
};
initAuth();
}, []);
const login = async (data: LoginRequest) => {
try {
const response = await authAPI.login(data);
const { token, user: userData } = response.data;
localStorageService.setAuthToken(token);
localStorageService.setUser(userData);
setUser(userData);
} catch (error) {
throw error;
}
};
const register = async (data: RegisterRequest) => {
try {
const response = await authAPI.register(data);
const { token, user: userData } = response.data;
localStorageService.setAuthToken(token);
localStorageService.setUser(userData);
setUser(userData);
} catch (error) {
throw error;
}
};
const logout = () => {
localStorageService.clearAuth();
setUser(null);
};
const refreshUser = async () => {
try {
const response = await authAPI.getCurrentUser();
setUser(response.data);
localStorageService.setUser(response.data);
} catch (error) {
console.error('Failed to refresh user:', error);
logout();
}
};
const value: AuthContextType = {
user,
isAuthenticated,
isLoading,
login,
register,
logout,
refreshUser,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { CounterWithStats, AnonymousCounter, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
import { countersAPI } from '../services/api';
import { localStorageService } from '../services/localStorage';
import { useAuth } from './useAuth';
export const useCounters = () => {
const { isAuthenticated } = useAuth();
const [counters, setCounters] = useState<CounterWithStats[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0); // Force re-renders
// Load counters based on authentication status
useEffect(() => {
loadCounters();
}, [isAuthenticated]);
useEffect(() => {
console.log('Counters:', counters);
}, [counters]);
const loadCounters = async (search?: string) => {
setIsLoading(true);
setError(null);
try {
if (isAuthenticated) {
// Load from API
const response = await countersAPI.getCounters(search);
console.log('Setting authenticated counters:', response.data);
setCounters([...response.data]); // Ensure new array reference
setVersion(prev => prev + 1); // Force re-render
} else {
// Load from localStorage and convert to CounterWithStats format
const anonymousCounters = localStorageService.getAnonymousCounters();
const convertedCounters = convertAnonymousToDisplay(anonymousCounters);
console.log('Setting anonymous counters:', convertedCounters);
setCounters([...convertedCounters]); // Ensure new array reference
setVersion(prev => prev + 1); // Force re-render
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to load counters');
} finally {
setIsLoading(false);
}
};
const convertAnonymousToDisplay = (anonymousCounters: AnonymousCounter[]): CounterWithStats[] => {
console.log('Converting anonymous counters:', anonymousCounters);
const converted = anonymousCounters.map(counter => {
const today = localStorageService.getTodayString();
const todayValue = counter.entries[today] || 0;
const weekValue = Object.keys(counter.entries)
.filter((date) => {
const entryDate = new Date(date);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return entryDate >= weekAgo;
})
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
const monthValue = Object.keys(counter.entries)
.filter((date) => {
const entryDate = new Date(date);
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return entryDate >= monthAgo;
})
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
const entryCount = Object.keys(counter.entries).reduce((sum: number, date: string) => sum + Math.abs(counter.entries[date] as number), 0);
return {
id: counter.id,
name: counter.name,
description: counter.description,
created_at: counter.created_at,
updated_at: counter.updated_at,
total_value: counter.total_value,
today_value: todayValue,
week_value: weekValue,
month_value: monthValue,
entry_count: entryCount,
};
});
console.log('Converted counters:', converted);
return converted;
};
const createCounter = async (data: CreateCounterRequest) => {
if (isAuthenticated) {
try {
const response = await countersAPI.createCounter(data);
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to create counter');
}
} else {
// Create anonymous counter
const newCounter: AnonymousCounter = {
id: localStorageService.generateId(),
name: data.name,
description: data.description,
total_value: 0,
entries: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
localStorageService.addAnonymousCounter(newCounter);
await loadCounters(); // Refresh the list
return newCounter;
}
};
const updateCounter = async (id: number | string, data: UpdateCounterRequest) => {
if (isAuthenticated) {
try {
const response = await countersAPI.updateCounter(id as number, data);
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to update counter');
}
} else {
// Update anonymous counter
const updated = { ...data, updated_at: new Date().toISOString() };
localStorageService.updateAnonymousCounter(id as string, updated);
await loadCounters(); // Refresh the list
return { id, ...updated };
}
};
const deleteCounter = async (id: number | string) => {
if (isAuthenticated) {
try {
await countersAPI.deleteCounter(id as number);
await loadCounters(); // Refresh the list
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to delete counter');
}
} else {
// Delete anonymous counter
localStorageService.deleteAnonymousCounter(id as string);
await loadCounters(); // Refresh the list
}
};
const incrementCounter = async (id: number | string, value: number) => {
if (isAuthenticated) {
try {
const response = await countersAPI.incrementCounter(id as number, { value });
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to increment counter');
}
} else {
// Increment anonymous counter
const today = localStorageService.getTodayString();
const counters = localStorageService.getAnonymousCounters();
const counter = counters.find((c: AnonymousCounter) => c.id === id);
if (!counter) {
throw new Error('Counter not found');
}
const newTotal = counter.total_value + value;
const newEntries = { ...counter.entries };
newEntries[today] = (newEntries[today] || 0) + value;
const updated = {
total_value: newTotal,
entries: newEntries,
updated_at: new Date().toISOString(),
};
localStorageService.updateAnonymousCounter(id as string, updated);
console.log('Increment completed, reloading counters...');
await loadCounters(); // Refresh the list
return { id, ...updated };
}
};
const getCounterEntries = async (id: number | string): Promise<CounterEntry[]> => {
if (isAuthenticated) {
try {
const response = await countersAPI.getCounterEntries(id as number);
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to load counter entries');
}
} else {
// Get entries from localStorage
const counters = localStorageService.getAnonymousCounters();
const counter = counters.find((c: AnonymousCounter) => c.id === id);
if (!counter) {
throw new Error('Counter not found');
}
return Object.entries(counter.entries || {}).map(([date, value], index) => ({
id: index + 1,
counter_id: parseInt(counter.id as string),
value: value as number,
date: date,
created_at: counter.updated_at,
}));
}
};
const searchCounters = (search: string) => {
loadCounters(search);
};
return {
counters,
isLoading,
error,
version, // Add version to force re-renders
createCounter,
updateCounter,
deleteCounter,
incrementCounter,
getCounterEntries,
searchCounters,
refreshCounters: loadCounters,
};
};

20
frontend/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f8fafc;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}

13
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,96 @@
import axios, { AxiosResponse } from 'axios';
import {
AuthResponse,
RegisterRequest,
LoginRequest,
Counter,
CounterWithStats,
CounterEntry,
CreateCounterRequest,
UpdateCounterRequest,
IncrementRequest,
CounterStats,
User,
} from '../types';
import { getApiUrl, logConfig } from '../config/environment';
// Initialize configuration
logConfig();
const API_BASE_URL = getApiUrl();
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Auth API
export const authAPI = {
register: (data: RegisterRequest): Promise<AxiosResponse<AuthResponse>> =>
api.post('/auth/register', data),
login: (data: LoginRequest): Promise<AxiosResponse<AuthResponse>> =>
api.post('/auth/login', data),
getCurrentUser: (): Promise<AxiosResponse<User>> =>
api.get('/auth/me'),
};
// Counters API
export const countersAPI = {
getCounters: (search?: string): Promise<AxiosResponse<CounterWithStats[]>> => {
const params = search ? { search } : {};
return api.get('/counters', { params });
},
getCounter: (id: number): Promise<AxiosResponse<CounterWithStats>> =>
api.get(`/counters/${id}`),
createCounter: (data: CreateCounterRequest): Promise<AxiosResponse<Counter>> =>
api.post('/counters', data),
updateCounter: (id: number, data: UpdateCounterRequest): Promise<AxiosResponse<Counter>> =>
api.put(`/counters/${id}`, data),
deleteCounter: (id: number): Promise<AxiosResponse<{ message: string }>> =>
api.delete(`/counters/${id}`),
incrementCounter: (id: number, data: IncrementRequest): Promise<AxiosResponse<CounterEntry>> =>
api.post(`/counters/${id}/increment`, data),
getCounterEntries: (id: number, startDate?: string, endDate?: string): Promise<AxiosResponse<CounterEntry[]>> => {
const params: any = {};
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
return api.get(`/counters/${id}/entries`, { params });
},
getCounterStats: (id: number): Promise<AxiosResponse<CounterStats>> =>
api.get(`/counters/${id}/stats`),
};
export default api;

View File

@@ -0,0 +1,90 @@
import { AnonymousCounter } from '../types';
const ANONYMOUS_COUNTERS_KEY = 'anonymous_counters';
export const localStorageService = {
// Anonymous counters
getAnonymousCounters: (): AnonymousCounter[] => {
try {
const data = localStorage.getItem(ANONYMOUS_COUNTERS_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Error loading anonymous counters:', error);
return [];
}
},
saveAnonymousCounters: (counters: AnonymousCounter[]): void => {
try {
localStorage.setItem(ANONYMOUS_COUNTERS_KEY, JSON.stringify(counters));
} catch (error) {
console.error('Error saving anonymous counters:', error);
}
},
addAnonymousCounter: (counter: AnonymousCounter): void => {
const counters = localStorageService.getAnonymousCounters();
counters.push(counter);
localStorageService.saveAnonymousCounters(counters);
},
updateAnonymousCounter: (id: string, updates: Partial<AnonymousCounter>): void => {
const counters = localStorageService.getAnonymousCounters();
const index = counters.findIndex(c => c.id === id);
if (index !== -1) {
counters[index] = { ...counters[index], ...updates };
localStorageService.saveAnonymousCounters(counters);
}
},
deleteAnonymousCounter: (id: string): void => {
const counters = localStorageService.getAnonymousCounters();
const filtered = counters.filter(c => c.id !== id);
localStorageService.saveAnonymousCounters(filtered);
},
// Auth
setAuthToken: (token: string): void => {
localStorage.setItem('token', token);
},
getAuthToken: (): string | null => {
return localStorage.getItem('token');
},
removeAuthToken: (): void => {
localStorage.removeItem('token');
},
setUser: (user: any): void => {
localStorage.setItem('user', JSON.stringify(user));
},
getUser: (): any => {
try {
const data = localStorage.getItem('user');
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Error loading user:', error);
return null;
}
},
removeUser: (): void => {
localStorage.removeItem('user');
},
clearAuth: (): void => {
localStorageService.removeAuthToken();
localStorageService.removeUser();
},
// Utility
generateId: (): string => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
},
getTodayString: (): string => {
return new Date().toISOString().split('T')[0];
},
};

View File

@@ -0,0 +1,86 @@
export interface User {
id: number;
username: string;
email: string;
created_at: string;
updated_at: string;
}
export interface Counter {
id: number;
user_id?: number;
name: string;
description: string;
created_at: string;
updated_at: string;
}
export interface CounterEntry {
id: number;
counter_id: number;
value: number;
date: string;
created_at: string;
}
export interface CounterWithStats {
id: number | string; // Allow both number (authenticated) and string (anonymous) IDs
name: string;
description: string;
created_at: string;
updated_at: string;
total_value: number;
today_value: number;
week_value: number;
month_value: number;
entry_count: number;
}
export interface AnonymousCounter {
id: string;
name: string;
description: string;
total_value: number;
entries: Record<string, number>; // date -> count
created_at: string;
updated_at: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface CreateCounterRequest {
name: string;
description: string;
}
export interface UpdateCounterRequest {
name: string;
description: string;
}
export interface IncrementRequest {
value: number;
}
export interface DailyStat {
date: string;
total: number;
}
export interface CounterStats {
daily_stats: DailyStat[];
}

View File

@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [],
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

31
go.mod
View File

@@ -1,33 +1,50 @@
module counter module counter
go 1.21 go 1.21.0
require ( require (
github.com/gin-contrib/cors v1.7.0
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.21.0
github.com/sirupsen/logrus v1.9.3
golang.org/x/crypto v0.31.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.10.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.36.1 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

66
go.sum
View File

@@ -1,19 +1,29 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ -22,15 +32,31 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -38,9 +64,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -52,6 +93,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -59,19 +102,22 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,79 @@
package handlers
import (
"net/http"
"counter/internal/domain/entities"
"counter/internal/usecase/auth"
"github.com/gin-gonic/gin"
)
// AuthHandler handles authentication HTTP requests
type AuthHandler struct {
authService *auth.AuthService
}
// NewAuthHandler creates a new authentication handler
func NewAuthHandler(authService *auth.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(c *gin.Context) {
var req auth.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.authService.Register(c.Request.Context(), &req)
if err != nil {
switch err {
case entities.ErrUserAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
}
return
}
c.JSON(http.StatusCreated, response)
}
// Login handles user login
func (h *AuthHandler) Login(c *gin.Context) {
var req auth.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.authService.Login(c.Request.Context(), &req)
if err != nil {
switch err {
case entities.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"})
}
return
}
c.JSON(http.StatusOK, response)
}
// GetMe returns the current authenticated user
func (h *AuthHandler) GetMe(c *gin.Context) {
userID := c.GetInt("user_id")
user, err := h.authService.GetCurrentUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
c.JSON(http.StatusOK, user)
}

View File

@@ -0,0 +1,228 @@
package handlers
import (
"net/http"
"strconv"
"time"
"counter/internal/domain/entities"
"counter/internal/usecase/counter"
"github.com/gin-gonic/gin"
)
// CounterHandler handles counter HTTP requests
type CounterHandler struct {
counterService *counter.CounterService
}
// NewCounterHandler creates a new counter handler
func NewCounterHandler(counterService *counter.CounterService) *CounterHandler {
return &CounterHandler{
counterService: counterService,
}
}
// Create handles counter creation
func (h *CounterHandler) Create(c *gin.Context) {
userID := c.GetInt("user_id")
var req counter.CreateCounterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
counter, err := h.counterService.Create(c.Request.Context(), userID, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter"})
return
}
c.JSON(http.StatusCreated, counter)
}
// Get handles retrieving a specific counter
func (h *CounterHandler) Get(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
counter, err := h.counterService.Get(c.Request.Context(), counterID, userID)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter"})
}
return
}
c.JSON(http.StatusOK, counter)
}
// List handles retrieving all counters for a user
func (h *CounterHandler) List(c *gin.Context) {
userID := c.GetInt("user_id")
search := c.Query("search")
counters, err := h.counterService.List(c.Request.Context(), userID, search)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counters"})
return
}
c.JSON(http.StatusOK, counters)
}
// Update handles counter updates
func (h *CounterHandler) Update(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
var req counter.UpdateCounterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
counter, err := h.counterService.Update(c.Request.Context(), counterID, userID, &req)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update counter"})
}
return
}
c.JSON(http.StatusOK, counter)
}
// Delete handles counter deletion
func (h *CounterHandler) Delete(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
err = h.counterService.Delete(c.Request.Context(), counterID, userID)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete counter"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "Counter deleted successfully"})
}
// Increment handles counter increment/decrement
func (h *CounterHandler) Increment(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
var req counter.IncrementRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
entry, err := h.counterService.Increment(c.Request.Context(), counterID, userID, &req)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create counter entry"})
}
return
}
c.JSON(http.StatusCreated, entry)
}
// GetEntries handles retrieving counter entries
func (h *CounterHandler) GetEntries(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
// Parse date range parameters
var startDate, endDate *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
if parsed, err := time.Parse("2006-01-02", startDateStr); err == nil {
startDate = &parsed
}
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if parsed, err := time.Parse("2006-01-02", endDateStr); err == nil {
endDate = &parsed
}
}
entries, err := h.counterService.GetEntries(c.Request.Context(), counterID, userID, startDate, endDate)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch counter entries"})
}
return
}
c.JSON(http.StatusOK, entries)
}
// GetStats handles retrieving counter statistics
func (h *CounterHandler) GetStats(c *gin.Context) {
userID := c.GetInt("user_id")
counterID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid counter ID"})
return
}
// Parse days parameter
days := 30 // default
if daysStr := c.Query("days"); daysStr != "" {
if parsed, err := strconv.Atoi(daysStr); err == nil && parsed > 0 {
days = parsed
}
}
stats, err := h.counterService.GetStats(c.Request.Context(), counterID, userID, days)
if err != nil {
switch err {
case entities.ErrCounterNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Counter not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
}
return
}
c.JSON(http.StatusOK, gin.H{"daily_stats": stats})
}

View File

@@ -0,0 +1,57 @@
package middleware
import (
"net/http"
"strings"
"counter/internal/infrastructure/security"
"github.com/gin-gonic/gin"
)
// AuthMiddleware validates JWT token and sets user context
func AuthMiddleware(jwtService security.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
// Extract token from "Bearer <token>"
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
tokenString := tokenParts[1]
claims, err := jwtService.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// Set user information in context
userID, ok := claims["user_id"].(float64)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
username, ok := claims["username"].(string)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
c.Set("user_id", int(userID))
c.Set("username", username)
c.Next()
}
}

View File

@@ -0,0 +1,43 @@
package middleware
import (
"time"
"counter/internal/infrastructure/config"
"counter/internal/infrastructure/logging"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// LoggingMiddleware creates a Gin middleware for HTTP request logging
func LoggingMiddleware(logger logging.Logger, cfg *config.Config) gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Create structured log entry with default fields
entry := logger.WithFields(logrus.Fields{
"service": "counter-app",
"environment": string(cfg.Environment),
"version": "1.0.0",
"method": param.Method,
"path": param.Path,
"status": param.StatusCode,
"latency": param.Latency.String(),
"client_ip": param.ClientIP,
"user_agent": param.Request.UserAgent(),
"timestamp": param.TimeStamp.Format(time.RFC3339),
})
// Set log level based on status code
switch {
case param.StatusCode >= 500:
entry.Error("HTTP Request")
case param.StatusCode >= 400:
entry.Warn("HTTP Request")
default:
entry.Info("HTTP Request")
}
// Return empty string since we're handling logging ourselves
return ""
})
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"time"
"counter/internal/infrastructure/metrics"
"github.com/gin-gonic/gin"
)
// MetricsMiddleware creates a Gin middleware for HTTP metrics
func MetricsMiddleware(metricsService metrics.MetricsService) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
metricsService.RecordHTTPRequest(c.Request.Method, c.FullPath(), c.Writer.Status(), duration)
}
}

View File

@@ -0,0 +1,107 @@
package http
import (
"counter/internal/delivery/http/handlers"
"counter/internal/delivery/http/middleware"
"counter/internal/infrastructure/config"
"counter/internal/infrastructure/logging"
"counter/internal/infrastructure/metrics"
"counter/internal/infrastructure/security"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// Router sets up all HTTP routes and middleware
type Router struct {
router *gin.Engine
authHandler *handlers.AuthHandler
counterHandler *handlers.CounterHandler
config *config.Config
logger logging.Logger
metricsService metrics.MetricsService
jwtService security.JWTService
}
// NewRouter creates a new router with all dependencies
func NewRouter(
authHandler *handlers.AuthHandler,
counterHandler *handlers.CounterHandler,
cfg *config.Config,
logger logging.Logger,
metricsService metrics.MetricsService,
jwtService security.JWTService,
) *Router {
// Set Gin mode
gin.SetMode(cfg.GinMode)
router := gin.Default()
return &Router{
router: router,
authHandler: authHandler,
counterHandler: counterHandler,
config: cfg,
logger: logger,
metricsService: metricsService,
jwtService: jwtService,
}
}
// SetupRoutes configures all routes and middleware
func (r *Router) SetupRoutes() {
// Configure CORS
corsConfig := cors.DefaultConfig()
corsConfig.AllowOrigins = []string{"http://localhost:3000", "http://localhost:5173"} // React dev servers
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
corsConfig.AllowCredentials = true
r.router.Use(cors.New(corsConfig))
// Add middleware
r.router.Use(middleware.MetricsMiddleware(r.metricsService))
r.router.Use(middleware.LoggingMiddleware(r.logger, r.config))
// Health check endpoint
r.router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// API routes
api := r.router.Group("/api/v1")
{
// Authentication routes
authGroup := api.Group("/auth")
{
authGroup.POST("/register", r.authHandler.Register)
authGroup.POST("/login", r.authHandler.Login)
authGroup.GET("/me", middleware.AuthMiddleware(r.jwtService), r.authHandler.GetMe)
}
// Counter routes (protected)
counterGroup := api.Group("/counters")
counterGroup.Use(middleware.AuthMiddleware(r.jwtService))
{
counterGroup.POST("", r.counterHandler.Create)
counterGroup.GET("", r.counterHandler.List)
counterGroup.GET("/:id", r.counterHandler.Get)
counterGroup.PUT("/:id", r.counterHandler.Update)
counterGroup.DELETE("/:id", r.counterHandler.Delete)
counterGroup.POST("/:id/increment", r.counterHandler.Increment)
counterGroup.GET("/:id/entries", r.counterHandler.GetEntries)
counterGroup.GET("/:id/stats", r.counterHandler.GetStats)
}
}
// Serve static files (React app)
r.router.Static("/static", "./frontend/build/static")
r.router.StaticFile("/", "./frontend/build/index.html")
r.router.NoRoute(func(c *gin.Context) {
c.File("./frontend/build/index.html")
})
}
// GetRouter returns the configured Gin router
func (r *Router) GetRouter() *gin.Engine {
return r.router
}

View File

@@ -0,0 +1,60 @@
package entities
import "time"
// Counter represents a counter entity
type Counter struct {
ID int `json:"id" db:"id"`
UserID int `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CounterEntry represents a single increment/decrement entry
type CounterEntry struct {
ID int `json:"id" db:"id"`
CounterID int `json:"counter_id" db:"counter_id"`
Value int `json:"value" db:"value"` // +1 for increment, -1 for decrement
Date time.Time `json:"date" db:"date"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// CounterWithStats represents a counter with aggregated statistics
type CounterWithStats struct {
Counter
TotalValue int `json:"total_value"`
TodayValue int `json:"today_value"`
WeekValue int `json:"week_value"`
MonthValue int `json:"month_value"`
EntryCount int `json:"entry_count"`
}
// DailyStat represents daily statistics for a counter
type DailyStat struct {
Date time.Time `json:"date"`
Total int `json:"total"`
}
// Validate validates counter data
func (c *Counter) Validate() error {
if c.Name == "" {
return ErrInvalidCounterName
}
if c.UserID <= 0 {
return ErrInvalidUserID
}
return nil
}
// Validate validates counter entry data
func (ce *CounterEntry) Validate() error {
if ce.CounterID <= 0 {
return ErrInvalidCounterID
}
if ce.Value == 0 {
return ErrInvalidEntryValue
}
return nil
}

View File

@@ -0,0 +1,19 @@
package entities
import "errors"
// Domain errors
var (
ErrInvalidUsername = errors.New("username is required")
ErrInvalidEmail = errors.New("email is required")
ErrInvalidPassword = errors.New("password is required")
ErrInvalidCounterName = errors.New("counter name is required")
ErrInvalidUserID = errors.New("invalid user ID")
ErrInvalidCounterID = errors.New("invalid counter ID")
ErrInvalidEntryValue = errors.New("entry value cannot be zero")
ErrUserNotFound = errors.New("user not found")
ErrCounterNotFound = errors.New("counter not found")
ErrCounterEntryNotFound = errors.New("counter entry not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
)

View File

@@ -0,0 +1,32 @@
package entities
import "time"
// User represents a registered user
type User struct {
ID int `json:"id" db:"id"`
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
Password string `json:"-" db:"password"` // Hidden from JSON
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Validate validates user data
func (u *User) Validate() error {
if u.Username == "" {
return ErrInvalidUsername
}
if u.Email == "" {
return ErrInvalidEmail
}
if u.Password == "" {
return ErrInvalidPassword
}
return nil
}
// ClearPassword removes password from user for safe serialization
func (u *User) ClearPassword() {
u.Password = ""
}

View File

@@ -0,0 +1,20 @@
package repositories
import (
"context"
"counter/internal/domain/entities"
"time"
)
// CounterRepository defines the interface for counter data operations
type CounterRepository interface {
Create(ctx context.Context, counter *entities.Counter) error
FindByID(ctx context.Context, id, userID int) (*entities.CounterWithStats, error)
FindByUserID(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error)
Update(ctx context.Context, counter *entities.Counter) error
Delete(ctx context.Context, id, userID int) error
AddEntry(ctx context.Context, entry *entities.CounterEntry) error
GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error)
GetDailyStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error)
Exists(ctx context.Context, id, userID int) (bool, error)
}

View File

@@ -0,0 +1,16 @@
package repositories
import (
"context"
"counter/internal/domain/entities"
)
// UserRepository defines the interface for user data operations
type UserRepository interface {
Create(ctx context.Context, user *entities.User) error
FindByID(ctx context.Context, id int) (*entities.User, error)
FindByUsername(ctx context.Context, username string) (*entities.User, error)
FindByEmail(ctx context.Context, email string) (*entities.User, error)
Update(ctx context.Context, user *entities.User) error
Delete(ctx context.Context, id int) error
}

View File

@@ -0,0 +1,225 @@
package config
import (
"fmt"
"log"
"os"
"strings"
"github.com/joho/godotenv"
)
type Environment string
const (
Development Environment = "development"
Staging Environment = "staging"
Production Environment = "production"
)
type Config struct {
Environment Environment
DatabaseURL string
JWTSecret string
Port string
MetricsPort string
GinMode string
LogLevel string
Debug bool
LogDir string
LogVolume string
}
// LoadConfig loads configuration with proper environment file precedence
func LoadConfig() *Config {
// Load environment files in priority order
loadEnvironmentFiles()
// Get environment
env := getEnvironment()
// Load configuration
config := &Config{
Environment: env,
DatabaseURL: getEnv("DATABASE_URL", getDefaultDatabaseURL(env)),
JWTSecret: getRequiredEnv("JWT_SECRET"),
Port: getEnv("PORT", "8080"),
MetricsPort: getEnv("METRICS_PORT", "9090"),
GinMode: getGinMode(env),
LogLevel: getLogLevel(env),
Debug: env == Development,
LogDir: getEnv("LOG_DIR", "/app/logs"),
LogVolume: getEnv("LOG_VOLUME", "counter_logs"),
}
// Log configuration (without sensitive data)
logConfig(config)
return config
}
// loadEnvironmentFiles loads environment files in priority order
func loadEnvironmentFiles() {
// Get environment first (from system env or default)
env := getEnvironmentFromSystem()
log.Printf("🔍 Detected environment: %s", env)
// Define file loading order (later files override earlier ones)
files := []string{
".env", // Base configuration
fmt.Sprintf(".env.%s", env), // Environment-specific
}
// Load files in order
for _, file := range files {
if _, err := os.Stat(file); err == nil {
if err := godotenv.Load(file); err != nil {
log.Printf("⚠️ Warning: Could not load %s: %v", file, err)
} else {
log.Printf("✅ Loaded: %s", file)
}
} else {
log.Printf("❌ Not found: %s", file)
}
}
}
// getEnvironmentFromSystem gets environment from system variables only
func getEnvironmentFromSystem() string {
// Check if ENVIRONMENT is already set
if env := os.Getenv("ENVIRONMENT"); env != "" {
return strings.ToLower(env)
}
// Fallback detection
if ginMode := os.Getenv("GIN_MODE"); ginMode == "release" {
return "production"
}
return "development"
}
// getEnvironment gets the current environment
func getEnvironment() Environment {
env := strings.ToLower(getEnv("ENVIRONMENT", "development"))
switch env {
case "development", "dev":
return Development
case "staging", "stage":
return Staging
case "production", "prod":
return Production
default:
log.Printf("⚠️ Unknown environment '%s', defaulting to development", env)
return Development
}
}
// getGinMode returns the appropriate Gin mode for the environment
func getGinMode(env Environment) string {
switch env {
case Production, Staging:
return "release"
case Development:
return "debug"
default:
return "debug"
}
}
// getLogLevel returns the appropriate log level for the environment
func getLogLevel(env Environment) string {
switch env {
case Production:
return "info" // Changed from "warn" to "info" to capture more logs
case Staging:
return "info"
case Development:
return "debug"
default:
return "debug"
}
}
// getDefaultDatabaseURL returns default database URL for environment
func getDefaultDatabaseURL(env Environment) string {
switch env {
case Development:
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
case Staging:
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=disable"
case Production:
return "postgres://postgres:password@postgres:5432/counter_db?sslmode=require"
default:
return "postgres://postgres:password@localhost:5432/counter_db?sslmode=disable"
}
}
// getEnv gets environment variable with default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getRequiredEnv gets required environment variable
func getRequiredEnv(key string) string {
value := os.Getenv(key)
if value == "" {
log.Fatalf("❌ Required environment variable %s is not set", key)
}
return value
}
// logConfig logs configuration (without sensitive data)
func logConfig(config *Config) {
// Use standard log for configuration banner since logger might not be initialized yet
log.Printf("")
log.Printf("╔══════════════════════════════════════════════════════════════╗")
log.Printf("║ COUNTER APPLICATION ║")
log.Printf("║ ║")
log.Printf("║ 🌍 ENVIRONMENT: %-15s ║", strings.ToUpper(string(config.Environment)))
log.Printf("║ 🚀 MODE: %-20s ║", config.GinMode)
log.Printf("║ 🔧 DEBUG: %-20s ║", fmt.Sprintf("%t", config.Debug))
log.Printf("║ 📊 LOG LEVEL: %-15s ║", config.LogLevel)
log.Printf("║ 🌐 PORT: %-20s ║", config.Port)
log.Printf("║ 📈 METRICS PORT: %-15s ║", config.MetricsPort)
log.Printf("║ 📝 LOG DIR: %-20s ║", config.LogDir)
log.Printf("║ 📦 LOG VOLUME: %-18s ║", config.LogVolume)
log.Printf("║ ║")
log.Printf("║ 📁 Configuration Files Loaded: ║")
log.Printf("║ • .env (base configuration) ║")
log.Printf("║ • .env.%s (environment-specific) ║", config.Environment)
log.Printf("║ ║")
log.Printf("║ 🔐 Security: ║")
log.Printf("║ • Database: %s ║", maskDatabaseURL(config.DatabaseURL))
log.Printf("║ • JWT Secret: %s ║", maskSecret(config.JWTSecret))
log.Printf("║ ║")
log.Printf("╚══════════════════════════════════════════════════════════════╝")
log.Printf("")
}
// maskDatabaseURL masks sensitive parts of database URL
func maskDatabaseURL(url string) string {
// Simple masking - replace password with ***
if strings.Contains(url, "://") {
parts := strings.Split(url, "://")
if len(parts) == 2 {
// Replace password in connection string
masked := strings.Replace(parts[1], ":", ":***@", 1)
return parts[0] + "://" + masked
}
}
return "***"
}
// maskSecret masks JWT secret for logging
func maskSecret(secret string) string {
if len(secret) <= 8 {
return "***"
}
return secret[:4] + "***" + secret[len(secret)-4:]
}

View File

@@ -0,0 +1,90 @@
package postgres
import (
"database/sql"
"fmt"
"counter/internal/infrastructure/config"
"counter/internal/infrastructure/logging"
_ "github.com/lib/pq"
)
// Connection manages the database connection
type Connection struct {
db *sql.DB
}
// NewConnection creates a new database connection
func NewConnection(cfg *config.Config, logger logging.Logger) (*Connection, error) {
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Test the connection
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
logger.Info("✅ Database connection established successfully")
conn := &Connection{db: db}
// Create tables
if err := conn.CreateTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return conn, nil
}
// GetDB returns the database connection
func (c *Connection) GetDB() *sql.DB {
return c.db
}
// Close closes the database connection
func (c *Connection) Close() error {
return c.db.Close()
}
// CreateTables creates the necessary database tables
func (c *Connection) CreateTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS counters (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS counter_entries (
id SERIAL PRIMARY KEY,
counter_id INTEGER REFERENCES counters(id) ON DELETE CASCADE,
value INTEGER NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_counters_user_id ON counters(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_counter_entries_counter_id ON counter_entries(counter_id)`,
`CREATE INDEX IF NOT EXISTS idx_counter_entries_date ON counter_entries(date)`,
}
for _, query := range queries {
if _, err := c.db.Exec(query); err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,283 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"counter/internal/domain/entities"
"counter/internal/domain/repositories"
)
// CounterRepository implements the CounterRepository interface for PostgreSQL
type CounterRepository struct {
db *sql.DB
}
// NewCounterRepository creates a new counter repository
func NewCounterRepository(db *sql.DB) repositories.CounterRepository {
return &CounterRepository{db: db}
}
// Create creates a new counter
func (r *CounterRepository) Create(ctx context.Context, counter *entities.Counter) error {
query := `
INSERT INTO counters (user_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`
err := r.db.QueryRowContext(ctx, query, counter.UserID, counter.Name, counter.Description).
Scan(&counter.ID, &counter.CreatedAt, &counter.UpdatedAt)
if err != nil {
return err
}
return nil
}
// FindByID finds a counter by ID with stats
func (r *CounterRepository) FindByID(ctx context.Context, id, userID int) (*entities.CounterWithStats, error) {
query := `
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
COALESCE(SUM(ce.value), 0) as total_value,
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
COUNT(ce.id) as entry_count
FROM counters c
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
WHERE c.id = $1 AND c.user_id = $2
GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at
`
counter := &entities.CounterWithStats{}
err := r.db.QueryRowContext(ctx, query, id, userID).
Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue, &counter.EntryCount)
if err == sql.ErrNoRows {
return nil, entities.ErrCounterNotFound
}
if err != nil {
return nil, err
}
return counter, nil
}
// FindByUserID finds all counters for a user with stats
func (r *CounterRepository) FindByUserID(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error) {
query := `
SELECT c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at,
COALESCE(SUM(ce.value), 0) as total_value,
COALESCE(SUM(CASE WHEN ce.date = CURRENT_DATE THEN ce.value ELSE 0 END), 0) as today_value,
COALESCE(SUM(CASE WHEN ce.date >= CURRENT_DATE - INTERVAL '7 days' THEN ce.value ELSE 0 END), 0) as week_value,
COALESCE(SUM(CASE WHEN ce.date >= DATE_TRUNC('month', CURRENT_DATE) THEN ce.value ELSE 0 END), 0) as month_value,
COUNT(ce.id) as entry_count
FROM counters c
LEFT JOIN counter_entries ce ON c.id = ce.counter_id
WHERE c.user_id = $1
`
args := []interface{}{userID}
if search != "" {
query += " AND (c.name ILIKE $2 OR c.description ILIKE $2)"
args = append(args, "%"+search+"%")
}
query += " GROUP BY c.id, c.user_id, c.name, c.description, c.created_at, c.updated_at ORDER BY c.updated_at DESC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var counters []*entities.CounterWithStats
for rows.Next() {
counter := &entities.CounterWithStats{}
err := rows.Scan(&counter.ID, &counter.UserID, &counter.Name, &counter.Description,
&counter.CreatedAt, &counter.UpdatedAt, &counter.TotalValue,
&counter.TodayValue, &counter.WeekValue, &counter.MonthValue, &counter.EntryCount)
if err != nil {
return nil, err
}
counters = append(counters, counter)
}
return counters, nil
}
// Update updates a counter
func (r *CounterRepository) Update(ctx context.Context, counter *entities.Counter) error {
query := `
UPDATE counters
SET name = $1, description = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND user_id = $4
RETURNING updated_at
`
err := r.db.QueryRowContext(ctx, query, counter.Name, counter.Description, counter.ID, counter.UserID).
Scan(&counter.UpdatedAt)
if err == sql.ErrNoRows {
return entities.ErrCounterNotFound
}
if err != nil {
return err
}
return nil
}
// Delete deletes a counter
func (r *CounterRepository) Delete(ctx context.Context, id, userID int) error {
query := `DELETE FROM counters WHERE id = $1 AND user_id = $2`
result, err := r.db.ExecContext(ctx, query, id, userID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return entities.ErrCounterNotFound
}
return nil
}
// AddEntry adds a new counter entry
func (r *CounterRepository) AddEntry(ctx context.Context, entry *entities.CounterEntry) error {
query := `
INSERT INTO counter_entries (counter_id, value, date)
VALUES ($1, $2, $3)
RETURNING id, created_at
`
err := r.db.QueryRowContext(ctx, query, entry.CounterID, entry.Value, entry.Date).
Scan(&entry.ID, &entry.CreatedAt)
if err != nil {
return err
}
// Update counter's updated_at timestamp
_, err = r.db.ExecContext(ctx, "UPDATE counters SET updated_at = CURRENT_TIMESTAMP WHERE id = $1", entry.CounterID)
if err != nil {
// Log error but don't fail the request
// This could be improved with proper logging
}
return nil
}
// GetEntries retrieves entries for a specific counter
func (r *CounterRepository) GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error) {
// First verify counter belongs to user
exists, err := r.Exists(ctx, counterID, userID)
if err != nil {
return nil, err
}
if !exists {
return nil, entities.ErrCounterNotFound
}
query := `
SELECT id, counter_id, value, date, created_at
FROM counter_entries
WHERE counter_id = $1
`
args := []interface{}{counterID}
if startDate != nil {
query += fmt.Sprintf(" AND date >= $%d", len(args)+1)
args = append(args, *startDate)
if endDate != nil {
query += fmt.Sprintf(" AND date <= $%d", len(args)+1)
args = append(args, *endDate)
}
} else if endDate != nil {
query += fmt.Sprintf(" AND date <= $%d", len(args)+1)
args = append(args, *endDate)
}
query += " ORDER BY date DESC, created_at DESC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []*entities.CounterEntry
for rows.Next() {
entry := &entities.CounterEntry{}
err := rows.Scan(&entry.ID, &entry.CounterID, &entry.Value, &entry.Date, &entry.CreatedAt)
if err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, nil
}
// GetDailyStats retrieves daily statistics for a counter
func (r *CounterRepository) GetDailyStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error) {
// First verify counter belongs to user
exists, err := r.Exists(ctx, counterID, userID)
if err != nil {
return nil, err
}
if !exists {
return nil, entities.ErrCounterNotFound
}
query := `
SELECT date, SUM(value) as daily_total
FROM counter_entries
WHERE counter_id = $1 AND date >= CURRENT_DATE - INTERVAL '%d days'
GROUP BY date
ORDER BY date DESC
`
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(query, days), counterID)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []*entities.DailyStat
for rows.Next() {
stat := &entities.DailyStat{}
err := rows.Scan(&stat.Date, &stat.Total)
if err != nil {
return nil, err
}
stats = append(stats, stat)
}
return stats, nil
}
// Exists checks if a counter exists and belongs to the user
func (r *CounterRepository) Exists(ctx context.Context, id, userID int) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM counters WHERE id = $1 AND user_id = $2)`
var exists bool
err := r.db.QueryRowContext(ctx, query, id, userID).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}

View File

@@ -0,0 +1,146 @@
package postgres
import (
"context"
"database/sql"
"counter/internal/domain/entities"
"counter/internal/domain/repositories"
)
// UserRepository implements the UserRepository interface for PostgreSQL
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *sql.DB) repositories.UserRepository {
return &UserRepository{db: db}
}
// Create creates a new user
func (r *UserRepository) Create(ctx context.Context, user *entities.User) error {
query := `
INSERT INTO users (username, email, password)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`
err := r.db.QueryRowContext(ctx, query, user.Username, user.Email, user.Password).
Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return err
}
return nil
}
// FindByID finds a user by ID
func (r *UserRepository) FindByID(ctx context.Context, id int) (*entities.User, error) {
query := `
SELECT id, username, email, password, created_at, updated_at
FROM users
WHERE id = $1
`
user := &entities.User{}
err := r.db.QueryRowContext(ctx, query, id).
Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, entities.ErrUserNotFound
}
if err != nil {
return nil, err
}
return user, nil
}
// FindByUsername finds a user by username
func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*entities.User, error) {
query := `
SELECT id, username, email, password, created_at, updated_at
FROM users
WHERE username = $1
`
user := &entities.User{}
err := r.db.QueryRowContext(ctx, query, username).
Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, entities.ErrUserNotFound
}
if err != nil {
return nil, err
}
return user, nil
}
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
query := `
SELECT id, username, email, password, created_at, updated_at
FROM users
WHERE email = $1
`
user := &entities.User{}
err := r.db.QueryRowContext(ctx, query, email).
Scan(&user.ID, &user.Username, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, entities.ErrUserNotFound
}
if err != nil {
return nil, err
}
return user, nil
}
// Update updates a user
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
query := `
UPDATE users
SET username = $1, email = $2, password = $3, updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING updated_at
`
err := r.db.QueryRowContext(ctx, query, user.Username, user.Email, user.Password, user.ID).
Scan(&user.UpdatedAt)
if err == sql.ErrNoRows {
return entities.ErrUserNotFound
}
if err != nil {
return err
}
return nil
}
// Delete deletes a user
func (r *UserRepository) Delete(ctx context.Context, id int) error {
query := `DELETE FROM users WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return entities.ErrUserNotFound
}
return nil
}

View File

@@ -0,0 +1,111 @@
package logging
import (
"io"
"os"
"path/filepath"
"time"
"counter/internal/infrastructure/config"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// Logger interface defines the contract for logging
type Logger interface {
Info(args ...interface{})
Infof(format string, args ...interface{})
Warn(args ...interface{})
Warnf(format string, args ...interface{})
Error(args ...interface{})
Errorf(format string, args ...interface{})
Fatal(args ...interface{})
Fatalf(format string, args ...interface{})
WithFields(fields logrus.Fields) *logrus.Entry
WithError(err error) *logrus.Entry
}
// LogrusLogger implements the Logger interface using logrus
type LogrusLogger struct {
*logrus.Logger
}
// InitLogger initializes the structured logger with file output
func InitLogger(cfg *config.Config) (Logger, error) {
logger := logrus.New()
// Set log level based on configuration
level, err := logrus.ParseLevel(cfg.LogLevel)
if err != nil {
level = logrus.InfoLevel
}
logger.SetLevel(level)
// Set JSON formatter for structured logging
logger.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: time.RFC3339,
FieldMap: logrus.FieldMap{
logrus.FieldKeyTime: "timestamp",
logrus.FieldKeyLevel: "level",
logrus.FieldKeyMsg: "message",
},
})
// Create log directory if it doesn't exist
if err := os.MkdirAll(cfg.LogDir, 0755); err != nil {
return nil, err
}
// Create log file with timestamp
logFile := filepath.Join(cfg.LogDir, "app.log")
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
// Set output to both file and stdout
multiWriter := io.MultiWriter(os.Stdout, file)
logger.SetOutput(multiWriter)
// Log initialization with default fields
logger.WithFields(logrus.Fields{
"service": "counter-app",
"environment": string(cfg.Environment),
"version": "1.0.0",
}).Info("Logger initialized successfully")
return &LogrusLogger{Logger: logger}, nil
}
// LoggingMiddleware creates a Gin middleware for HTTP request logging
func LoggingMiddleware(logger Logger, cfg *config.Config) gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Create structured log entry with default fields
entry := logger.WithFields(logrus.Fields{
"service": "counter-app",
"environment": string(cfg.Environment),
"version": "1.0.0",
"method": param.Method,
"path": param.Path,
"status": param.StatusCode,
"latency": param.Latency.String(),
"client_ip": param.ClientIP,
"user_agent": param.Request.UserAgent(),
"timestamp": param.TimeStamp.Format(time.RFC3339),
})
// Set log level based on status code
switch {
case param.StatusCode >= 500:
entry.Error("HTTP Request")
case param.StatusCode >= 400:
entry.Warn("HTTP Request")
default:
entry.Info("HTTP Request")
}
// Return empty string since we're handling logging ourselves
return ""
})
}

View File

@@ -0,0 +1,103 @@
package metrics
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// MetricsService interface defines the contract for metrics operations
type MetricsService interface {
RecordHTTPRequest(method, path string, statusCode int, duration time.Duration)
RecordDBOperation(operation, table string)
RecordAuthAttempt(action, result string)
StartMetricsServer(port string)
MetricsMiddleware() gin.HandlerFunc
}
// PrometheusMetricsService implements MetricsService using Prometheus
type PrometheusMetricsService struct {
httpRequestsTotal *prometheus.CounterVec
httpRequestDuration *prometheus.HistogramVec
dbOperationsTotal *prometheus.CounterVec
authAttemptsTotal *prometheus.CounterVec
}
// NewPrometheusMetricsService creates a new Prometheus metrics service
func NewPrometheusMetricsService() MetricsService {
return &PrometheusMetricsService{
httpRequestsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
),
httpRequestDuration: promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
),
dbOperationsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "db_operations_total",
Help: "Total number of database operations",
},
[]string{"operation", "table"},
),
authAttemptsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "auth_attempts_total",
Help: "Total number of authentication attempts",
},
[]string{"action", "result"},
),
}
}
// RecordHTTPRequest records an HTTP request
func (m *PrometheusMetricsService) RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) {
status := strconv.Itoa(statusCode)
m.httpRequestsTotal.WithLabelValues(method, path, status).Inc()
m.httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
}
// RecordDBOperation records a database operation
func (m *PrometheusMetricsService) RecordDBOperation(operation, table string) {
m.dbOperationsTotal.WithLabelValues(operation, table).Inc()
}
// RecordAuthAttempt records an authentication attempt
func (m *PrometheusMetricsService) RecordAuthAttempt(action, result string) {
m.authAttemptsTotal.WithLabelValues(action, result).Inc()
}
// StartMetricsServer starts the Prometheus metrics server
func (m *PrometheusMetricsService) StartMetricsServer(port string) {
http.Handle("/metrics", promhttp.Handler())
go func() {
if err := http.ListenAndServe(":"+port, nil); err != nil {
// Log error but don't fail the application
}
}()
}
// MetricsMiddleware creates a Gin middleware for HTTP metrics
func (m *PrometheusMetricsService) MetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
m.RecordHTTPRequest(c.Request.Method, c.FullPath(), c.Writer.Status(), duration)
}
}

View File

@@ -0,0 +1,59 @@
package security
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// JWTService interface defines the contract for JWT operations
type JWTService interface {
GenerateToken(userID int, username string) (string, error)
ValidateToken(tokenString string) (jwt.MapClaims, error)
}
// JWTServiceImpl implements JWTService using golang-jwt
type JWTServiceImpl struct {
secret []byte
}
// NewJWTService creates a new JWT service
func NewJWTService(secret string) JWTService {
return &JWTServiceImpl{
secret: []byte(secret),
}
}
// GenerateToken generates a JWT token for a user
func (j *JWTServiceImpl) GenerateToken(userID int, username string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"username": username,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(j.secret)
}
// ValidateToken validates a JWT token and returns the claims
func (j *JWTServiceImpl) ValidateToken(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.secret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}

View File

@@ -0,0 +1,29 @@
package security
import "golang.org/x/crypto/bcrypt"
// PasswordService interface defines the contract for password operations
type PasswordService interface {
HashPassword(password string) (string, error)
CheckPasswordHash(password, hash string) bool
}
// PasswordServiceImpl implements PasswordService using bcrypt
type PasswordServiceImpl struct{}
// NewPasswordService creates a new password service
func NewPasswordService() PasswordService {
return &PasswordServiceImpl{}
}
// HashPassword hashes a password using bcrypt
func (p *PasswordServiceImpl) HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
// CheckPasswordHash compares a password with its hash
func (p *PasswordServiceImpl) CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -0,0 +1,141 @@
package auth
import (
"context"
"counter/internal/domain/entities"
"counter/internal/domain/repositories"
"counter/internal/infrastructure/security"
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo repositories.UserRepository
passwordService security.PasswordService
jwtService security.JWTService
}
// NewAuthService creates a new authentication service
func NewAuthService(
userRepo repositories.UserRepository,
passwordService security.PasswordService,
jwtService security.JWTService,
) *AuthService {
return &AuthService{
userRepo: userRepo,
passwordService: passwordService,
jwtService: jwtService,
}
}
// RegisterRequest represents a user registration request
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// LoginRequest represents a user login request
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// AuthResponse represents an authentication response
type AuthResponse struct {
Token string `json:"token"`
User *entities.User `json:"user"`
}
// Register registers a new user
func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*AuthResponse, error) {
// Check if username already exists
_, err := s.userRepo.FindByUsername(ctx, req.Username)
if err == nil {
return nil, entities.ErrUserAlreadyExists
}
if err != entities.ErrUserNotFound {
return nil, err
}
// Check if email already exists
_, err = s.userRepo.FindByEmail(ctx, req.Email)
if err == nil {
return nil, entities.ErrUserAlreadyExists
}
if err != entities.ErrUserNotFound {
return nil, err
}
// Hash password
hashedPassword, err := s.passwordService.HashPassword(req.Password)
if err != nil {
return nil, err
}
// Create user
user := &entities.User{
Username: req.Username,
Email: req.Email,
Password: hashedPassword,
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
// Generate token
token, err := s.jwtService.GenerateToken(user.ID, user.Username)
if err != nil {
return nil, err
}
// Clear password from response
user.ClearPassword()
return &AuthResponse{
Token: token,
User: user,
}, nil
}
// Login authenticates a user
func (s *AuthService) Login(ctx context.Context, req *LoginRequest) (*AuthResponse, error) {
// Find user
user, err := s.userRepo.FindByUsername(ctx, req.Username)
if err != nil {
return nil, entities.ErrInvalidCredentials
}
// Check password
if !s.passwordService.CheckPasswordHash(req.Password, user.Password) {
return nil, entities.ErrInvalidCredentials
}
// Generate token
token, err := s.jwtService.GenerateToken(user.ID, user.Username)
if err != nil {
return nil, err
}
// Clear password from response
user.ClearPassword()
return &AuthResponse{
Token: token,
User: user,
}, nil
}
// GetCurrentUser retrieves the current authenticated user
func (s *AuthService) GetCurrentUser(ctx context.Context, userID int) (*entities.User, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
// Clear password from response
user.ClearPassword()
return user, nil
}

View File

@@ -0,0 +1,133 @@
package counter
import (
"context"
"time"
"counter/internal/domain/entities"
"counter/internal/domain/repositories"
)
// CounterService handles counter business logic
type CounterService struct {
counterRepo repositories.CounterRepository
}
// NewCounterService creates a new counter service
func NewCounterService(counterRepo repositories.CounterRepository) *CounterService {
return &CounterService{
counterRepo: counterRepo,
}
}
// CreateCounterRequest represents a counter creation request
type CreateCounterRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Description string `json:"description" max:"500"`
}
// UpdateCounterRequest represents a counter update request
type UpdateCounterRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Description string `json:"description" max:"500"`
}
// IncrementRequest represents a counter increment request
type IncrementRequest struct {
Value int `json:"value" binding:"required"`
}
// Create creates a new counter
func (s *CounterService) Create(ctx context.Context, userID int, req *CreateCounterRequest) (*entities.Counter, error) {
counter := &entities.Counter{
UserID: userID,
Name: req.Name,
Description: req.Description,
}
if err := counter.Validate(); err != nil {
return nil, err
}
if err := s.counterRepo.Create(ctx, counter); err != nil {
return nil, err
}
return counter, nil
}
// Get retrieves a counter by ID
func (s *CounterService) Get(ctx context.Context, counterID, userID int) (*entities.CounterWithStats, error) {
return s.counterRepo.FindByID(ctx, counterID, userID)
}
// List retrieves all counters for a user
func (s *CounterService) List(ctx context.Context, userID int, search string) ([]*entities.CounterWithStats, error) {
return s.counterRepo.FindByUserID(ctx, userID, search)
}
// Update updates a counter
func (s *CounterService) Update(ctx context.Context, counterID, userID int, req *UpdateCounterRequest) (*entities.Counter, error) {
counter := &entities.Counter{
ID: counterID,
UserID: userID,
Name: req.Name,
Description: req.Description,
}
if err := counter.Validate(); err != nil {
return nil, err
}
if err := s.counterRepo.Update(ctx, counter); err != nil {
return nil, err
}
return counter, nil
}
// Delete deletes a counter
func (s *CounterService) Delete(ctx context.Context, counterID, userID int) error {
return s.counterRepo.Delete(ctx, counterID, userID)
}
// Increment increments/decrements a counter
func (s *CounterService) Increment(ctx context.Context, counterID, userID int, req *IncrementRequest) (*entities.CounterEntry, error) {
// Verify counter exists and belongs to user
exists, err := s.counterRepo.Exists(ctx, counterID, userID)
if err != nil {
return nil, err
}
if !exists {
return nil, entities.ErrCounterNotFound
}
entry := &entities.CounterEntry{
CounterID: counterID,
Value: req.Value,
Date: time.Now().Truncate(24 * time.Hour), // Truncate to date only
}
if err := entry.Validate(); err != nil {
return nil, err
}
if err := s.counterRepo.AddEntry(ctx, entry); err != nil {
return nil, err
}
return entry, nil
}
// GetEntries retrieves entries for a counter
func (s *CounterService) GetEntries(ctx context.Context, counterID, userID int, startDate, endDate *time.Time) ([]*entities.CounterEntry, error) {
return s.counterRepo.GetEntries(ctx, counterID, userID, startDate, endDate)
}
// GetStats retrieves statistics for a counter
func (s *CounterService) GetStats(ctx context.Context, counterID, userID int, days int) ([]*entities.DailyStat, error) {
if days <= 0 {
days = 30 // Default to 30 days
}
return s.counterRepo.GetDailyStats(ctx, counterID, userID, days)
}

25
main.go
View File

@@ -1,25 +0,0 @@
package main
import (
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello world"))
}
func main() {
http.HandleFunc("/", helloHandler)
port := ":8080"
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(port, nil))
}

10
scripts/dev.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# Development environment script
echo "🚀 Starting Counter app in development mode..."
# Set environment
export ENVIRONMENT=development
# Start with development configuration
docker-compose -f docker-compose.yml up --build

10
scripts/prod.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# Production environment script
echo "🚀 Starting Counter app in production mode..."
# Set environment
export ENVIRONMENT=production
# Start with production configuration
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d

10
scripts/staging.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# Staging environment script
echo "🚀 Starting Counter app in staging mode..."
# Set environment
export ENVIRONMENT=staging
# Start with staging configuration
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up --build -d