feat: migrate to DragonflyDB and clean up environment configuration

- Replace Redis with DragonflyDB for better performance and memory efficiency
- Remove redundant environment variables (POSTGRES_*, ENCRYPTION_KEY, OAUTH_SERVICE_URL)
- Consolidate database configuration to use single DB_* variables
- Use JWT_SECRET for both JWT tokens and encryption
- Remove PORT variable redundancy, use BACKEND_PORT consistently
- Clean up docker-compose configurations for dev/prod consistency
- Add DragonflyDB configuration with optimized memory usage
- Remove redis.conf as it's no longer needed
- Update health checks to use Redis-compatible CLI for DragonflyDB
- Add missing VITE_API_URL to production frontend
- Fix GitHub Actions to use correct go.sum path
- Clean up development directories and unused files
This commit is contained in:
Tomas Dvorak
2026-03-05 23:51:34 +01:00
parent f3a835caa2
commit 954a1a1080
146 changed files with 5801 additions and 25847 deletions
+29 -12
View File
@@ -1,27 +1,51 @@
# Server Configuration # Server Configuration
FRONTEND_PORT=3000 FRONTEND_PORT=3000
BACKEND_PORT=8080 BACKEND_PORT=9000
DB_PORT=5432
DRAGONFLY_PORT=6379
GIN_MODE=debug GIN_MODE=debug
# Demo Mode Configuration
# Set to true for demo mode (read-only with demo data)
# Set to false for normal mode (full functionality)
VITE_DEMO_MODE=true
VITE_API_URL=http://localhost:9000
FRONTEND_URL=http://localhost:3000
OAUTH_SERVICE_URL=https://oauth.trackeep.org
VITE_OAUTH_SERVICE_URL=https://oauth.trackeep.org
# Database Configuration # Database Configuration
DB_TYPE=postgres DB_TYPE=postgres
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5433
DB_USER=trackeep DB_USER=trackeep
DB_PASSWORD=your_password_here DB_PASSWORD=your_password_here
DB_NAME=trackeep DB_NAME=trackeep
DB_SSL_MODE=disable DB_SSL_MODE=disable
# DragonflyDB Configuration # DragonflyDB Configuration
DRAGONFLY_ADDR=dragonfly:6379 DRAGONFLY_ADDR=dragonfly:6380
DRAGONFLY_PORT=6380
DRAGONFLY_PASSWORD=your_dragonfly_password_here DRAGONFLY_PASSWORD=your_dragonfly_password_here
# JWT Configuration (also used for encryption) # JWT Configuration (also used for encryption)
# Generate a secure 64-character hex string using: openssl rand -hex 32
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
# Token expiration time (e.g., 24h, 1h, 30m, 7d)
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
# GitHub OAuth App Configuration
GITHUB_CLIENT_ID=your_github_client_id_here
GITHUB_CLIENT_SECRET=your_github_client_secret_here
GITHUB_REDIRECT_URL=http://localhost:9000/api/v1/auth/github/callback
# GitHub App Configuration (for repository installation + backups)
# App installation callback should point to:
# http://localhost:9000/api/v1/github/app/callback
GITHUB_APP_SLUG=trackeep
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
GITHUB_BACKUP_ROOT=./data/github-backups
GITHUB_BACKUP_TIMEOUT=10m
# File Upload Configuration # File Upload Configuration
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760 MAX_FILE_SIZE=10485760
@@ -29,13 +53,6 @@ MAX_FILE_SIZE=10485760
# CORS Configuration # CORS Configuration
CORS_ALLOWED_ORIGINS=* CORS_ALLOWED_ORIGINS=*
# Demo Mode Configuration
VITE_DEMO_MODE=false
# AI Services Configuration
SEARCH_API_PROVIDER=demo
SEARCH_RESULTS_LIMIT=10
# Auto Update Configuration # Auto Update Configuration
AUTO_UPDATE_CHECK=false AUTO_UPDATE_CHECK=false
UPDATE_CHECK_INTERVAL=24h UPDATE_CHECK_INTERVAL=24h
-192
View File
@@ -1,192 +0,0 @@
# Trackeep Mobile App
React Native mobile application for Trackeep - productivity and knowledge management platform.
## Features
### ✅ Core Features Implemented
- **🔐 Authentication**: Login with email/password and GitHub OAuth
- **📱 Offline Support**: Full offline functionality with sync when online
- **📝 Content Management**: Bookmarks, Tasks, Notes, and Time Tracking
- **🔍 Search**: Unified search across all content types
- **⏱️ Time Tracking**: Built-in timer with task association
- **🎨 Modern UI**: Material Design with React Native Paper
- **📊 Dashboard**: Overview with stats and recent activity
### ✅ Mobile-Specific Features
- **Gesture Navigation**: Intuitive mobile navigation patterns
- **Push Notifications**: Task reminders and updates with permission management
- **Camera Integration**: Document scanning capability with permission handling
- **Voice Notes**: Audio recording for quick notes with speech-to-text
- **Background Sync**: Automatic data synchronization
- **Responsive Design**: Optimized for various screen sizes
## Tech Stack
- **React Native** 0.72.6
- **TypeScript** for type safety
- **React Navigation** for navigation
- **React Native Paper** for UI components
- **AsyncStorage** for local data persistence
- **Axios** for API communication
- **Vector Icons** for iconography
## Project Structure
```
mobile-app/
├── src/
│ ├── components/ # Reusable UI components
│ ├── screens/ # Screen components
│ │ ├── auth/ # Authentication screens
│ │ ├── DashboardScreen.tsx
│ │ ├── BookmarksScreen.tsx
│ │ ├── TasksScreen.tsx
│ │ ├── NotesScreen.tsx
│ │ ├── TimeTrackingScreen.tsx
│ │ ├── SearchScreen.tsx
│ │ └── SettingsScreen.tsx
│ ├── services/ # Business logic and API
│ │ ├── AuthContext.tsx
│ │ ├── OfflineContext.tsx
│ │ └── api.ts
│ ├── navigation/ # Navigation configuration
│ ├── utils/ # Utility functions
│ │ ├── storage.ts
│ │ └── offlineSync.ts
│ └── types/ # TypeScript type definitions
├── android/ # Android-specific code
├── ios/ # iOS-specific code
└── package.json
```
## Getting Started
### Prerequisites
- Node.js 16+
- React Native CLI
- Android Studio (for Android development)
- Xcode (for iOS development)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd Trackeep/mobile-app
```
2. Install dependencies:
```bash
npm install
```
3. For iOS, install pods:
```bash
cd ios && pod install && cd ..
```
### Running the App
#### Android
```bash
npm run android
```
#### iOS
```bash
npm run ios
```
#### Start Metro Bundler
```bash
npm start
```
## Configuration
### Environment Variables
Create a `.env` file in the root directory:
```env
API_BASE_URL=http://localhost:8080/api
```
### API Configuration
Update the API base URL in `src/services/api.ts` to match your backend server.
## Features Status
### ✅ Completed
- [x] Project setup and configuration
- [x] Authentication flow (email/password, GitHub)
- [x] Navigation structure
- [x] Core screens (Dashboard, Bookmarks, Tasks, Notes, Time Tracking, Search, Settings)
- [x] Offline data storage and sync
- [x] Modern UI with Material Design
- [x] TypeScript integration
- [x] API service layer
- [x] Push notification implementation with permission management
- [x] Camera integration for document scanning
- [x] Voice recording for notes with speech-to-text
- [x] Enhanced settings screen with mobile features
### 📋 Planned
- [ ] Biometric authentication
- [ ] Dark mode theme
- [ ] Widget support
- [ ] Apple Watch companion app
- [ ] Advanced analytics
## Development
### Code Style
The project uses TypeScript and follows React Native best practices. All components are functional components with hooks.
### State Management
- **Authentication**: React Context (AuthContext)
- **Offline Sync**: React Context (OfflineContext)
- **Local Data**: AsyncStorage with SQLite for complex queries
### API Integration
All API calls are centralized in `src/services/api.ts` with automatic token management and error handling.
## Testing
```bash
npm test
```
## Building
### Android Release Build
```bash
npm run build:android
```
### iOS Release Build
```bash
npm run build:ios
```
## 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.
## Support
For support and questions, please open an issue in the repository.
-210
View File
@@ -1,210 +0,0 @@
# Mobile App Sync Testing Guide
## Overview
This guide helps you test the bi-directional synchronization between the Trackeep mobile app and web dashboard.
## Prerequisites
1. **Backend Server**: Ensure your Trackeep backend is running
2. **Web Dashboard**: Access the web dashboard at `http://localhost:3000` (or your configured URL)
3. **Mobile App**: Run the React Native app using:
```bash
npm start
npm run android # or npm run ios
```
## First Launch Setup
1. **Server Configuration**: On first launch, the mobile app will show the server setup screen:
- Enter your backend URL (e.g., `http://localhost:8080`)
- Enter your credentials
- Test connection before completing setup
2. **Authentication**: After setup, you'll be redirected to login with your existing credentials
## Testing Real-Time Sync
### Test 1: Create Content on Mobile, Verify on Web
1. **On Mobile App**:
- Open the Dashboard
- Tap the FAB (+) button
- Create a new task, bookmark, or note
- Verify it appears in the mobile dashboard
2. **On Web Dashboard**:
- Navigate to the corresponding section (Tasks, Bookmarks, or Notes)
- The new item should appear within seconds (if WebSocket is connected)
- If not, refresh the page to see the synced item
### Test 2: Create Content on Web, Verify on Mobile
1. **On Web Dashboard**:
- Create a new task, bookmark, or note
- Save the item
2. **On Mobile App**:
- The item should appear automatically if real-time sync is working
- Pull to refresh on the dashboard to force sync
- Check the specific section to verify the item appears
### Test 3: Offline Mode Testing
1. **Enable Offline Mode**:
- Turn off internet connection on your mobile device
- The app should show "🔴 Offline" status
2. **Create Content Offline**:
- Create several tasks, bookmarks, or notes
- Notice the pending changes counter increases
3. **Restore Connection**:
- Turn internet back on
- App should show "🟢 Connected" and auto-sync
- Verify items appear on web dashboard
### Test 4: Conflict Resolution
1. **Simulate Conflict**:
- Create the same item on both mobile and web while offline
- Bring both online simultaneously
- Verify how conflicts are resolved (last write wins or merge)
## Key Features to Test
### Real-Time Updates
- ✅ WebSocket connection status
- ✅ Instant updates across devices
- ✅ Connection recovery after disconnection
### Offline Support
- ✅ Offline data persistence
- ✅ Pending changes tracking
- ✅ Automatic sync when online
- ✅ Manual sync button
### Data Integrity
- ✅ All data types sync correctly (tasks, bookmarks, notes)
- ✅ Timestamps preserved
- ✅ User associations maintained
- ✅ Tags and metadata sync
## Troubleshooting
### Common Issues
1. **WebSocket Connection Failed**:
- Check if backend WebSocket endpoint is accessible
- Verify firewall settings
- Check browser console for WebSocket errors
2. **Sync Not Working**:
- Verify server URL in mobile app settings
- Check authentication tokens
- Review backend logs for sync errors
3. **Offline Mode Not Detected**:
- Check network permissions on mobile device
- Verify NetInfo plugin is working
- Test with airplane mode
### Debug Tools
1. **Mobile App Debugging**:
```bash
# Enable debug mode
npx react-native log-android
npx react-native log-ios
```
2. **Backend Logs**:
- Monitor sync endpoint logs
- Check WebSocket connection logs
- Review database transaction logs
3. **Browser Console**:
- Monitor WebSocket connections
- Check for real-time update events
- Verify API responses
## Performance Testing
### Test Scenarios
1. **Large Dataset Sync**:
- Create 100+ items on one device
- Measure sync time to other device
- Verify no data loss
2. **Concurrent Updates**:
- Multiple users updating same data
- Test conflict resolution
- Verify data consistency
3. **Network Conditions**:
- Test on slow networks (2G/3G)
- Test with intermittent connectivity
- Verify sync resilience
## Expected Results
### Successful Sync Indicators
1. **Mobile App**:
- Status shows "🟢 Connected"
- Last sync time updates
- No pending changes counter
- Real-time updates received
2. **Web Dashboard**:
- New items appear without refresh
- WebSocket connection established
- No sync errors in console
### Performance Benchmarks
- **Small items** (< 1KB): Should sync within 1-2 seconds
- **Large items** (> 100KB): Should sync within 5-10 seconds
- **Batch sync** (50+ items): Should complete within 30 seconds
## Automated Testing
For comprehensive testing, consider implementing:
1. **Unit Tests**:
- Sync logic validation
- Offline queue management
- Conflict resolution
2. **Integration Tests**:
- End-to-end sync workflows
- WebSocket connection testing
- API integration validation
3. **E2E Tests**:
- Multi-device sync scenarios
- Offline/online transitions
- User interaction flows
## Reporting Issues
When reporting sync issues, include:
1. Device information (OS, version)
2. Network conditions
3. Steps to reproduce
4. Screenshots of error messages
5. Backend logs (if available)
6. Browser console errors
## Success Criteria
The sync implementation is considered successful when:
- ✅ All data types sync bi-directionally
- ✅ Real-time updates work within 5 seconds
- ✅ Offline mode functions correctly
- ✅ No data loss during sync
- ✅ Conflicts are handled gracefully
- ✅ Performance meets benchmarks
- ✅ Error recovery works reliably
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

-6
View File
@@ -1,6 +0,0 @@
{
"name": "Trackeep",
"displayName": "Trackeep",
"version": "1.0.0",
"description": "Productivity and knowledge management mobile app"
}
-15
View File
@@ -1,15 +0,0 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
[
'module-resolver',
{
root: ['./src'],
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
alias: {
'@': './src',
},
},
],
],
};
-9
View File
@@ -1,9 +0,0 @@
/**
* Trackeep Mobile App
* React Native entry point
*/
import {AppRegistry} from 'react-native';
import App from './src/App';
import {name as appName} from './app.json';
AppRegistry.registerComponent(appName, () => App);
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

-21
View File
@@ -1,21 +0,0 @@
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const defaultConfig = getDefaultConfig(__dirname);
const config = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
resolver: {
alias: {
'@': './src',
},
},
};
module.exports = mergeConfig(defaultConfig, config);
-12563
View File
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
{
"name": "trackeep-mobile",
"version": "1.0.0",
"description": "Trackeep mobile app for productivity and knowledge management",
"main": "index.js",
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"build:android": "cd android && ./gradlew assembleRelease",
"build:ios": "react-native run-ios --configuration Release"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.19.5",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/drawer": "^6.6.6",
"@react-navigation/native": "^6.1.9",
"@react-navigation/native-stack": "^6.9.26",
"@react-navigation/stack": "^6.3.20",
"@types/react-native-push-notification": "^8.1.4",
"@types/react-native-vector-icons": "^6.4.18",
"axios": "^1.6.2",
"react": "18.2.0",
"react-native": "0.72.6",
"react-native-background-timer": "^2.4.1",
"react-native-camera": "^4.2.1",
"react-native-gesture-handler": "^2.13.4",
"react-native-keychain": "^8.1.3",
"react-native-paper": "^5.11.1",
"react-native-permissions": "^3.10.1",
"react-native-push-notification": "^8.1.1",
"react-native-reanimated": "^3.5.4",
"react-native-safe-area-context": "^4.7.4",
"react-native-screens": "^3.25.0",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "^13.14.0",
"react-native-vector-icons": "^10.0.2",
"react-native-vision-camera": "^3.3.5",
"react-native-voice": "^0.3.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@react-native/eslint-config": "^0.72.2",
"@react-native/metro-config": "^0.72.11",
"@tsconfig/react-native": "^3.0.0",
"@types/react": "^18.0.24",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.2.1",
"eslint": "^8.19.0",
"jest": "^29.2.1",
"metro-react-native-babel-preset": "0.76.8",
"prettier": "^2.4.1",
"react-test-renderer": "18.2.0",
"typescript": "4.8.4"
},
"jest": {
"preset": "react-native"
}
}
-93
View File
@@ -1,93 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
NavigationContainer,
DefaultTheme as NavigationDefaultTheme,
DarkTheme as NavigationDarkTheme,
} from '@react-navigation/native';
import {
Provider as PaperProvider,
DefaultTheme as PaperDefaultTheme,
MD3DarkTheme as PaperDarkTheme,
} from 'react-native-paper';
import { StatusBar } from 'react-native';
import { AuthProvider } from './services/AuthContext';
import { OfflineProvider } from './services/OfflineContext';
import { NotificationProvider } from './services/NotificationContext';
import { CameraProvider } from './services/CameraContext';
import { VoiceProvider } from './services/VoiceContext';
import { ServerConfigProvider } from './services/ServerConfigContext';
import { RealtimeSyncProvider } from './services/RealtimeSyncContext';
import AppNavigator from './navigation/AppNavigator';
import { loadTheme } from './utils/storage';
const CombinedDefaultTheme = {
...NavigationDefaultTheme,
...PaperDefaultTheme,
colors: {
...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors,
},
};
const CombinedDarkTheme = {
...NavigationDarkTheme,
...PaperDarkTheme,
colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
},
};
const App: React.FC = () => {
const [isDarkTheme, setIsDarkTheme] = useState(false);
const [isThemeLoaded, setIsThemeLoaded] = useState(false);
useEffect(() => {
const initializeTheme = async () => {
try {
const savedTheme = await loadTheme();
setIsDarkTheme(savedTheme === 'dark');
} catch (error) {
console.error('Error loading theme:', error);
} finally {
setIsThemeLoaded(true);
}
};
initializeTheme();
}, []);
const theme = isDarkTheme ? CombinedDarkTheme : CombinedDefaultTheme;
if (!isThemeLoaded) {
return null;
}
return (
<PaperProvider theme={theme}>
<NavigationContainer theme={theme}>
<StatusBar
barStyle={isDarkTheme ? 'light-content' : 'dark-content'}
backgroundColor={theme.colors.background}
/>
<ServerConfigProvider>
<RealtimeSyncProvider>
<AuthProvider>
<NotificationProvider>
<CameraProvider>
<VoiceProvider>
<OfflineProvider>
<AppNavigator />
</OfflineProvider>
</VoiceProvider>
</CameraProvider>
</NotificationProvider>
</AuthProvider>
</RealtimeSyncProvider>
</ServerConfigProvider>
</NavigationContainer>
</PaperProvider>
);
};
export default App;
@@ -1,40 +0,0 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../services/AuthContext';
import { useServerConfig } from '../services/ServerConfigContext';
import AuthNavigator from './AuthNavigator';
import TabNavigator from './TabNavigator';
import LoadingScreen from '../screens/LoadingScreen';
import ServerSetupScreen from '../screens/ServerSetupScreen';
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
Loading: undefined;
ServerSetup: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
const AppNavigator: React.FC = () => {
const { isAuthenticated, isLoading } = useAuth();
const { isConfigured, isLoading: configLoading } = useServerConfig();
if (isLoading || configLoading) {
return <LoadingScreen />;
}
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{!isConfigured ? (
<Stack.Screen name="ServerSetup" component={ServerSetupScreen} />
) : isAuthenticated ? (
<Stack.Screen name="Main" component={TabNavigator} />
) : (
<Stack.Screen name="Auth" component={AuthNavigator} />
)}
</Stack.Navigator>
);
};
export default AppNavigator;
@@ -1,27 +0,0 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import LoginScreen from '../screens/auth/LoginScreen';
import RegisterScreen from '../screens/auth/RegisterScreen';
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createNativeStackNavigator<AuthStackParamList>();
const AuthNavigator: React.FC = () => {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
gestureEnabled: false,
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
};
export default AuthNavigator;
@@ -1,129 +0,0 @@
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { useOffline } from '../services/OfflineContext';
import { useTheme } from 'react-native-paper';
import DashboardScreen from '../screens/DashboardScreen';
import BookmarksScreen from '../screens/BookmarksScreen';
import TasksScreen from '../screens/TasksScreen';
import NotesScreen from '../screens/NotesScreen';
import TimeTrackingScreen from '../screens/TimeTrackingScreen';
import SearchScreen from '../screens/SearchScreen';
import SettingsScreen from '../screens/SettingsScreen';
import AIAssistantScreen from '../screens/AIAssistantScreen';
export type MainTabParamList = {
Dashboard: undefined;
Bookmarks: undefined;
Tasks: undefined;
Notes: undefined;
TimeTracking: undefined;
Search: undefined;
AIAssistant: undefined;
Settings: undefined;
};
const Tab = createBottomTabNavigator<MainTabParamList>();
const TabNavigator: React.FC = () => {
const { pendingChanges } = useOffline();
const theme = useTheme();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ color, size }) => {
let iconName: string;
switch (route.name) {
case 'Dashboard':
iconName = 'view-dashboard';
break;
case 'Bookmarks':
iconName = 'bookmark';
break;
case 'Tasks':
iconName = 'check-circle';
break;
case 'Notes':
iconName = 'note-text';
break;
case 'TimeTracking':
iconName = 'timer';
break;
case 'Search':
iconName = 'magnify';
break;
case 'AIAssistant':
iconName = 'robot';
break;
case 'Settings':
iconName = 'cog';
break;
default:
iconName = 'help-circle';
}
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: 'gray',
tabBarStyle: {
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.outline,
},
headerStyle: {
backgroundColor: theme.colors.surface,
},
headerTintColor: theme.colors.onSurface,
})}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: 'Dashboard',
tabBarBadge: pendingChanges > 0 ? pendingChanges : undefined,
}}
/>
<Tab.Screen
name="Bookmarks"
component={BookmarksScreen}
options={{ title: 'Bookmarks' }}
/>
<Tab.Screen
name="Tasks"
component={TasksScreen}
options={{ title: 'Tasks' }}
/>
<Tab.Screen
name="Notes"
component={NotesScreen}
options={{ title: 'Notes' }}
/>
<Tab.Screen
name="TimeTracking"
component={TimeTrackingScreen}
options={{ title: 'Time' }}
/>
<Tab.Screen
name="Search"
component={SearchScreen}
options={{ title: 'Search' }}
/>
<Tab.Screen
name="AIAssistant"
component={AIAssistantScreen}
options={{ title: 'AI' }}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{ title: 'Settings' }}
/>
</Tab.Navigator>
);
};
export default TabNavigator;
@@ -1,400 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
View,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import {
Text,
Title,
Paragraph,
TextInput,
Button,
Avatar,
Chip,
} from 'react-native-paper';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRealtimeUpdates } from '../services/RealtimeSyncContext';
import { useServerConfig } from '../services/ServerConfigContext';
interface Message {
id: string;
text: string;
sender: 'user' | 'ai';
timestamp: Date;
type?: 'text' | 'recommendation' | 'analysis';
}
const AIAssistantScreen: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { config } = useServerConfig();
const [suggestions] = useState([
'Help me organize my tasks',
'Suggest bookmarks for learning React',
'Analyze my productivity patterns',
'Create a study plan',
]);
useEffect(() => {
// Initialize with welcome message
setMessages([
{
id: '1',
text: "Hello! I'm your AI assistant. I can help you organize tasks, suggest bookmarks, analyze your productivity, and much more. How can I assist you today?",
sender: 'ai',
timestamp: new Date(),
type: 'text',
},
]);
}, []);
// Listen for real-time AI updates
useRealtimeUpdates((data) => {
if (data.type === 'ai_response') {
const newMessage: Message = {
id: Date.now().toString(),
text: data.response,
sender: 'ai',
timestamp: new Date(),
type: data.responseType,
};
setMessages(prev => [...prev, newMessage]);
setIsLoading(false);
}
});
const handleSendMessage = async () => {
if (!inputText.trim()) return;
const userMessage: Message = {
id: Date.now().toString(),
text: inputText,
sender: 'user',
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setInputText('');
setIsLoading(true);
try {
// Call LongCat AI API
const response = await fetch(`${config?.baseUrl}/api/ai/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAuthToken()}`,
},
body: JSON.stringify({
message: inputText,
context: 'trackeep_assistant',
}),
});
if (response.ok) {
const data = await response.json();
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
text: data.response,
sender: 'ai',
timestamp: new Date(),
type: data.type || 'text',
};
setMessages(prev => [...prev, aiResponse]);
} else {
// Fallback to mock response
const mockResponse: Message = {
id: (Date.now() + 1).toString(),
text: generateMockResponse(inputText),
sender: 'ai',
timestamp: new Date(),
type: 'text',
};
setMessages(prev => [...prev, mockResponse]);
}
} catch (error) {
console.error('Error calling AI API:', error);
// Fallback to mock response
const mockResponse: Message = {
id: (Date.now() + 1).toString(),
text: generateMockResponse(inputText),
sender: 'ai',
timestamp: new Date(),
type: 'text',
};
setMessages(prev => [...prev, mockResponse]);
} finally {
setIsLoading(false);
}
};
const getAuthToken = async (): Promise<string | null> => {
try {
const authData = await AsyncStorage.getItem('trackeep_auth_token');
return authData;
} catch (error) {
console.error('Error getting auth token:', error);
return null;
}
};
const generateMockResponse = (userInput: string): string => {
const input = userInput.toLowerCase();
if (input.includes('task') || input.includes('organize')) {
return "I can help you organize your tasks! Based on your current tasks, I suggest prioritizing the high-priority items first. Would you like me to create a schedule for you?";
} else if (input.includes('bookmark') || input.includes('learn')) {
return "Great! I can suggest relevant bookmarks for your learning goals. I see you're interested in React - here are some top resources I recommend...";
} else if (input.includes('productivity') || input.includes('analyze')) {
return "Looking at your activity patterns, you're most productive in the morning. I suggest scheduling important tasks between 9-11 AM for better results.";
} else if (input.includes('study') || input.includes('plan')) {
return "I can create a personalized study plan for you! Based on your current notes and bookmarks, here's a structured learning path...";
} else {
return "I understand you need help with that. Let me analyze your current data and provide you with personalized recommendations.";
}
};
const handleSuggestionPress = (suggestion: string) => {
setInputText(suggestion);
};
const formatTime = (date: Date): string => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const renderMessage = (message: Message) => (
<View key={message.id} style={[
styles.messageContainer,
message.sender === 'user' ? styles.userMessage : styles.aiMessage,
]}>
{message.sender === 'ai' && (
<Avatar.Text
size={32}
label="AI"
style={styles.avatar}
/>
)}
<View style={[
styles.messageBubble,
message.sender === 'user' ? styles.userBubble : styles.aiBubble,
]}>
<Text style={[
styles.messageText,
message.sender === 'user' ? styles.userText : styles.aiText,
]}>
{message.text}
</Text>
<Text style={styles.timestamp}>
{formatTime(message.timestamp)}
</Text>
</View>
{message.sender === 'user' && (
<Avatar.Text
size={32}
label="U"
style={styles.avatar}
/>
)}
</View>
);
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<View style={styles.header}>
<Title style={styles.title}>AI Assistant</Title>
<Paragraph style={styles.subtitle}>
Your personal productivity companion
</Paragraph>
</View>
<ScrollView
style={styles.messagesContainer}
contentContainerStyle={styles.messagesContent}
>
{messages.map(renderMessage)}
{isLoading && (
<View style={[styles.messageContainer, styles.aiMessage]}>
<Avatar.Text
size={32}
label="AI"
style={styles.avatar}
/>
<View style={[styles.messageBubble, styles.aiBubble]}>
<Text style={styles.aiText}>Thinking...</Text>
</View>
</View>
)}
</ScrollView>
{/* Suggestions */}
{messages.length === 1 && (
<View style={styles.suggestionsContainer}>
<Text style={styles.suggestionsTitle}>Try asking:</Text>
<View style={styles.suggestionsList}>
{suggestions.map((suggestion, index) => (
<Chip
key={index}
onPress={() => handleSuggestionPress(suggestion)}
style={styles.suggestionChip}
textStyle={styles.suggestionText}
>
{suggestion}
</Chip>
))}
</View>
</View>
)}
{/* Input Area */}
<View style={styles.inputContainer}>
<TextInput
value={inputText}
onChangeText={setInputText}
placeholder="Ask me anything..."
multiline
maxLength={500}
style={styles.textInput}
right={
<TextInput.Icon
icon="send"
onPress={handleSendMessage}
disabled={!inputText.trim() || isLoading}
/>
}
/>
<Button
mode="contained"
onPress={handleSendMessage}
disabled={!inputText.trim() || isLoading}
loading={isLoading}
style={styles.sendButton}
>
Send
</Button>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
padding: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#6200ee',
},
subtitle: {
color: '#666',
marginTop: 4,
},
messagesContainer: {
flex: 1,
},
messagesContent: {
padding: 16,
},
messageContainer: {
flexDirection: 'row',
marginBottom: 16,
alignItems: 'flex-end',
},
userMessage: {
justifyContent: 'flex-end',
},
aiMessage: {
justifyContent: 'flex-start',
},
avatar: {
marginHorizontal: 8,
backgroundColor: '#6200ee',
},
messageBubble: {
maxWidth: '70%',
padding: 12,
borderRadius: 16,
minHeight: 40,
},
userBubble: {
backgroundColor: '#6200ee',
borderBottomRightRadius: 4,
},
aiBubble: {
backgroundColor: '#fff',
borderBottomLeftRadius: 4,
borderWidth: 1,
borderColor: '#e0e0e0',
},
messageText: {
fontSize: 16,
lineHeight: 20,
},
userText: {
color: '#fff',
},
aiText: {
color: '#333',
},
timestamp: {
fontSize: 11,
color: '#999',
marginTop: 4,
alignSelf: 'flex-end',
},
suggestionsContainer: {
padding: 16,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
suggestionsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#666',
marginBottom: 8,
},
suggestionsList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
suggestionChip: {
backgroundColor: '#f0f0f0',
},
suggestionText: {
fontSize: 12,
color: '#333',
},
inputContainer: {
padding: 16,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
textInput: {
marginBottom: 8,
backgroundColor: '#f8f8f8',
},
sendButton: {
backgroundColor: '#6200ee',
},
});
export default AIAssistantScreen;
@@ -1,119 +0,0 @@
import React from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Text, Card, Title, Paragraph, FAB, Searchbar } from 'react-native-paper';
const BookmarksScreen: React.FC = () => {
const [searchQuery, setSearchQuery] = React.useState('');
const [bookmarks] = React.useState([
{
id: '1',
title: 'React Native Documentation',
url: 'https://reactnative.dev',
description: 'Official React Native documentation',
tags: ['react', 'mobile', 'documentation'],
isFavorite: true,
createdAt: new Date(),
},
{
id: '2',
title: 'TypeScript Handbook',
url: 'https://www.typescriptlang.org/docs',
description: 'Learn TypeScript from the official handbook',
tags: ['typescript', 'programming', 'tutorial'],
isFavorite: false,
createdAt: new Date(),
},
]);
const onChangeSearch = (query: string) => setSearchQuery(query);
const renderBookmark = ({ item }: any) => (
<Card style={styles.card}>
<Card.Content>
<Title numberOfLines={1}>{item.title}</Title>
<Paragraph numberOfLines={2}>{item.description}</Paragraph>
<Text style={styles.url}>{item.url}</Text>
<View style={styles.tagsContainer}>
{item.tags.map((tag: string, index: number) => (
<Text key={index} style={styles.tag}>
#{tag}
</Text>
))}
</View>
</Card.Content>
</Card>
);
return (
<View style={styles.container}>
<Searchbar
placeholder="Search bookmarks..."
onChangeText={onChangeSearch}
value={searchQuery}
style={styles.searchBar}
/>
<FlatList
data={bookmarks}
renderItem={renderBookmark}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
<FAB
icon="bookmark-plus"
style={styles.fab}
onPress={() => console.log('Add bookmark')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
searchBar: {
margin: 16,
marginBottom: 8,
},
list: {
paddingHorizontal: 16,
paddingBottom: 80,
},
card: {
marginBottom: 12,
elevation: 2,
},
url: {
color: '#6200ee',
fontSize: 12,
marginTop: 8,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
},
tag: {
backgroundColor: '#e3f2fd',
color: '#1976d2',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
fontSize: 10,
marginRight: 4,
marginBottom: 4,
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#6200ee',
},
});
export default BookmarksScreen;
@@ -1,444 +0,0 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, ScrollView, RefreshControl, Dimensions } from 'react-native';
import { Text, Card, Title, Paragraph, Button, FAB, Avatar, Chip, ProgressBar } from 'react-native-paper';
import { useAuth } from '../services/AuthContext';
import { useOffline } from '../services/OfflineContext';
import { useRealtimeSync, useRealtimeUpdates } from '../services/RealtimeSyncContext';
import { bookmarksAPI, tasksAPI, notesAPI } from '../services/api';
interface QuickStats {
totalBookmarks: number;
totalTasks: number;
totalNotes: number;
completedTasks: number;
recentActivity: number;
}
interface RecentActivity {
id: string;
type: 'bookmark' | 'task' | 'note';
action: string;
title: string;
timestamp: string;
}
const { width } = Dimensions.get('window');
const DashboardScreen: React.FC = () => {
const { user } = useAuth();
const { isOnline, pendingChanges, syncNow } = useOffline();
const { isSyncing, lastSyncTime } = useRealtimeSync();
const [stats, setStats] = useState<QuickStats>({
totalBookmarks: 0,
totalTasks: 0,
totalNotes: 0,
completedTasks: 0,
recentActivity: 0,
});
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadDashboardData();
}, []);
// Listen for real-time updates
useRealtimeUpdates((data) => {
console.log('Dashboard received real-time update:', data);
loadDashboardData();
});
const loadDashboardData = async () => {
try {
const [bookmarksRes, tasksRes, notesRes] = await Promise.all([
bookmarksAPI.getBookmarks(),
tasksAPI.getTasks(),
notesAPI.getNotes(),
]);
if (bookmarksRes.success && tasksRes.success && notesRes.success) {
const bookmarks = bookmarksRes.data || [];
const tasks = tasksRes.data || [];
const notes = notesRes.data || [];
const completedTasks = tasks.filter(task => (task as any).completed).length;
setStats({
totalBookmarks: bookmarks.length,
totalTasks: tasks.length,
totalNotes: notes.length,
completedTasks,
recentActivity: 5, // Mock recent activity count
});
// Generate mock recent activity
const activity: RecentActivity[] = [
{
id: '1',
type: 'bookmark',
action: 'Added',
title: bookmarks[0]?.title || 'New bookmark',
timestamp: '2 hours ago',
},
{
id: '2',
type: 'task',
action: 'Completed',
title: tasks[0]?.title || 'New task',
timestamp: '3 hours ago',
},
{
id: '3',
type: 'note',
action: 'Created',
title: notes[0]?.title || 'New note',
timestamp: '5 hours ago',
},
];
setRecentActivity(activity);
}
} catch (error) {
console.error('Error loading dashboard data:', error);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadDashboardData();
if (isOnline && pendingChanges > 0) {
await syncNow();
}
setRefreshing(false);
};
const getTaskCompletionPercentage = () => {
if (stats.totalTasks === 0) return 0;
return Math.round((stats.completedTasks / stats.totalTasks) * 100);
};
const formatLastSync = () => {
if (!lastSyncTime) return 'Never';
const now = Date.now();
const diff = now - lastSyncTime;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
return (
<View style={styles.container}>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{/* Header Section */}
<View style={styles.header}>
<View style={styles.userSection}>
<Avatar.Text
size={60}
label={user?.name?.charAt(0).toUpperCase() || 'U'}
style={styles.avatar}
/>
<View style={styles.userInfo}>
<Title style={styles.welcomeText}>
Welcome back, {user?.name || 'User'}!
</Title>
<Paragraph style={styles.subtitle}>
{isOnline ? '🟢 Connected' : '🔴 Offline'}
{isSyncing ? ' Syncing...' : ` Last sync: ${formatLastSync()}`}
</Paragraph>
</View>
</View>
</View>
{/* Quick Stats Cards */}
<View style={styles.statsGrid}>
<Card style={[styles.statCard, { backgroundColor: '#e3f2fd' }]}>
<Card.Content style={styles.statContent}>
<Text style={[styles.statNumber, { color: '#1976d2' }]}>
{stats.totalBookmarks}
</Text>
<Text style={styles.statLabel}>Bookmarks</Text>
</Card.Content>
</Card>
<Card style={[styles.statCard, { backgroundColor: '#e8f5e8' }]}>
<Card.Content style={styles.statContent}>
<Text style={[styles.statNumber, { color: '#388e3c' }]}>
{stats.totalTasks}
</Text>
<Text style={styles.statLabel}>Tasks</Text>
</Card.Content>
</Card>
<Card style={[styles.statCard, { backgroundColor: '#fff3e0' }]}>
<Card.Content style={styles.statContent}>
<Text style={[styles.statNumber, { color: '#f57c00' }]}>
{stats.totalNotes}
</Text>
<Text style={styles.statLabel}>Notes</Text>
</Card.Content>
</Card>
</View>
{/* Task Progress */}
<Card style={styles.card}>
<Card.Content>
<Title style={styles.cardTitle}>Task Progress</Title>
<View style={styles.progressContainer}>
<Text style={styles.progressText}>
{stats.completedTasks} of {stats.totalTasks} tasks completed
</Text>
<ProgressBar
progress={getTaskCompletionPercentage() / 100}
color="#4caf50"
style={styles.progressBar}
/>
<Text style={styles.progressPercentage}>
{getTaskCompletionPercentage()}%
</Text>
</View>
</Card.Content>
</Card>
{/* Recent Activity */}
<Card style={styles.card}>
<Card.Content>
<Title style={styles.cardTitle}>Recent Activity</Title>
{recentActivity.length > 0 ? (
recentActivity.map((activity) => (
<View key={activity.id} style={styles.activityItem}>
<View style={styles.activityIcon}>
<Text style={styles.activityEmoji}>
{activity.type === 'bookmark' ? '🔖' :
activity.type === 'task' ? '✅' : '📝'}
</Text>
</View>
<View style={styles.activityContent}>
<Text style={styles.activityTitle}>
{activity.action} {activity.title}
</Text>
<Text style={styles.activityTime}>
{activity.timestamp}
</Text>
</View>
</View>
))
) : (
<Paragraph style={styles.emptyText}>No recent activity</Paragraph>
)}
</Card.Content>
</Card>
{/* Sync Status */}
{!isOnline && pendingChanges > 0 && (
<Card style={[styles.card, styles.offlineCard]}>
<Card.Content>
<Title style={styles.cardTitle}>Offline Mode</Title>
<Paragraph>
You have {pendingChanges} changes pending sync
</Paragraph>
<Button
mode="outlined"
onPress={syncNow}
style={styles.syncButton}
disabled={!isOnline || isSyncing}
loading={isSyncing}
>
{isSyncing ? 'Syncing...' : 'Sync Now'}
</Button>
</Card.Content>
</Card>
)}
{/* Quick Actions */}
<Card style={styles.card}>
<Card.Content>
<Title style={styles.cardTitle}>Quick Actions</Title>
<View style={styles.quickActions}>
<Chip
icon="bookmark-plus"
onPress={() => console.log('Add bookmark')}
style={styles.actionChip}
>
Add Bookmark
</Chip>
<Chip
icon="plus"
onPress={() => console.log('Add task')}
style={styles.actionChip}
>
Add Task
</Chip>
<Chip
icon="note-plus"
onPress={() => console.log('Add note')}
style={styles.actionChip}
>
Add Note
</Chip>
</View>
</Card.Content>
</Card>
</ScrollView>
<FAB
icon="plus"
style={styles.fab}
onPress={() => console.log('Add new item')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollView: {
flex: 1,
padding: 16,
},
header: {
marginBottom: 24,
},
userSection: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
avatar: {
marginRight: 16,
backgroundColor: '#6200ee',
},
userInfo: {
flex: 1,
},
welcomeText: {
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
color: '#666',
marginTop: 4,
fontSize: 14,
},
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
},
statCard: {
width: (width - 48) / 3,
elevation: 2,
},
statContent: {
alignItems: 'center',
paddingVertical: 16,
},
statNumber: {
fontSize: 24,
fontWeight: 'bold',
},
statLabel: {
fontSize: 12,
color: '#666',
marginTop: 4,
textAlign: 'center',
},
card: {
marginBottom: 16,
elevation: 2,
},
cardTitle: {
fontSize: 18,
marginBottom: 12,
color: '#333',
},
progressContainer: {
marginTop: 8,
},
progressText: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
progressBar: {
height: 8,
borderRadius: 4,
marginBottom: 8,
},
progressPercentage: {
fontSize: 16,
fontWeight: 'bold',
color: '#4caf50',
textAlign: 'center',
},
activityItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
activityIcon: {
marginRight: 12,
},
activityEmoji: {
fontSize: 20,
},
activityContent: {
flex: 1,
},
activityTitle: {
fontSize: 14,
fontWeight: '500',
color: '#333',
},
activityTime: {
fontSize: 12,
color: '#666',
marginTop: 2,
},
emptyText: {
textAlign: 'center',
color: '#666',
fontStyle: 'italic',
},
offlineCard: {
backgroundColor: '#fff3cd',
borderColor: '#ffeaa7',
borderWidth: 1,
},
syncButton: {
marginTop: 12,
},
quickActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
actionChip: {
marginBottom: 8,
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#6200ee',
},
});
export default DashboardScreen;
@@ -1,28 +0,0 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { ActivityIndicator, Text } from 'react-native-paper';
const LoadingScreen: React.FC = () => {
return (
<View style={styles.container}>
<ActivityIndicator size="large" />
<Text style={styles.text}>Loading Trackeep...</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
text: {
marginTop: 16,
fontSize: 16,
color: '#666',
},
});
export default LoadingScreen;
@@ -1,104 +0,0 @@
import React from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Text, Card, Title, Paragraph, FAB } from 'react-native-paper';
const NotesScreen: React.FC = () => {
const [notes] = React.useState([
{
id: '1',
title: 'Mobile App Architecture',
content: 'React Native with TypeScript, navigation, offline support...',
tags: ['architecture', 'mobile', 'react-native'],
createdAt: new Date(),
},
{
id: '2',
title: 'Meeting Notes - Product Review',
content: 'Discussed new features, timeline, and user feedback...',
tags: ['meeting', 'product', 'review'],
createdAt: new Date(),
},
]);
const renderNote = ({ item }: any) => (
<Card style={styles.card}>
<Card.Content>
<Title numberOfLines={1}>{item.title}</Title>
<Paragraph numberOfLines={3}>{item.content}</Paragraph>
<View style={styles.tagsContainer}>
{item.tags.map((tag: string, index: number) => (
<Text key={index} style={styles.tag}>
#{tag}
</Text>
))}
</View>
<Text style={styles.date}>
{item.createdAt.toLocaleDateString()}
</Text>
</Card.Content>
</Card>
);
return (
<View style={styles.container}>
<FlatList
data={notes}
renderItem={renderNote}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
<FAB
icon="plus"
style={styles.fab}
onPress={() => console.log('Add note')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 16,
paddingBottom: 80,
},
card: {
marginBottom: 12,
elevation: 2,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
},
tag: {
backgroundColor: '#e8f5e8',
color: '#2e7d32',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
fontSize: 10,
marginRight: 4,
marginBottom: 4,
},
date: {
fontSize: 10,
color: '#666',
marginTop: 8,
textAlign: 'right',
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#6200ee',
},
});
export default NotesScreen;
@@ -1,213 +0,0 @@
import React, { useState } from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Text, Card, Title, Paragraph, Searchbar, Chip } from 'react-native-paper';
const SearchScreen: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState('all');
const filters = [
{ id: 'all', label: 'All' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'notes', label: 'Notes' },
];
const searchResults = [
{
id: '1',
type: 'bookmark',
title: 'React Native Documentation',
description: 'Official React Native documentation and guides',
url: 'https://reactnative.dev',
},
{
id: '2',
type: 'task',
title: 'Complete mobile app setup',
description: 'Finish React Native project structure and navigation',
status: 'in_progress',
},
{
id: '3',
type: 'note',
title: 'Mobile App Architecture',
content: 'React Native with TypeScript, navigation patterns...',
tags: ['architecture', 'mobile'],
},
];
const onChangeSearch = (query: string) => setSearchQuery(query);
const renderResult = ({ item }: any) => {
const getTypeIcon = (type: string) => {
switch (type) {
case 'bookmark': return '🔖';
case 'task': return '✅';
case 'note': return '📝';
default: return '📄';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'bookmark': return '#1976d2';
case 'task': return '#f44336';
case 'note': return '#4caf50';
default: return '#666';
}
};
return (
<Card style={styles.resultCard}>
<Card.Content>
<View style={styles.resultHeader}>
<Text style={styles.typeIcon}>{getTypeIcon(item.type)}</Text>
<Text style={[styles.typeLabel, { color: getTypeColor(item.type) }]}>
{item.type.charAt(0).toUpperCase() + item.type.slice(1)}
</Text>
</View>
<Title numberOfLines={1} style={styles.resultTitle}>
{item.title}
</Title>
<Paragraph numberOfLines={2} style={styles.resultDescription}>
{item.description || item.content}
</Paragraph>
{item.url && (
<Text style={styles.resultUrl} numberOfLines={1}>
{item.url}
</Text>
)}
{item.tags && (
<View style={styles.tagsContainer}>
{item.tags.map((tag: string, index: number) => (
<Chip key={index} style={styles.tag}>
{tag}
</Chip>
))}
</View>
)}
</Card.Content>
</Card>
);
};
return (
<View style={styles.container}>
<Searchbar
placeholder="Search everything..."
onChangeText={onChangeSearch}
value={searchQuery}
style={styles.searchBar}
/>
<View style={styles.filtersContainer}>
{filters.map(filter => (
<Chip
key={filter.id}
selected={selectedFilter === filter.id}
onPress={() => setSelectedFilter(filter.id)}
style={styles.filterChip}
>
{filter.label}
</Chip>
))}
</View>
<FlatList
data={searchResults}
renderItem={renderResult}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.resultsList}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchQuery ? 'No results found' : 'Start typing to search'}
</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
searchBar: {
margin: 16,
marginBottom: 8,
},
filtersContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingBottom: 8,
},
filterChip: {
marginRight: 8,
},
resultsList: {
paddingHorizontal: 16,
paddingBottom: 16,
},
resultCard: {
marginBottom: 12,
elevation: 2,
},
resultHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
typeIcon: {
fontSize: 16,
marginRight: 8,
},
typeLabel: {
fontSize: 12,
fontWeight: 'bold',
textTransform: 'uppercase',
},
resultTitle: {
fontSize: 16,
marginBottom: 4,
},
resultDescription: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
resultUrl: {
fontSize: 12,
color: '#1976d2',
marginBottom: 8,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
tag: {
marginRight: 4,
marginBottom: 4,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyText: {
fontSize: 16,
color: '#666',
textAlign: 'center',
},
});
export default SearchScreen;
@@ -1,321 +0,0 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import {
Card,
Title,
Paragraph,
TextInput,
Button,
HelperText,
} from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useServerConfig } from '../services/ServerConfigContext';
import { updateAPIBaseURL } from '../services/api';
interface ServerConfig {
baseUrl: string;
username: string;
password: string;
}
const ServerSetupScreen: React.FC = () => {
const [config, setConfig] = useState<ServerConfig>({
baseUrl: '',
username: '',
password: '',
});
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Partial<ServerConfig>>({});
const { setConfig: saveConfig } = useServerConfig();
const validateConfig = (): boolean => {
const newErrors: Partial<ServerConfig> = {};
if (!config.baseUrl.trim()) {
newErrors.baseUrl = 'Server URL is required';
} else if (!isValidUrl(config.baseUrl)) {
newErrors.baseUrl = 'Please enter a valid URL (e.g., https://your-server.com)';
}
if (!config.username.trim()) {
newErrors.username = 'Username is required';
}
if (!config.password.trim()) {
newErrors.password = 'Password is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const isValidUrl = (url: string): boolean => {
try {
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
};
const testConnection = async (): Promise<boolean> => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(`${config.baseUrl}/api/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok;
} catch (error) {
console.error('Connection test failed:', error);
return false;
}
};
const handleTestConnection = async () => {
if (!config.baseUrl.trim()) {
Alert.alert('Error', 'Please enter a server URL first');
return;
}
setIsLoading(true);
try {
const isConnected = await testConnection();
if (isConnected) {
Alert.alert('Success', 'Connection to server successful!');
} else {
Alert.alert(
'Connection Failed',
'Could not connect to the server. Please check the URL and ensure the server is running.'
);
}
} catch (error) {
Alert.alert('Error', 'Failed to test connection. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleSetup = async () => {
if (!validateConfig()) {
return;
}
setIsLoading(true);
try {
const isConnected = await testConnection();
if (!isConnected) {
Alert.alert(
'Connection Failed',
'Could not connect to the server. Please check the URL and try again.'
);
return;
}
// Test authentication
const authResponse = await fetch(`${config.baseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: config.username,
password: config.password,
}),
});
if (authResponse.ok) {
const authData = await authResponse.json();
if (authData.token) {
await saveConfig(config);
updateAPIBaseURL(`${config.baseUrl}/api`);
Alert.alert('Success', 'Server configuration completed successfully!');
// Navigation will be handled automatically by the AppNavigator
} else {
Alert.alert('Authentication Failed', 'Invalid username or password.');
}
} else {
Alert.alert('Authentication Failed', 'Invalid username or password.');
}
} catch (error) {
Alert.alert('Setup Failed', 'An error occurred during setup. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
>
<View style={styles.content}>
<Card style={styles.card}>
<Card.Content>
<Title style={styles.title}>Welcome to Trackeep</Title>
<Paragraph style={styles.subtitle}>
Connect to your Trackeep server to get started
</Paragraph>
</Card.Content>
</Card>
<Card style={styles.card}>
<Card.Content>
<Title style={styles.cardTitle}>Server Configuration</Title>
<TextInput
label="Server URL"
value={config.baseUrl}
onChangeText={(text) => setConfig({ ...config, baseUrl: text })}
placeholder="https://your-server.com"
autoCapitalize="none"
keyboardType="url"
style={styles.input}
error={!!errors.baseUrl}
/>
<HelperText type="error" visible={!!errors.baseUrl}>
{errors.baseUrl}
</HelperText>
<TextInput
label="Username"
value={config.username}
onChangeText={(text) => setConfig({ ...config, username: text })}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
error={!!errors.username}
/>
<HelperText type="error" visible={!!errors.username}>
{errors.username}
</HelperText>
<TextInput
label="Password"
value={config.password}
onChangeText={(text) => setConfig({ ...config, password: text })}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
error={!!errors.password}
/>
<HelperText type="error" visible={!!errors.password}>
{errors.password}
</HelperText>
<Button
mode="outlined"
onPress={handleTestConnection}
disabled={isLoading || !config.baseUrl.trim()}
style={styles.testButton}
loading={isLoading}
>
Test Connection
</Button>
</Card.Content>
</Card>
<Card style={styles.infoCard}>
<Card.Content>
<Title style={styles.cardTitle}>Need Help?</Title>
<Paragraph style={styles.infoText}>
Enter the full URL of your Trackeep server
</Paragraph>
<Paragraph style={styles.infoText}>
Use your existing Trackeep account credentials
</Paragraph>
<Paragraph style={styles.infoText}>
Make sure your server is accessible from this device
</Paragraph>
</Card.Content>
</Card>
<Button
mode="contained"
onPress={handleSetup}
disabled={isLoading}
loading={isLoading}
style={styles.setupButton}
contentStyle={styles.setupButtonContent}
>
Complete Setup
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
keyboardAvoidingView: {
flex: 1,
},
content: {
flex: 1,
padding: 16,
justifyContent: 'center',
},
card: {
marginBottom: 16,
elevation: 2,
},
infoCard: {
marginBottom: 24,
backgroundColor: '#e3f2fd',
},
title: {
textAlign: 'center',
fontSize: 24,
fontWeight: 'bold',
color: '#6200ee',
},
subtitle: {
textAlign: 'center',
marginTop: 8,
color: '#666',
},
cardTitle: {
fontSize: 18,
marginBottom: 16,
color: '#333',
},
input: {
marginBottom: 8,
},
testButton: {
marginTop: 8,
},
infoText: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
setupButton: {
backgroundColor: '#6200ee',
},
setupButtonContent: {
paddingVertical: 8,
},
});
export default ServerSetupScreen;
@@ -1,324 +0,0 @@
import React from 'react';
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
import { List, Switch, Text, Card, Title, Button } from 'react-native-paper';
import { useAuth } from '../services/AuthContext';
import { useOffline } from '../services/OfflineContext';
import { useNotifications } from '../services/NotificationContext';
import { useCamera } from '../services/CameraContext';
import { useVoice } from '../services/VoiceContext';
const SettingsScreen: React.FC = () => {
const { user, logout } = useAuth();
const { isOnline, syncNow } = useOffline();
const { hasPermission: hasNotificationPermission, requestPermission: requestNotificationPermission } = useNotifications();
const { hasPermission: hasCameraPermission, requestPermission: requestCameraPermission, scanDocument } = useCamera();
const { hasPermission: hasVoicePermission, requestPermission: requestVoicePermission, isRecording, startRecording, stopRecording } = useVoice();
const [notifications, setNotifications] = React.useState(true);
const [darkMode, setDarkMode] = React.useState(false);
const [autoSync, setAutoSync] = React.useState(true);
const handleLogout = async () => {
await logout();
};
const handleNotificationPermission = async () => {
if (!hasNotificationPermission) {
const granted = await requestNotificationPermission();
if (granted) {
Alert.alert('Success', 'Notification permission granted!');
} else {
Alert.alert('Permission Denied', 'Notification permission is required for reminders');
}
}
};
const handleCameraPermission = async () => {
if (!hasCameraPermission) {
const granted = await requestCameraPermission();
if (granted) {
Alert.alert('Success', 'Camera permission granted!');
} else {
Alert.alert('Permission Denied', 'Camera permission is required for document scanning');
}
}
};
const handleVoicePermission = async () => {
if (!hasVoicePermission) {
const granted = await requestVoicePermission();
if (granted) {
Alert.alert('Success', 'Microphone permission granted!');
} else {
Alert.alert('Permission Denied', 'Microphone permission is required for voice recording');
}
}
};
const handleTestNotification = () => {
// This would use the notification service to show a test notification
Alert.alert('Test Notification', 'This is a test notification!');
};
const handleTestCamera = async () => {
try {
const result = await scanDocument();
if (result) {
Alert.alert('Success', 'Document scanned successfully!');
}
} catch (error) {
Alert.alert('Error', 'Failed to scan document');
}
};
const handleTestVoice = async () => {
if (isRecording) {
const recording = await stopRecording();
if (recording) {
Alert.alert('Success', `Voice recorded! Duration: ${recording.duration}s`);
}
} else {
startRecording();
Alert.alert('Recording', 'Voice recording started...');
}
};
return (
<View style={styles.container}>
<ScrollView style={styles.scrollView}>
<Card style={styles.card}>
<Card.Content>
<Title>Account</Title>
<Text style={styles.userInfo}>
{user?.name} ({user?.email})
</Text>
<Button
mode="outlined"
onPress={handleLogout}
style={styles.logoutButton}
>
Sign Out
</Button>
</Card.Content>
</Card>
<Card style={styles.card}>
<Card.Content>
<Title>Preferences</Title>
<List.Item
title="Push Notifications"
description="Receive notifications for tasks and reminders"
right={() => (
<Switch
value={notifications}
onValueChange={setNotifications}
/>
)}
/>
<List.Item
title="Dark Mode"
description="Use dark theme"
right={() => (
<Switch
value={darkMode}
onValueChange={setDarkMode}
/>
)}
/>
<List.Item
title="Auto Sync"
description="Automatically sync when online"
right={() => (
<Switch
value={autoSync}
onValueChange={setAutoSync}
/>
)}
/>
</Card.Content>
</Card>
<Card style={styles.card}>
<Card.Content>
<Title>📱 Mobile Features</Title>
<List.Item
title="Push Notifications"
description={hasNotificationPermission ? "Permission granted" : "Permission required"}
left={() => <Text style={styles.featureIcon}>🔔</Text>}
right={() => (
<View style={styles.featureActions}>
{!hasNotificationPermission && (
<Button
mode="outlined"
onPress={handleNotificationPermission}
compact
>
Enable
</Button>
)}
{hasNotificationPermission && (
<Button
mode="text"
onPress={handleTestNotification}
compact
>
Test
</Button>
)}
</View>
)}
/>
<List.Item
title="Camera & Document Scanning"
description={hasCameraPermission ? "Permission granted" : "Permission required"}
left={() => <Text style={styles.featureIcon}>📸</Text>}
right={() => (
<View style={styles.featureActions}>
{!hasCameraPermission && (
<Button
mode="outlined"
onPress={handleCameraPermission}
compact
>
Enable
</Button>
)}
{hasCameraPermission && (
<Button
mode="text"
onPress={handleTestCamera}
compact
>
Test
</Button>
)}
</View>
)}
/>
<List.Item
title="Voice Recording"
description={hasVoicePermission ? "Permission granted" : "Permission required"}
left={() => <Text style={styles.featureIcon}>🎤</Text>}
right={() => (
<View style={styles.featureActions}>
{!hasVoicePermission && (
<Button
mode="outlined"
onPress={handleVoicePermission}
compact
>
Enable
</Button>
)}
{hasVoicePermission && (
<Button
mode={isRecording ? "contained" : "text"}
onPress={handleTestVoice}
compact
>
{isRecording ? "Stop" : "Test"}
</Button>
)}
</View>
)}
/>
</Card.Content>
</Card>
<Card style={styles.card}>
<Card.Content>
<Title>Sync Status</Title>
<List.Item
title="Connection"
description={isOnline ? 'Connected' : 'Offline'}
left={() => (
<Text style={styles.statusIcon}>
{isOnline ? '🟢' : '🔴'}
</Text>
)}
/>
<Button
mode="outlined"
onPress={syncNow}
disabled={!isOnline}
style={styles.syncButton}
>
Sync Now
</Button>
</Card.Content>
</Card>
<Card style={styles.card}>
<Card.Content>
<Title>About</Title>
<List.Item
title="Version"
description="1.0.0"
/>
<List.Item
title="Build"
description="React Native Mobile App"
/>
<List.Item
title="GitHub"
description="View source code"
onPress={() => console.log('Open GitHub')}
/>
</Card.Content>
</Card>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollView: {
flex: 1,
},
card: {
margin: 16,
elevation: 2,
},
userInfo: {
fontSize: 16,
marginBottom: 16,
color: '#666',
},
logoutButton: {
marginTop: 8,
},
statusIcon: {
fontSize: 16,
width: 24,
textAlign: 'center',
},
syncButton: {
marginTop: 8,
},
featureIcon: {
fontSize: 16,
width: 24,
textAlign: 'center',
},
featureActions: {
flexDirection: 'row',
alignItems: 'center',
},
});
export default SettingsScreen;
@@ -1,132 +0,0 @@
import React from 'react';
import { View, StyleSheet, FlatList } from 'react-native';
import { Text, Card, Title, Paragraph, FAB, Checkbox } from 'react-native-paper';
const TasksScreen: React.FC = () => {
const [tasks, setTasks] = React.useState([
{
id: '1',
title: 'Complete mobile app setup',
description: 'Finish React Native project structure',
status: 'in_progress' as const,
priority: 'high' as const,
completed: false,
},
{
id: '2',
title: 'Review pull requests',
description: 'Check and merge pending PRs',
status: 'todo' as const,
priority: 'medium' as const,
completed: false,
},
]);
const toggleTask = (taskId: string) => {
setTasks(prev =>
prev.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
)
);
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return '#f44336';
case 'medium': return '#ff9800';
case 'low': return '#4caf50';
default: return '#666';
}
};
const renderTask = ({ item }: any) => (
<Card style={styles.card}>
<Card.Content>
<View style={styles.taskHeader}>
<Checkbox
status={item.completed ? 'checked' : 'unchecked'}
onPress={() => toggleTask(item.id)}
/>
<View style={styles.taskContent}>
<Title style={[styles.taskTitle, item.completed && styles.completedTitle]}>
{item.title}
</Title>
<Paragraph style={styles.taskDescription}>
{item.description}
</Paragraph>
<Text style={[styles.priority, { color: getPriorityColor(item.priority) }]}>
{item.priority.toUpperCase()}
</Text>
</View>
</View>
</Card.Content>
</Card>
);
return (
<View style={styles.container}>
<FlatList
data={tasks}
renderItem={renderTask}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
<FAB
icon="plus"
style={styles.fab}
onPress={() => console.log('Add task')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
list: {
padding: 16,
paddingBottom: 80,
},
card: {
marginBottom: 12,
elevation: 2,
},
taskHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
},
taskContent: {
flex: 1,
marginLeft: 12,
},
taskTitle: {
fontSize: 16,
},
completedTitle: {
textDecorationLine: 'line-through',
color: '#666',
},
taskDescription: {
marginTop: 4,
fontSize: 14,
},
priority: {
fontSize: 10,
fontWeight: 'bold',
marginTop: 8,
textTransform: 'uppercase',
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#6200ee',
},
});
export default TasksScreen;
@@ -1,194 +0,0 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, Card, Title, Paragraph, Button, FAB } from 'react-native-paper';
const TimeTrackingScreen: React.FC = () => {
const [isTimerRunning, setIsTimerRunning] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const [currentTask, setCurrentTask] = useState('');
useEffect(() => {
let interval: NodeJS.Timeout;
if (isTimerRunning) {
interval = setInterval(() => {
setElapsedTime(prev => prev + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [isTimerRunning]);
const formatTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const toggleTimer = () => {
setIsTimerRunning(!isTimerRunning);
};
const resetTimer = () => {
setIsTimerRunning(false);
setElapsedTime(0);
setCurrentTask('');
};
const timeEntries = [
{
id: '1',
description: 'Mobile app development',
duration: '2:30:00',
date: 'Today',
},
{
id: '2',
description: 'Code review',
duration: '0:45:00',
date: 'Yesterday',
},
];
return (
<View style={styles.container}>
<Card style={styles.timerCard}>
<Card.Content>
<Title style={styles.timerTitle}>Time Tracker</Title>
<Text style={styles.timeDisplay}>{formatTime(elapsedTime)}</Text>
{currentTask ? (
<Paragraph style={styles.currentTask}>
Working on: {currentTask}
</Paragraph>
) : (
<Paragraph style={styles.noTask}>
No task selected
</Paragraph>
)}
<View style={styles.timerButtons}>
<Button
mode={isTimerRunning ? 'outlined' : 'contained'}
onPress={toggleTimer}
style={styles.timerButton}
>
{isTimerRunning ? 'Pause' : 'Start'}
</Button>
<Button
mode="outlined"
onPress={resetTimer}
style={styles.timerButton}
>
Reset
</Button>
</View>
</Card.Content>
</Card>
<Card style={styles.entriesCard}>
<Card.Content>
<Title>Recent Entries</Title>
{timeEntries.map(entry => (
<View key={entry.id} style={styles.entryItem}>
<View style={styles.entryContent}>
<Text style={styles.entryDescription}>
{entry.description}
</Text>
<Text style={styles.entryDuration}>
{entry.duration}
</Text>
</View>
<Text style={styles.entryDate}>{entry.date}</Text>
</View>
))}
</Card.Content>
</Card>
<FAB
icon="plus"
style={styles.fab}
onPress={() => console.log('Add time entry')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 16,
},
timerCard: {
marginBottom: 16,
elevation: 4,
},
timerTitle: {
textAlign: 'center',
marginBottom: 16,
},
timeDisplay: {
fontSize: 48,
fontWeight: 'bold',
textAlign: 'center',
color: '#6200ee',
marginBottom: 16,
},
currentTask: {
textAlign: 'center',
color: '#666',
marginBottom: 16,
},
noTask: {
textAlign: 'center',
color: '#999',
fontStyle: 'italic',
marginBottom: 16,
},
timerButtons: {
flexDirection: 'row',
justifyContent: 'space-around',
},
timerButton: {
flex: 1,
marginHorizontal: 8,
},
entriesCard: {
elevation: 2,
},
entryItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
entryContent: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
entryDescription: {
flex: 1,
fontSize: 16,
},
entryDuration: {
fontSize: 16,
fontWeight: 'bold',
color: '#6200ee',
},
entryDate: {
fontSize: 12,
color: '#666',
marginTop: 4,
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
backgroundColor: '#6200ee',
},
});
export default TimeTrackingScreen;
@@ -1,190 +0,0 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import {
TextInput,
Button,
Text,
Card,
Title,
Paragraph,
} from 'react-native-paper';
import { useAuth } from '../../services/AuthContext';
const LoginScreen: React.FC = ({ navigation }: any) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { login, loginWithGitHub } = useAuth();
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
setLoading(true);
try {
const success = await login(email, password);
if (!success) {
Alert.alert('Error', 'Invalid email or password');
}
} catch (error) {
Alert.alert('Error', 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
const handleGitHubLogin = async () => {
setLoading(true);
try {
const success = await loginWithGitHub();
if (!success) {
Alert.alert('Error', 'GitHub login failed');
}
} catch (error) {
Alert.alert('Error', 'GitHub login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
<Card style={styles.card}>
<Card.Content>
<Title style={styles.title}>Welcome to Trackeep</Title>
<Paragraph style={styles.subtitle}>
Your productivity and knowledge management companion
</Paragraph>
<TextInput
label="Email"
value={email}
onChangeText={setEmail}
mode="outlined"
keyboardType="email-address"
autoCapitalize="none"
style={styles.input}
disabled={loading}
/>
<TextInput
label="Password"
value={password}
onChangeText={setPassword}
mode="outlined"
secureTextEntry={!showPassword}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
style={styles.input}
disabled={loading}
/>
<Button
mode="contained"
onPress={handleLogin}
loading={loading}
disabled={loading}
style={styles.button}
>
Sign In
</Button>
<View style={styles.divider}>
<Text style={styles.dividerText}>OR</Text>
</View>
<Button
mode="outlined"
onPress={handleGitHubLogin}
loading={loading}
disabled={loading}
style={styles.githubButton}
icon="github"
>
Continue with GitHub
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
style={styles.linkButton}
>
Don't have an account? Sign Up
</Button>
</Card.Content>
</Card>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
card: {
elevation: 4,
borderRadius: 12,
},
title: {
textAlign: 'center',
marginBottom: 8,
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
textAlign: 'center',
marginBottom: 24,
color: '#666',
},
input: {
marginBottom: 16,
},
button: {
marginBottom: 16,
paddingVertical: 8,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 16,
},
dividerText: {
flex: 1,
textAlign: 'center',
color: '#666',
fontSize: 12,
},
githubButton: {
marginBottom: 16,
paddingVertical: 8,
},
linkButton: {
marginTop: 8,
},
});
export default LoginScreen;
@@ -1,191 +0,0 @@
import React, { useState } from 'react';
import {
View,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import {
TextInput,
Button,
Card,
Title,
Paragraph,
} from 'react-native-paper';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { AuthStackParamList } from '../../navigation/AuthNavigator';
type RegisterScreenNavigationProp = NativeStackNavigationProp<
AuthStackParamList,
'Register'
>;
interface Props {
navigation: RegisterScreenNavigationProp;
}
const RegisterScreen: React.FC<Props> = ({ navigation }) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleRegister = async () => {
if (!name || !email || !password || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match');
return;
}
if (password.length < 6) {
Alert.alert('Error', 'Password must be at least 6 characters');
return;
}
setLoading(true);
try {
Alert.alert('Success', 'Registration successful! Please sign in.');
navigation.navigate('Login');
} catch (error) {
Alert.alert('Error', 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
<Card style={styles.card}>
<Card.Content>
<Title style={styles.title}>Create Account</Title>
<Paragraph style={styles.subtitle}>
Join Trackeep and boost your productivity
</Paragraph>
<TextInput
label="Full Name"
value={name}
onChangeText={setName}
mode="outlined"
autoCapitalize="words"
style={styles.input}
disabled={loading}
/>
<TextInput
label="Email"
value={email}
onChangeText={setEmail}
mode="outlined"
keyboardType="email-address"
autoCapitalize="none"
style={styles.input}
disabled={loading}
/>
<TextInput
label="Password"
value={password}
onChangeText={setPassword}
mode="outlined"
secureTextEntry={!showPassword}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
style={styles.input}
disabled={loading}
/>
<TextInput
label="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
mode="outlined"
secureTextEntry={!showConfirmPassword}
right={
<TextInput.Icon
icon={showConfirmPassword ? 'eye-off' : 'eye'}
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
/>
}
style={styles.input}
disabled={loading}
/>
<Button
mode="contained"
onPress={handleRegister}
loading={loading}
disabled={loading}
style={styles.button}
>
Sign Up
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Login')}
style={styles.linkButton}
>
Already have an account? Sign In
</Button>
</Card.Content>
</Card>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
card: {
elevation: 4,
borderRadius: 12,
},
title: {
textAlign: 'center',
marginBottom: 8,
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
textAlign: 'center',
marginBottom: 24,
color: '#666',
},
input: {
marginBottom: 16,
},
button: {
marginBottom: 16,
paddingVertical: 8,
},
linkButton: {
marginTop: 8,
},
});
export default RegisterScreen;
@@ -1,197 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, NavigationState } from '../types';
import { authAPI } from './api';
import { storeAuthData, getStoredAuthData, clearAuthData } from '../utils/storage';
interface AuthContextType extends NavigationState {
login: (email: string, password: string) => Promise<boolean>;
loginWithGitHub: () => Promise<boolean>;
logout: () => Promise<void>;
updateUser: (user: Partial<User>) => Promise<boolean>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [state, setState] = useState<NavigationState>({
isAuthenticated: false,
isLoading: true,
user: undefined,
});
useEffect(() => {
initializeAuth();
}, []);
const initializeAuth = async () => {
try {
const storedAuth = await getStoredAuthData();
if (storedAuth && storedAuth.token) {
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
if (userResponse.success && userResponse.data) {
setState({
isAuthenticated: true,
isLoading: false,
user: userResponse.data,
});
} else {
await clearAuthData();
setState({
isAuthenticated: false,
isLoading: false,
user: undefined,
});
}
} else {
setState({
isAuthenticated: false,
isLoading: false,
user: undefined,
});
}
} catch (error) {
console.error('Auth initialization error:', error);
await clearAuthData();
setState({
isAuthenticated: false,
isLoading: false,
user: undefined,
});
}
};
const login = async (email: string, password: string): Promise<boolean> => {
try {
setState(prev => ({ ...prev, isLoading: true }));
const response = await authAPI.login(email, password);
if (response.success && response.data) {
await storeAuthData({
token: response.data.token,
user: response.data.user,
});
setState({
isAuthenticated: true,
isLoading: false,
user: response.data.user,
});
return true;
}
setState(prev => ({ ...prev, isLoading: false }));
return false;
} catch (error) {
console.error('Login error:', error);
setState(prev => ({ ...prev, isLoading: false }));
return false;
}
};
const loginWithGitHub = async (): Promise<boolean> => {
try {
setState(prev => ({ ...prev, isLoading: true }));
const response = await authAPI.loginWithGitHub();
if (response.success && response.data) {
await storeAuthData({
token: response.data.token,
user: response.data.user,
});
setState({
isAuthenticated: true,
isLoading: false,
user: response.data.user,
});
return true;
}
setState(prev => ({ ...prev, isLoading: false }));
return false;
} catch (error) {
console.error('GitHub login error:', error);
setState(prev => ({ ...prev, isLoading: false }));
return false;
}
};
const logout = async (): Promise<void> => {
try {
await clearAuthData();
setState({
isAuthenticated: false,
isLoading: false,
user: undefined,
});
} catch (error) {
console.error('Logout error:', error);
}
};
const updateUser = async (updates: Partial<User>): Promise<boolean> => {
try {
if (!state.user) return false;
const response = await authAPI.updateUser(state.user.id, updates);
if (response.success && response.data) {
setState(prev => ({
...prev,
user: { ...prev.user!, ...response.data },
}));
return true;
}
return false;
} catch (error) {
console.error('Update user error:', error);
return false;
}
};
const refreshUser = async (): Promise<void> => {
try {
const storedAuth = await getStoredAuthData();
if (storedAuth && storedAuth.token) {
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
if (userResponse.success && userResponse.data) {
setState(prev => ({
...prev,
user: userResponse.data,
}));
}
}
} catch (error) {
console.error('Refresh user error:', error);
}
};
const value: AuthContextType = {
...state,
login,
loginWithGitHub,
logout,
updateUser,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
@@ -1,136 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Alert, Platform } from 'react-native';
import { useCameraDevices } from 'react-native-vision-camera';
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
interface CameraContextType {
hasPermission: boolean;
devices: any;
isActive: boolean;
requestPermission: () => Promise<boolean>;
startCamera: () => void;
stopCamera: () => void;
capturePhoto: () => Promise<string | null>;
scanDocument: () => Promise<string | null>;
}
const CameraContext = createContext<CameraContextType | undefined>(undefined);
interface CameraProviderProps {
children: ReactNode;
}
export const CameraProvider: React.FC<CameraProviderProps> = ({ children }) => {
const [hasPermission, setHasPermission] = useState(false);
const [isActive, setIsActive] = useState(false);
const devices = useCameraDevices();
const device = devices.find(d => d.position === 'back');
useEffect(() => {
checkPermission();
}, []);
const checkPermission = async () => {
const permission = Platform.OS === 'ios'
? PERMISSIONS.IOS.CAMERA
: PERMISSIONS.ANDROID.CAMERA;
const result = await request(permission);
setHasPermission(result === RESULTS.GRANTED);
};
const requestPermission = async (): Promise<boolean> => {
const permission = Platform.OS === 'ios'
? PERMISSIONS.IOS.CAMERA
: PERMISSIONS.ANDROID.CAMERA;
const result = await request(permission);
const granted = result === RESULTS.GRANTED;
setHasPermission(granted);
return granted;
};
const startCamera = () => {
if (hasPermission && device) {
setIsActive(true);
} else {
Alert.alert('Camera Error', 'Camera permission is required or no camera available');
}
};
const stopCamera = () => {
setIsActive(false);
};
const capturePhoto = async (): Promise<string | null> => {
if (!device || !isActive) {
Alert.alert('Camera Error', 'Camera is not active');
return null;
}
try {
// This would need to be implemented with actual camera capture logic
// For now, return a placeholder
const photo = 'captured-photo-path';
return photo;
} catch (error) {
console.error('Error capturing photo:', error);
Alert.alert('Error', 'Failed to capture photo');
return null;
}
};
const scanDocument = async (): Promise<string | null> => {
if (!hasPermission) {
const granted = await requestPermission();
if (!granted) {
Alert.alert('Permission Required', 'Camera access is required for document scanning');
return null;
}
}
try {
// Start camera for document scanning
startCamera();
// This would integrate with a document scanning library
// For now, return a placeholder
const scannedDocument = 'scanned-document-path';
// Stop camera after scanning
stopCamera();
return scannedDocument;
} catch (error) {
console.error('Error scanning document:', error);
Alert.alert('Error', 'Failed to scan document');
stopCamera();
return null;
}
};
const value: CameraContextType = {
hasPermission,
devices,
isActive,
requestPermission,
startCamera,
stopCamera,
capturePhoto,
scanDocument,
};
return (
<CameraContext.Provider value={value}>
{children}
</CameraContext.Provider>
);
};
export const useCamera = (): CameraContextType => {
const context = useContext(CameraContext);
if (context === undefined) {
throw new Error('useCamera must be used within a CameraProvider');
}
return context;
};
@@ -1,175 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import PushNotification from 'react-native-push-notification';
import { Platform, Alert } from 'react-native';
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
interface Notification {
id: string;
title: string;
message: string;
date?: Date;
userInfo?: any;
}
interface NotificationContextType {
isInitialized: boolean;
hasPermission: boolean;
requestPermission: () => Promise<boolean>;
scheduleNotification: (notification: Notification) => void;
cancelNotification: (id: string) => void;
cancelAllNotifications: () => void;
showLocalNotification: (title: string, message: string) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [isInitialized, setIsInitialized] = useState(false);
const [hasPermission, setHasPermission] = useState(false);
useEffect(() => {
initializeNotifications();
}, []);
const initializeNotifications = () => {
PushNotification.configure({
onRegister: (token) => {
console.log('Push notification token:', token);
// TODO: Send token to backend for server-side notifications
},
onNotification: (notification) => {
console.log('Notification received:', notification);
if (notification.userInteraction) {
// User tapped on notification
handleNotificationPress(notification);
}
},
permissions: {
alert: true,
badge: true,
sound: true,
},
popInitialNotification: true,
requestPermissions: Platform.OS === 'ios',
});
PushNotification.createChannel(
'trackeep-tasks',
'Task Reminders',
4,
(created: any) => console.log('Task channel created:', created)
);
PushNotification.createChannel(
'trackeep-general',
'General Notifications',
3,
(created: any) => console.log('General channel created:', created)
);
checkPermission();
setIsInitialized(true);
};
const checkPermission = async () => {
if (Platform.OS === 'ios') {
PushNotification.checkPermissions((permissions) => {
setHasPermission(Boolean(permissions.alert || permissions.badge || permissions.sound));
});
} else {
const permission = PERMISSIONS.ANDROID.POST_NOTIFICATIONS;
const result = await request(permission);
setHasPermission(result === RESULTS.GRANTED);
}
};
const requestPermission = async (): Promise<boolean> => {
return new Promise((resolve) => {
if (Platform.OS === 'ios') {
PushNotification.requestPermissions((permissions: any) => {
const granted = permissions.alert || permissions.badge || permissions.sound;
setHasPermission(granted);
resolve(granted);
});
} else {
request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS).then((result) => {
const granted = result === RESULTS.GRANTED;
setHasPermission(granted);
resolve(granted);
});
}
});
};
const scheduleNotification = (notification: Notification) => {
if (!hasPermission) {
Alert.alert('Permission Required', 'Please enable notifications to receive reminders.');
return;
}
PushNotification.localNotificationSchedule({
channelId: 'trackeep-tasks',
id: parseInt(notification.id),
title: notification.title,
message: notification.message,
date: notification.date || new Date(),
allowWhileIdle: true,
userInfo: notification.userInfo,
actions: ['View', 'Dismiss'],
});
};
const cancelNotification = (id: string) => {
PushNotification.cancelLocalNotifications({ id: id.toString() });
};
const cancelAllNotifications = () => {
PushNotification.cancelAllLocalNotifications();
};
const showLocalNotification = (title: string, message: string) => {
PushNotification.localNotification({
channelId: 'trackeep-general',
title,
message,
actions: ['View', 'Dismiss'],
});
};
const handleNotificationPress = (notification: any) => {
// TODO: Navigate to relevant screen based on notification data
console.log('Notification pressed:', notification);
};
const value: NotificationContextType = {
isInitialized,
hasPermission,
requestPermission,
scheduleNotification,
cancelNotification,
cancelAllNotifications,
showLocalNotification,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
};
export const useNotifications = (): NotificationContextType => {
const context = useContext(NotificationContext);
if (context === undefined) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
};
@@ -1,115 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { OfflineState } from '../types';
import NetInfo from '@react-native-community/netinfo';
import { syncOfflineData, getPendingChangesCount } from '../utils/offlineSync';
interface OfflineContextType extends OfflineState {
syncNow: () => Promise<void>;
forceSync: () => Promise<void>;
clearPendingChanges: () => Promise<void>;
}
const OfflineContext = createContext<OfflineContextType | undefined>(undefined);
interface OfflineProviderProps {
children: ReactNode;
}
export const OfflineProvider: React.FC<OfflineProviderProps> = ({ children }) => {
const [state, setState] = useState<OfflineState>({
isOnline: true,
syncInProgress: false,
pendingChanges: 0,
lastSyncTime: undefined,
});
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((netState: any) => {
const isOnline = netState.isConnected ?? false;
setState(prev => ({
...prev,
isOnline
}));
if (isOnline && state.pendingChanges > 0) {
syncOfflineData();
}
});
loadPendingChanges();
return () => unsubscribe();
}, []);
const loadPendingChanges = async () => {
try {
const count = await getPendingChangesCount();
setState(prev => ({ ...prev, pendingChanges: count }));
} catch (error) {
console.error('Error loading pending changes:', error);
}
};
const syncNow = async () => {
if (!state.isOnline || state.syncInProgress) return;
setState(prev => ({ ...prev, syncInProgress: true }));
try {
await syncOfflineData();
const count = await getPendingChangesCount();
setState(prev => ({
...prev,
syncInProgress: false,
pendingChanges: count,
lastSyncTime: new Date(),
}));
} catch (error) {
console.error('Sync error:', error);
setState(prev => ({ ...prev, syncInProgress: false }));
}
};
const forceSync = async () => {
setState(prev => ({ ...prev, syncInProgress: true }));
try {
await syncOfflineData();
const count = await getPendingChangesCount();
setState(prev => ({
...prev,
syncInProgress: false,
pendingChanges: count,
lastSyncTime: new Date(),
}));
} catch (error) {
console.error('Force sync error:', error);
setState(prev => ({ ...prev, syncInProgress: false }));
}
};
const clearPendingChanges = async () => {
try {
setState(prev => ({ ...prev, pendingChanges: 0 }));
} catch (error) {
console.error('Error clearing pending changes:', error);
}
};
const value: OfflineContextType = {
...state,
syncNow,
forceSync,
clearPendingChanges,
};
return <OfflineContext.Provider value={value}>{children}</OfflineContext.Provider>;
};
export const useOffline = (): OfflineContextType => {
const context = useContext(OfflineContext);
if (context === undefined) {
throw new Error('useOffline must be used within an OfflineProvider');
}
return context;
};
@@ -1,280 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useNetInfo } from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useServerConfig } from './ServerConfigContext';
import { DeviceEventEmitter } from 'react-native';
interface SyncEvent {
id: string;
type: 'create' | 'update' | 'delete';
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
entityId: string;
data: any;
timestamp: number;
synced: boolean;
}
interface RealtimeSyncContextType {
isOnline: boolean;
isSyncing: boolean;
pendingEvents: SyncEvent[];
lastSyncTime: number | null;
syncNow: () => Promise<void>;
addSyncEvent: (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => Promise<void>;
clearPendingEvents: () => Promise<void>;
}
const RealtimeSyncContext = createContext<RealtimeSyncContextType | undefined>(undefined);
const SYNC_EVENTS_KEY = 'trackeep_sync_events';
const LAST_SYNC_KEY = 'trackeep_last_sync';
interface RealtimeSyncProviderProps {
children: ReactNode;
}
export const RealtimeSyncProvider: React.FC<RealtimeSyncProviderProps> = ({ children }) => {
const [isSyncing, setIsSyncing] = useState(false);
const [pendingEvents, setPendingEvents] = useState<SyncEvent[]>([]);
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
const netInfo = useNetInfo();
const { config } = useServerConfig();
const isOnline = netInfo.isConnected === true;
useEffect(() => {
loadSyncData();
}, []);
useEffect(() => {
if (isOnline && config && pendingEvents.length > 0) {
syncPendingEvents();
}
}, [isOnline, config, pendingEvents.length]);
useEffect(() => {
if (isOnline && config) {
connectWebSocket();
} else {
disconnectWebSocket();
}
return () => {
disconnectWebSocket();
};
}, [isOnline, config]);
const loadSyncData = async () => {
try {
const storedEvents = await AsyncStorage.getItem(SYNC_EVENTS_KEY);
const storedLastSync = await AsyncStorage.getItem(LAST_SYNC_KEY);
if (storedEvents) {
const events = JSON.parse(storedEvents);
setPendingEvents(events);
}
if (storedLastSync) {
setLastSyncTime(JSON.parse(storedLastSync));
}
} catch (error) {
console.error('Error loading sync data:', error);
}
};
const connectWebSocket = () => {
if (!config) return;
try {
const wsUrl = config.baseUrl.replace('http', 'ws') + '/ws';
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
setWebsocket(ws);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleRealtimeUpdate(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setWebsocket(null);
// Attempt to reconnect after 5 seconds
setTimeout(() => {
if (isOnline && config) {
connectWebSocket();
}
}, 5000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error connecting WebSocket:', error);
}
};
const disconnectWebSocket = () => {
if (websocket) {
websocket.close();
setWebsocket(null);
}
};
const handleRealtimeUpdate = (data: any) => {
// This will be handled by individual components through event listeners
console.log('Received realtime update:', data);
// Emit a custom event that components can listen to
DeviceEventEmitter.emit('trackeep:sync', data);
};
const addSyncEvent = async (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => {
const syncEvent: SyncEvent = {
...event,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
synced: false,
};
const updatedEvents = [...pendingEvents, syncEvent];
setPendingEvents(updatedEvents);
try {
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(updatedEvents));
// Try to sync immediately if online
if (isOnline && config) {
await syncPendingEvents();
}
} catch (error) {
console.error('Error saving sync event:', error);
}
};
const syncPendingEvents = async () => {
if (!config || isSyncing || pendingEvents.length === 0) return;
setIsSyncing(true);
try {
const unsyncedEvents = pendingEvents.filter(event => !event.synced);
const results = await Promise.allSettled(
unsyncedEvents.map(event => syncSingleEvent(event))
);
const successfulEvents: string[] = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
successfulEvents.push(unsyncedEvents[index].id);
}
});
// Update pending events to mark successful ones as synced
const updatedEvents = pendingEvents.map(event => ({
...event,
synced: successfulEvents.includes(event.id),
}));
// Remove synced events after a delay
const finalEvents = updatedEvents.filter(event => !event.synced);
setPendingEvents(finalEvents);
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(finalEvents));
// Update last sync time
const now = Date.now();
setLastSyncTime(now);
await AsyncStorage.setItem(LAST_SYNC_KEY, JSON.stringify(now));
} catch (error) {
console.error('Error during sync:', error);
} finally {
setIsSyncing(false);
}
};
const syncSingleEvent = async (event: SyncEvent): Promise<boolean> => {
try {
const token = await AsyncStorage.getItem('trackeep_auth_token');
if (!token || !config) return false;
const response = await fetch(`${config.baseUrl}/api/sync/${event.entityType}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
type: event.type,
id: event.entityId,
data: event.data,
timestamp: event.timestamp,
}),
});
return response.ok;
} catch (error) {
console.error('Error syncing single event:', error);
return false;
}
};
const syncNow = async () => {
await syncPendingEvents();
};
const clearPendingEvents = async () => {
setPendingEvents([]);
try {
await AsyncStorage.removeItem(SYNC_EVENTS_KEY);
} catch (error) {
console.error('Error clearing pending events:', error);
}
};
const value: RealtimeSyncContextType = {
isOnline,
isSyncing,
pendingEvents,
lastSyncTime,
syncNow,
addSyncEvent,
clearPendingEvents,
};
return (
<RealtimeSyncContext.Provider value={value}>
{children}
</RealtimeSyncContext.Provider>
);
};
export const useRealtimeSync = (): RealtimeSyncContextType => {
const context = useContext(RealtimeSyncContext);
if (context === undefined) {
throw new Error('useRealtimeSync must be used within a RealtimeSyncProvider');
}
return context;
};
// Hook for components to listen to realtime updates
export const useRealtimeUpdates = (callback: (data: any) => void) => {
useEffect(() => {
const subscription = DeviceEventEmitter.addListener('trackeep:sync', callback);
return () => {
subscription.remove();
};
}, [callback]);
};
@@ -1,89 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface ServerConfig {
baseUrl: string;
username: string;
password: string;
}
interface ServerConfigContextType {
config: ServerConfig | null;
isConfigured: boolean;
setConfig: (config: ServerConfig) => Promise<void>;
clearConfig: () => Promise<void>;
isLoading: boolean;
}
const ServerConfigContext = createContext<ServerConfigContextType | undefined>(undefined);
const SERVER_CONFIG_KEY = 'trackeep_server_config';
interface ServerConfigProviderProps {
children: ReactNode;
}
export const ServerConfigProvider: React.FC<ServerConfigProviderProps> = ({ children }) => {
const [config, setConfigState] = useState<ServerConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const storedConfig = await AsyncStorage.getItem(SERVER_CONFIG_KEY);
if (storedConfig) {
const parsedConfig = JSON.parse(storedConfig);
setConfigState(parsedConfig);
}
} catch (error) {
console.error('Error loading server config:', error);
} finally {
setIsLoading(false);
}
};
const setConfig = async (newConfig: ServerConfig) => {
try {
await AsyncStorage.setItem(SERVER_CONFIG_KEY, JSON.stringify(newConfig));
setConfigState(newConfig);
} catch (error) {
console.error('Error saving server config:', error);
throw error;
}
};
const clearConfig = async () => {
try {
await AsyncStorage.removeItem(SERVER_CONFIG_KEY);
setConfigState(null);
} catch (error) {
console.error('Error clearing server config:', error);
throw error;
}
};
const value: ServerConfigContextType = {
config,
isConfigured: !!config,
setConfig,
clearConfig,
isLoading,
};
return (
<ServerConfigContext.Provider value={value}>
{children}
</ServerConfigContext.Provider>
);
};
export const useServerConfig = (): ServerConfigContextType => {
const context = useContext(ServerConfigContext);
if (context === undefined) {
throw new Error('useServerConfig must be used within a ServerConfigProvider');
}
return context;
};
@@ -1,208 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Alert, Platform } from 'react-native';
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
import Voice from 'react-native-voice';
interface VoiceRecording {
id: string;
path: string;
duration: number;
transcript?: string;
createdAt: Date;
}
interface VoiceContextType {
isRecording: boolean;
isProcessing: boolean;
hasPermission: boolean;
recordings: VoiceRecording[];
requestPermission: () => Promise<boolean>;
startRecording: () => void;
stopRecording: () => Promise<VoiceRecording | null>;
transcribeRecording: (recordingPath: string) => Promise<string | null>;
deleteRecording: (id: string) => void;
}
const VoiceContext = createContext<VoiceContextType | undefined>(undefined);
interface VoiceProviderProps {
children: ReactNode;
}
export const VoiceProvider: React.FC<VoiceProviderProps> = ({ children }) => {
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [hasPermission, setHasPermission] = useState(false);
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
const [recordingStartTime, setRecordingStartTime] = useState<Date | null>(null);
useEffect(() => {
initializeVoice();
return () => {
Voice.destroy();
};
}, []);
const initializeVoice = async () => {
await checkPermission();
Voice.onSpeechStart = onSpeechStart;
Voice.onSpeechEnd = onSpeechEnd;
Voice.onSpeechResults = onSpeechResults;
Voice.onSpeechError = onSpeechError;
};
const checkPermission = async () => {
const permission = Platform.OS === 'ios'
? PERMISSIONS.IOS.MICROPHONE
: PERMISSIONS.ANDROID.RECORD_AUDIO;
const result = await request(permission);
setHasPermission(result === RESULTS.GRANTED);
};
const requestPermission = async (): Promise<boolean> => {
const permission = Platform.OS === 'ios'
? PERMISSIONS.IOS.MICROPHONE
: PERMISSIONS.ANDROID.RECORD_AUDIO;
const result = await request(permission);
const granted = result === RESULTS.GRANTED;
setHasPermission(granted);
return granted;
};
const onSpeechStart = () => {
setIsRecording(true);
setRecordingStartTime(new Date());
};
const onSpeechEnd = () => {
setIsRecording(false);
setRecordingStartTime(null);
};
const onSpeechResults = (e: any) => {
// Handle speech recognition results
console.log('Speech results:', e.value);
};
const onSpeechError = (e: any) => {
console.error('Speech recognition error:', e);
setIsRecording(false);
setRecordingStartTime(null);
Alert.alert('Recording Error', 'Failed to process voice recording');
};
const startRecording = async () => {
if (!hasPermission) {
const granted = await requestPermission();
if (!granted) {
Alert.alert('Permission Required', 'Microphone access is required for voice recording');
return;
}
}
try {
setIsProcessing(true);
// Start speech recognition
await Voice.start('en-US');
// For actual audio recording, you would integrate with a library like react-native-audio-recorder-player
// This is a placeholder for the recording functionality
} catch (error) {
console.error('Error starting recording:', error);
Alert.alert('Error', 'Failed to start recording');
setIsProcessing(false);
}
};
const stopRecording = async (): Promise<VoiceRecording | null> => {
if (!isRecording) {
return null;
}
try {
setIsProcessing(true);
// Stop speech recognition
await Voice.stop();
// Calculate duration
const duration = recordingStartTime
? Math.floor((new Date().getTime() - recordingStartTime.getTime()) / 1000)
: 0;
// Create recording object (placeholder - actual implementation would save audio file)
const recording: VoiceRecording = {
id: Date.now().toString(),
path: `recording-${Date.now()}.m4a`,
duration,
createdAt: new Date(),
};
setRecordings(prev => [...prev, recording]);
setIsProcessing(false);
return recording;
} catch (error) {
console.error('Error stopping recording:', error);
setIsProcessing(false);
return null;
}
};
const transcribeRecording = async (recordingPath: string): Promise<string | null> => {
try {
setIsProcessing(true);
// Start speech recognition for transcription
await Voice.start('en-US');
// This would integrate with a speech-to-text service
// For now, return a placeholder
const transcript = "Transcribed text from audio recording";
await Voice.stop();
setIsProcessing(false);
return transcript;
} catch (error) {
console.error('Error transcribing recording:', error);
setIsProcessing(false);
return null;
}
};
const deleteRecording = (id: string) => {
setRecordings(prev => prev.filter(rec => rec.id !== id));
};
const value: VoiceContextType = {
isRecording,
isProcessing,
hasPermission,
recordings,
requestPermission,
startRecording,
stopRecording,
transcribeRecording,
deleteRecording,
};
return (
<VoiceContext.Provider value={value}>
{children}
</VoiceContext.Provider>
);
};
export const useVoice = (): VoiceContextType => {
const context = useContext(VoiceContext);
if (context === undefined) {
throw new Error('useVoice must be used within a VoiceProvider');
}
return context;
};
-321
View File
@@ -1,321 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { ApiResponse, User, Bookmark, Task, Note, TimeEntry, CalendarEvent, SearchFilters, SavedSearch } from '../types';
import { getStoredAuthData } from '../utils/storage';
let API_BASE_URL = __DEV__
? 'http://localhost:8080/api'
: 'https://trackeep.app/api';
class APIClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
updateBaseURL(newBaseURL: string) {
API_BASE_URL = newBaseURL;
this.client.defaults.baseURL = newBaseURL;
}
private setupInterceptors() {
this.client.interceptors.request.use(
async (config) => {
const authData = await getStoredAuthData();
if (authData && authData.token) {
config.headers.Authorization = `Bearer ${authData.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
this.client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await this.handleUnauthorized();
}
return Promise.reject(error);
}
);
}
private async handleUnauthorized() {
try {
const { clearAuthData } = await import('../utils/storage');
await clearAuthData();
} catch (error) {
console.error('Error handling unauthorized:', error);
}
}
public async request<T>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
try {
const response = await this.client.request(config);
return {
success: true,
data: response.data,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message || 'Unknown error',
};
}
}
}
const apiClient = new APIClient();
export const updateAPIBaseURL = (newBaseURL: string) => {
apiClient.updateBaseURL(newBaseURL);
};
export const authAPI = {
login: async (email: string, password: string): Promise<ApiResponse<{ token: string; user: User }>> => {
return apiClient.request({
method: 'POST',
url: '/auth/login',
data: { email, password },
});
},
loginWithGitHub: async (): Promise<ApiResponse<{ token: string; user: User }>> => {
return apiClient.request({
method: 'POST',
url: '/auth/github',
});
},
getCurrentUser: async (token: string): Promise<ApiResponse<User>> => {
return apiClient.request({
method: 'GET',
url: '/auth/me',
headers: { Authorization: `Bearer ${token}` },
});
},
updateUser: async (userId: string, updates: Partial<User>): Promise<ApiResponse<User>> => {
return apiClient.request({
method: 'PUT',
url: `/users/${userId}`,
data: updates,
});
},
};
export const bookmarksAPI = {
getBookmarks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Bookmark[]>> => {
return apiClient.request({
method: 'GET',
url: '/bookmarks',
params: filters,
});
},
createBookmark: async (bookmark: Omit<Bookmark, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Bookmark>> => {
return apiClient.request({
method: 'POST',
url: '/bookmarks',
data: bookmark,
});
},
updateBookmark: async (id: string, updates: Partial<Bookmark>): Promise<ApiResponse<Bookmark>> => {
return apiClient.request({
method: 'PUT',
url: `/bookmarks/${id}`,
data: updates,
});
},
deleteBookmark: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/bookmarks/${id}`,
});
},
};
export const tasksAPI = {
getTasks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Task[]>> => {
return apiClient.request({
method: 'GET',
url: '/tasks',
params: filters,
});
},
createTask: async (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Task>> => {
return apiClient.request({
method: 'POST',
url: '/tasks',
data: task,
});
},
updateTask: async (id: string, updates: Partial<Task>): Promise<ApiResponse<Task>> => {
return apiClient.request({
method: 'PUT',
url: `/tasks/${id}`,
data: updates,
});
},
deleteTask: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/tasks/${id}`,
});
},
};
export const notesAPI = {
getNotes: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Note[]>> => {
return apiClient.request({
method: 'GET',
url: '/notes',
params: filters,
});
},
createNote: async (note: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Note>> => {
return apiClient.request({
method: 'POST',
url: '/notes',
data: note,
});
},
updateNote: async (id: string, updates: Partial<Note>): Promise<ApiResponse<Note>> => {
return apiClient.request({
method: 'PUT',
url: `/notes/${id}`,
data: updates,
});
},
deleteNote: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/notes/${id}`,
});
},
};
export const timeEntriesAPI = {
getTimeEntries: async (filters?: any): Promise<ApiResponse<TimeEntry[]>> => {
return apiClient.request({
method: 'GET',
url: '/time-entries',
params: filters,
});
},
createTimeEntry: async (entry: Omit<TimeEntry, 'id' | 'createdAt'>): Promise<ApiResponse<TimeEntry>> => {
return apiClient.request({
method: 'POST',
url: '/time-entries',
data: entry,
});
},
updateTimeEntry: async (id: string, updates: Partial<TimeEntry>): Promise<ApiResponse<TimeEntry>> => {
return apiClient.request({
method: 'PUT',
url: `/time-entries/${id}`,
data: updates,
});
},
deleteTimeEntry: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/time-entries/${id}`,
});
},
};
export const searchAPI = {
search: async (filters: SearchFilters): Promise<ApiResponse<any>> => {
return apiClient.request({
method: 'POST',
url: '/search',
data: filters,
});
},
getSavedSearches: async (): Promise<ApiResponse<SavedSearch[]>> => {
return apiClient.request({
method: 'GET',
url: '/search/saved',
});
},
createSavedSearch: async (search: Omit<SavedSearch, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<SavedSearch>> => {
return apiClient.request({
method: 'POST',
url: '/search/saved',
data: search,
});
},
updateSavedSearch: async (id: string, updates: Partial<SavedSearch>): Promise<ApiResponse<SavedSearch>> => {
return apiClient.request({
method: 'PUT',
url: `/search/saved/${id}`,
data: updates,
});
},
deleteSavedSearch: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/search/saved/${id}`,
});
},
};
export const calendarAPI = {
getEvents: async (filters?: any): Promise<ApiResponse<CalendarEvent[]>> => {
return apiClient.request({
method: 'GET',
url: '/calendar/events',
params: filters,
});
},
createEvent: async (event: Omit<CalendarEvent, 'id'>): Promise<ApiResponse<CalendarEvent>> => {
return apiClient.request({
method: 'POST',
url: '/calendar/events',
data: event,
});
},
updateEvent: async (id: string, updates: Partial<CalendarEvent>): Promise<ApiResponse<CalendarEvent>> => {
return apiClient.request({
method: 'PUT',
url: `/calendar/events/${id}`,
data: updates,
});
},
deleteEvent: async (id: string): Promise<ApiResponse<void>> => {
return apiClient.request({
method: 'DELETE',
url: `/calendar/events/${id}`,
});
},
};
-140
View File
@@ -1,140 +0,0 @@
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
githubUsername?: string;
preferences: UserPreferences;
}
export interface UserPreferences {
theme: 'light' | 'dark' | 'auto';
notifications: boolean;
syncEnabled: boolean;
language: string;
}
export interface Bookmark {
id: string;
title: string;
url: string;
description?: string;
tags: string[];
isFavorite: boolean;
isRead: boolean;
createdAt: Date;
updatedAt: Date;
content?: string;
thumbnail?: string;
}
export interface Task {
id: string;
title: string;
description?: string;
status: 'todo' | 'in_progress' | 'completed' | 'cancelled';
priority: 'low' | 'medium' | 'high' | 'urgent';
dueDate?: Date;
createdAt: Date;
updatedAt: Date;
tags: string[];
estimatedTime?: number;
actualTime?: number;
}
export interface Note {
id: string;
title: string;
content: string;
tags: string[];
isPublic: boolean;
createdAt: Date;
updatedAt: Date;
parentId?: string;
children?: Note[];
}
export interface TimeEntry {
id: string;
taskId?: string;
bookmarkId?: string;
noteId?: string;
startTime: Date;
endTime?: Date;
duration?: number;
description: string;
tags: string[];
billable: boolean;
hourlyRate?: number;
createdAt: Date;
}
export interface CalendarEvent {
id: string;
title: string;
description?: string;
startTime: Date;
endTime: Date;
type: 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit';
priority: 'low' | 'medium' | 'high' | 'urgent';
location?: string;
attendees?: string[];
recurring?: RecurrenceRule;
source: 'trackeep' | 'google' | 'outlook' | 'manual';
}
export interface RecurrenceRule {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
interval: number;
endDate?: Date;
daysOfWeek?: number[];
}
export interface SearchFilters {
query: string;
contentType: 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files';
tags: string[];
dateRange: { start: Date; end: Date };
author: string;
language: string;
fileTypes: string[];
isFavorite: boolean;
isRead: boolean;
searchMode: 'fulltext' | 'semantic' | 'hybrid';
threshold: number;
}
export interface SavedSearch {
id: string;
name: string;
query: string;
filters: SearchFilters;
alert: boolean;
lastRun?: Date;
runCount: number;
isPublic: boolean;
description?: string;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface NavigationState {
isAuthenticated: boolean;
isLoading: boolean;
user?: User;
}
export interface OfflineState {
isOnline: boolean;
syncInProgress: boolean;
pendingChanges: number;
lastSyncTime?: Date;
}
-49
View File
@@ -1,49 +0,0 @@
declare module 'react-native-push-notification' {
export interface PushNotificationPermissions {
alert?: boolean;
badge?: boolean;
sound?: boolean;
}
export interface PushNotification {
configure(options: {
onRegister?: (token: any) => void;
onNotification?: (notification: any) => void;
permissions?: PushNotificationPermissions;
popInitialNotification?: boolean;
requestPermissions?: boolean;
}): void;
requestPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
checkPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
localNotification(details: {
channelId?: string;
id?: number;
title?: string;
message?: string;
userInfo?: any;
actions?: string[];
}): void;
localNotificationSchedule(details: {
channelId?: string;
id?: number;
title?: string;
message?: string;
date: Date;
userInfo?: any;
actions?: string[];
allowWhileIdle?: boolean;
}): void;
cancelLocalNotifications(details: { id: string }): void;
cancelAllLocalNotifications(): void;
createChannel(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
createChannelImportance(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
}
const PushNotification: PushNotification;
export default PushNotification;
}
-18
View File
@@ -1,18 +0,0 @@
declare module 'react-native-voice' {
export interface VoiceResults {
value?: string[];
error?: boolean;
isFinal?: boolean;
}
export default class Voice {
static isAvailable(): Promise<boolean>;
static start(locale?: string): Promise<void>;
static stop(): Promise<void>;
static destroy(): Promise<void>;
static onSpeechStart?: (e: any) => void;
static onSpeechEnd?: (e: any) => void;
static onSpeechResults?: (e: VoiceResults) => void;
static onSpeechError?: (e: any) => void;
}
}
@@ -1,106 +0,0 @@
import { useNotifications } from '../services/NotificationContext';
export class NotificationUtils {
private static notifications = useNotifications();
static scheduleTaskReminder(taskId: string, taskTitle: string, dueDate: Date) {
const reminderTime = new Date(dueDate.getTime() - 24 * 60 * 60 * 1000); // 1 day before
const now = new Date();
if (reminderTime > now) {
this.notifications.scheduleNotification({
id: `task-reminder-${taskId}`,
title: 'Task Due Soon',
message: `Task "${taskTitle}" is due tomorrow`,
date: reminderTime,
userInfo: { type: 'task', taskId },
});
}
// Schedule final reminder 1 hour before
const finalReminder = new Date(dueDate.getTime() - 60 * 60 * 1000);
if (finalReminder > now) {
this.notifications.scheduleNotification({
id: `task-final-${taskId}`,
title: 'Task Due Soon',
message: `Task "${taskTitle}" is due in 1 hour`,
date: finalReminder,
userInfo: { type: 'task', taskId },
});
}
}
static scheduleDeadlineReminder(taskId: string, taskTitle: string, deadline: Date) {
const reminderTimes = [
{ days: 7, message: 'due in 1 week' },
{ days: 3, message: 'due in 3 days' },
{ days: 1, message: 'due tomorrow' },
{ hours: 1, message: 'due in 1 hour' },
];
const now = new Date();
reminderTimes.forEach((reminder, index) => {
let reminderTime: Date;
if (reminder.days) {
reminderTime = new Date(deadline.getTime() - reminder.days * 24 * 60 * 60 * 1000);
} else if (reminder.hours) {
reminderTime = new Date(deadline.getTime() - reminder.hours * 60 * 60 * 1000);
} else {
return;
}
if (reminderTime > now) {
this.notifications.scheduleNotification({
id: `deadline-${taskId}-${index}`,
title: 'Deadline Reminder',
message: `Task "${taskTitle}" ${reminder.message}`,
date: reminderTime,
userInfo: { type: 'deadline', taskId },
});
}
});
}
static scheduleStudyReminder(courseId: string, courseTitle: string, studyTime: Date) {
this.notifications.scheduleNotification({
id: `study-${courseId}`,
title: 'Study Reminder',
message: `Time to study "${courseTitle}"`,
date: studyTime,
userInfo: { type: 'study', courseId },
});
}
static cancelTaskNotifications(taskId: string) {
this.notifications.cancelNotification(`task-reminder-${taskId}`);
this.notifications.cancelNotification(`task-final-${taskId}`);
// Cancel deadline notifications
for (let i = 0; i < 4; i++) {
this.notifications.cancelNotification(`deadline-${taskId}-${i}`);
}
}
static showTaskCompletedNotification(taskTitle: string) {
this.notifications.showLocalNotification(
'Task Completed! 🎉',
`Great job! You completed "${taskTitle}"`
);
}
static showTimeTrackingReminder() {
this.notifications.showLocalNotification(
'Time Tracking Reminder',
'Don\'t forget to track your time on current tasks'
);
}
static showDailySummaryNotification(completedTasks: number, totalHours: number) {
this.notifications.showLocalNotification(
'Daily Summary 📊',
`Completed ${completedTasks} tasks, tracked ${totalHours.toFixed(1)} hours today`
);
}
}
-126
View File
@@ -1,126 +0,0 @@
import { getOfflineData, clearOfflineChanges, addOfflineChange } from './storage';
import { bookmarksAPI, tasksAPI, notesAPI, timeEntriesAPI } from '../services/api';
interface OfflineChange {
id: string;
type: 'create' | 'update' | 'delete';
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
data: any;
timestamp: string;
}
export const getPendingChangesCount = async (): Promise<number> => {
try {
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
return changes.length;
} catch (error) {
console.error('Error getting pending changes count:', error);
return 0;
}
};
export const syncOfflineData = async (): Promise<void> => {
try {
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
for (const change of changes) {
try {
await processChange(change);
} catch (error) {
console.error(`Error processing change ${change.id}:`, error);
}
}
await clearOfflineChanges();
} catch (error) {
console.error('Sync error:', error);
throw error;
}
};
const processChange = async (change: OfflineChange): Promise<void> => {
switch (change.entityType) {
case 'bookmark':
await processBookmarkChange(change);
break;
case 'task':
await processTaskChange(change);
break;
case 'note':
await processNoteChange(change);
break;
case 'timeEntry':
await processTimeEntryChange(change);
break;
default:
console.warn(`Unknown entity type: ${change.entityType}`);
}
};
const processBookmarkChange = async (change: OfflineChange): Promise<void> => {
switch (change.type) {
case 'create':
await bookmarksAPI.createBookmark(change.data);
break;
case 'update':
await bookmarksAPI.updateBookmark(change.data.id, change.data);
break;
case 'delete':
await bookmarksAPI.deleteBookmark(change.data.id);
break;
}
};
const processTaskChange = async (change: OfflineChange): Promise<void> => {
switch (change.type) {
case 'create':
await tasksAPI.createTask(change.data);
break;
case 'update':
await tasksAPI.updateTask(change.data.id, change.data);
break;
case 'delete':
await tasksAPI.deleteTask(change.data.id);
break;
}
};
const processNoteChange = async (change: OfflineChange): Promise<void> => {
switch (change.type) {
case 'create':
await notesAPI.createNote(change.data);
break;
case 'update':
await notesAPI.updateNote(change.data.id, change.data);
break;
case 'delete':
await notesAPI.deleteNote(change.data.id);
break;
}
};
const processTimeEntryChange = async (change: OfflineChange): Promise<void> => {
switch (change.type) {
case 'create':
await timeEntriesAPI.createTimeEntry(change.data);
break;
case 'update':
await timeEntriesAPI.updateTimeEntry(change.data.id, change.data);
break;
case 'delete':
await timeEntriesAPI.deleteTimeEntry(change.data.id);
break;
}
};
export const queueOfflineChange = async (
type: 'create' | 'update' | 'delete',
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry',
data: any
): Promise<void> => {
await addOfflineChange({
type,
entityType,
data,
});
};
-168
View File
@@ -1,168 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User } from '../types';
const STORAGE_KEYS = {
AUTH_TOKEN: '@trackeep_auth_token',
USER_DATA: '@trackeep_user_data',
THEME: '@trackeep_theme',
BOOKMARKS: '@trackeep_bookmarks',
TASKS: '@trackeep_tasks',
NOTES: '@trackeep_notes',
TIME_ENTRIES: '@trackeep_time_entries',
OFFLINE_CHANGES: '@trackeep_offline_changes',
SEARCH_HISTORY: '@trackeep_search_history',
SAVED_SEARCHES: '@trackeep_saved_searches',
} as const;
export interface StoredAuthData {
token: string;
user: User;
}
export const storeAuthData = async (data: StoredAuthData): Promise<void> => {
try {
await AsyncStorage.multiSet([
[STORAGE_KEYS.AUTH_TOKEN, data.token],
[STORAGE_KEYS.USER_DATA, JSON.stringify(data.user)],
]);
} catch (error) {
console.error('Error storing auth data:', error);
throw error;
}
};
export const getStoredAuthData = async (): Promise<StoredAuthData | null> => {
try {
const [token, userData] = await AsyncStorage.multiGet([
STORAGE_KEYS.AUTH_TOKEN,
STORAGE_KEYS.USER_DATA,
]);
if (token[1] && userData[1]) {
return {
token: token[1],
user: JSON.parse(userData[1]),
};
}
return null;
} catch (error) {
console.error('Error getting stored auth data:', error);
return null;
}
};
export const clearAuthData = async (): Promise<void> => {
try {
await AsyncStorage.multiRemove([
STORAGE_KEYS.AUTH_TOKEN,
STORAGE_KEYS.USER_DATA,
]);
} catch (error) {
console.error('Error clearing auth data:', error);
throw error;
}
};
export const loadTheme = async (): Promise<'light' | 'dark'> => {
try {
const theme = await AsyncStorage.getItem(STORAGE_KEYS.THEME);
return theme === 'dark' ? 'dark' : 'light';
} catch (error) {
console.error('Error loading theme:', error);
return 'light';
}
};
export const saveTheme = async (theme: 'light' | 'dark'): Promise<void> => {
try {
await AsyncStorage.setItem(STORAGE_KEYS.THEME, theme);
} catch (error) {
console.error('Error saving theme:', error);
throw error;
}
};
export const storeOfflineData = async <T>(key: keyof typeof STORAGE_KEYS, data: T[]): Promise<void> => {
try {
await AsyncStorage.setItem(STORAGE_KEYS[key], JSON.stringify(data));
} catch (error) {
console.error(`Error storing offline data for ${key}:`, error);
throw error;
}
};
export const getOfflineData = async <T>(key: keyof typeof STORAGE_KEYS): Promise<T[]> => {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS[key]);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error(`Error getting offline data for ${key}:`, error);
return [];
}
};
export const addOfflineChange = async (change: any): Promise<void> => {
try {
const existingChanges = await getOfflineData('OFFLINE_CHANGES');
existingChanges.push({
...change,
id: Date.now().toString(),
timestamp: new Date().toISOString(),
});
await storeOfflineData('OFFLINE_CHANGES', existingChanges);
} catch (error) {
console.error('Error adding offline change:', error);
throw error;
}
};
export const clearOfflineChanges = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(STORAGE_KEYS.OFFLINE_CHANGES);
} catch (error) {
console.error('Error clearing offline changes:', error);
throw error;
}
};
export const getPendingChangesCount = async (): Promise<number> => {
try {
const changes = await getOfflineData('OFFLINE_CHANGES');
return changes.length;
} catch (error) {
console.error('Error getting pending changes count:', error);
return 0;
}
};
export const storeSearchHistory = async (query: string): Promise<void> => {
try {
const history = await getOfflineData('SEARCH_HISTORY');
const filteredHistory = (history as string[]).filter((item: string) => item !== query);
filteredHistory.unshift(query);
const limitedHistory = filteredHistory.slice(0, 50);
await storeOfflineData('SEARCH_HISTORY', limitedHistory);
} catch (error) {
console.error('Error storing search history:', error);
throw error;
}
};
export const getSearchHistory = async (): Promise<string[]> => {
try {
return await getOfflineData('SEARCH_HISTORY');
} catch (error) {
console.error('Error getting search history:', error);
return [];
}
};
export const clearAllData = async (): Promise<void> => {
try {
await AsyncStorage.multiRemove(Object.values(STORAGE_KEYS));
} catch (error) {
console.error('Error clearing all data:', error);
throw error;
}
};
-32
View File
@@ -1,32 +0,0 @@
{
"extends": "@tsconfig/react-native/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["es2017", "es2018", "es2019"],
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"target": "esnext",
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
},
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": [
"src/**/*",
"index.js",
"App.tsx"
],
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js"
]
}
-26
View File
@@ -1,26 +0,0 @@
# OAuth Service Configuration
OAUTH_SERVICE_PORT=9090
OAUTH_GIN_MODE=debug
OAUTH_CORS_ALLOWED_ORIGINS=*
# GitHub OAuth Configuration
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
# Production URLs (update these for your deployment)
DEFAULT_CLIENT_URL=https://yourdomain.com
SERVICE_DOMAIN=https://oauth.yourdomain.com
# JWT Configuration for OAuth Service
OAUTH_JWT_SECRET=your_oauth_jwt_secret_here
OAUTH_JWT_EXPIRES_IN=24h
# Database Configuration (if using separate database for OAuth)
OAUTH_DB_TYPE=postgres
OAUTH_DB_HOST=localhost
OAUTH_DB_PORT=5432
OAUTH_DB_USER=oauth_user
OAUTH_DB_PASSWORD=your_oauth_password
OAUTH_DB_NAME=oauth_db
OAUTH_DB_SSL_MODE=disable
-56
View File
@@ -1,56 +0,0 @@
# OAuth Service Configuration Changes
## Summary of Changes
### 1. CORS Configuration Updated
- **Before**: Restricted to specific origins (`http://localhost:5173,http://localhost:8080`)
- **After**: Allows all origins (`*`) for maximum flexibility
- **Implementation**: Updated CORS middleware to handle wildcard origins properly
### 2. Dynamic Client URL Detection
- **Before**: Hardcoded default client URL (`http://localhost:5173`)
- **After**: Dynamically determines client URL from:
- Query parameter `redirect_uri` (highest priority)
- Request `Origin` header
- Request `Referer` header
- Fallback to `DEFAULT_CLIENT_URL` environment variable
- **Implementation**: Enhanced `initiateGitHubOAuth` function with URL parsing logic
### 3. Service Domain Configuration
- **Added**: New `SERVICE_DOMAIN` environment variable
- **Purpose**: Identifies the OAuth service domain in logs and webhook responses
- **Current Value**: `https://oauth.tdvorak.dev`
### 4. Enhanced Webhook Handling
- **Before**: Basic webhook processing with minimal logging
- **After**:
- Proper webhook secret configuration check
- Enhanced logging with service domain identification
- Detailed event type handling with better payload logging
- Response includes service domain information
### 5. Environment Files Updated
- **`.env`**: Updated with new configuration values
- **`.env.example`**: Updated to reflect the new structure for other deployments
## Key Benefits
1. **Multi-domain Support**: Service can now handle requests from any domain
2. **Dynamic Client Detection**: Automatically redirects users back to their originating domain
3. **Better Debugging**: Enhanced logging makes troubleshooting easier
4. **Production Ready**: Configuration is more flexible for different deployment scenarios
## Security Considerations
- While CORS is set to allow all origins, the OAuth flow itself remains secure
- State parameter validation prevents CSRF attacks
- JWT tokens are still properly validated
- Webhook signature validation is in place (though secret needs to be configured)
## Usage
The service will now:
1. Accept OAuth requests from any domain
2. Automatically detect the client's origin for proper redirects
3. Handle webhooks with better logging and domain identification
4. Work seamlessly with the user's domain (`tdvorak.dev`) and any other domains
-50
View File
@@ -1,50 +0,0 @@
FROM golang:1.21-alpine AS builder
# Set the working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the source code
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o oauth-service main.go
# Final stage
FROM alpine:latest
# Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates
# Create a non-root user
RUN addgroup -g 1001 -S oauth && \
adduser -u 1001 -S oauth -G oauth
WORKDIR /app
# Copy the binary from builder stage
COPY --from=builder /app/oauth-service .
# Copy .env file if it exists
COPY --from=builder /app/.env.example .env
# Change ownership to non-root user
RUN chown -R oauth:oauth /app
# Switch to non-root user
USER oauth
# Expose port
EXPOSE 9090
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:9090/health || exit 1
# Run the binary
CMD ["./oauth-service"]
-66
View File
@@ -1,66 +0,0 @@
# TSX Integration Fixes Summary
## ✅ All Errors Fixed Successfully
### **TypeScript Configuration Fixed:**
- ✅ Removed problematic `solid-js/env` type from tsconfig.json
- ✅ Fixed all event handler type annotations
- ✅ Resolved null safety issues with event.currentTarget
### **Event Handler Fixes:**
- ✅ Added proper `MouseEvent` typing for onClick handlers
- ✅ Fixed HTMLElement casting for DOM queries
- ✅ Added null safety checks with optional chaining
### **Build System Fixed:**
- ✅ Renamed `.js` config files to `.cjs` for ES module compatibility
- ✅ Fixed PostCSS and TailwindCSS configuration
- ✅ All builds now pass without errors
### **Component Structure:**
- ✅ All TSX components properly typed with TypeScript
- ✅ SolidJS reactive signals working correctly
- ✅ Event handlers properly typed and functional
## 🚀 Final Status
**✅ TypeScript Check:** `npx tsc --noEmit` - No errors
**✅ Build:** `npm run build` - Successful
**✅ Dev Server:** `npm run dev` - Working
**✅ Backend:** `go run main.go` - Running successfully
**✅ Integration:** Full-stack system operational
## 📁 Project Structure
```
oauth-service/
├── src/
│ ├── components/
│ │ ├── Dashboard.tsx ✅ Fixed
│ │ ├── CourseManagement.tsx ✅ Fixed
│ │ └── InstanceManagement.tsx ✅ Fixed
│ ├── App.tsx ✅ Working
│ ├── index.tsx ✅ Working
│ └── styles.css ✅ Working
├── static/ ✅ Built frontend
├── main.go ✅ Backend running
├── tsconfig.json ✅ Fixed config
├── package.json ✅ Dependencies installed
└── dev.sh ✅ Development script
```
## 🎯 Ready to Use
**Development:**
```bash
./dev.sh # Starts both frontend (5174) and backend (9090)
```
**Production:**
```bash
npm run build && go run main.go
```
**Access:** http://localhost:9090/dashboard
All TypeScript errors have been resolved and the system is fully functional! 🎉
-283
View File
@@ -1,283 +0,0 @@
# Centralized OAuth Service
This is a **standalone OAuth service** that handles GitHub authentication and email verification for all users. Users never need to set up their own OAuth applications - everything is centralized.
## 🎯 **How It Works**
### **For Users:**
1. **GitHub OAuth**: Click "Connect GitHub" → GitHub authorization → Automatic login with GitHub profile
2. **Email Verification**: Enter email → Receive verification code → Verify email for 2FA
### **For Developers:**
1. **Zero setup** - No OAuth app creation needed
2. **Simple integration** - Just redirect to our service
3. **Secure authentication** - We handle all the complexity
4. **User management** - Centralized user database
## 🚀 **Quick Start**
### **1. Setup the OAuth Service**
```bash
# Navigate to the OAuth service
cd oauth-service
# Run the setup script
./setup.sh
# Edit the .env file with your GitHub OAuth credentials
nano .env
# Start the service
go run main.go
```
### **2. GitHub OAuth App Setup (One Time)**
1. Go to GitHub Settings → Developer settings → OAuth Apps
2. Create a new OAuth app with:
- **Application name**: Trackeep OAuth Service
- **Homepage URL**: `http://localhost:9090`
- **Authorization callback URL**: `http://localhost:9090/auth/github/callback`
3. Copy the Client ID and Client Secret to `.env`
### **3. Email Verification Setup (One Time)**
1. Configure smtp.purelymail.com for sending verification emails:
- **SMTP Host**: `smtp.purelymail.com`
- **SMTP Port**: `587`
- **Username**: Your purelymail SMTP username
- **Password**: Your purelymail SMTP password
2. Add SMTP credentials to `.env` file
3. The service will send 6-digit verification codes for 2FA
### **4. Integration in Your App**
```javascript
// Redirect to GitHub OAuth
const connectGitHub = () => {
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=' +
encodeURIComponent(window.location.origin);
};
// Send email verification code
const sendEmailVerification = (email) => {
fetch('http://localhost:9090/api/v1/email/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
}).then(response => response.json())
.then(data => {
if (data.demo_code) {
console.log('Demo verification code:', data.demo_code);
}
});
};
// Verify email code
const verifyEmailCode = (email, code) => {
fetch('http://localhost:9090/api/v1/email/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code })
}).then(response => response.json())
.then(data => {
if (data.verified) {
console.log('Email verified successfully!');
}
});
};
// Handle callback (works for both GitHub and Email)
const handleCallback = () => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const username = urlParams.get('user');
if (token) {
localStorage.setItem('token', token);
localStorage.setItem('username', username);
// Redirect to dashboard
window.location.href = '/app';
}
};
```
## 📡 **API Endpoints**
### **OAuth Endpoints:**
- `GET /auth/github` - Initiate GitHub OAuth flow
- `GET /auth/github/callback` - Handle GitHub callback
### **Email Verification Endpoints:**
- `POST /api/v1/email/send` - Send verification code to email
- `POST /api/v1/email/verify` - Verify email code for 2FA
### **API Endpoints:**
- `GET /api/v1/user/me` - Get current user info
- `GET /api/v1/user/:username/repos` - Get user repositories
- `POST /api/v1/webhook/github` - GitHub webhook handler
- `POST /api/v1/email/verify` - Verify email code
### **Utility:**
- `GET /health` - Service health check
## 🔧 **Configuration**
### **Environment Variables:**
```bash
# GitHub OAuth (Admin Only)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
# Email Verification Configuration (Admin Only)
SMTP_HOST=smtp.purelymail.com
SMTP_PORT=587
SMTP_USERNAME=your_purelymail_username
SMTP_PASSWORD=your_purelymail_password
# Service Configuration
PORT=9090
JWT_SECRET=your-super-secret-jwt-key
DEFAULT_CLIENT_URL=http://localhost:5173
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
```
## 🏗️ **Architecture**
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User App │ │ OAuth Service │ │ GitHub │
│ │ │ │ │ │
│ Connect GitHub ─┼───>│ /auth/github ────>│ OAuth Flow │
│ │ │ │ │ │
│ Handle Callback │<───>│ /auth/callback │<───>│ Return Token │
│ │ │ │ │ │
│ Store Token │ │ Generate JWT │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
```
## 🔒 **Security Features**
- **CSRF Protection**: State parameter validation
- **Secure JWT**: Signed tokens with expiration
- **CORS Support**: Configurable allowed origins
- **Webhook Support**: Optional webhook secret validation
- **Rate Limiting**: GitHub API rate limit awareness
## 📊 **User Management**
The service maintains a centralized user database:
```go
type User struct {
ID int `json:"id"`
GitHubID int `json:"github_id"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
LastLogin time.Time `json:"last_login"`
}
```
## 🔄 **Multi-Application Support**
The same OAuth service can serve multiple applications:
```javascript
// App 1
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app1.com';
// App 2
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app2.com';
// App 3
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app3.com';
```
## 🚀 **Production Deployment**
### **Docker Deployment:**
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download && go build -o oauth-service
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/oauth-service .
COPY .env .
EXPOSE 9090
CMD ["./oauth-service"]
```
### **Docker Compose:**
```yaml
version: '3.8'
services:
oauth-service:
build: ./oauth-service
ports:
- "9090:9090"
environment:
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
```
## 🛠️ **Development**
```bash
# Install dependencies
go mod tidy
# Run in development
go run main.go
# Build for production
go build -o oauth-service main.go
# Run tests
go test ./...
```
## 📝 **Benefits**
### **For Users:**
-**Zero configuration** - No OAuth app setup
-**Single sign-on** - One GitHub account for all apps
-**Secure** - Enterprise-grade security
-**Fast** - Instant authentication
### **For Developers:**
-**Easy integration** - Just redirect to our service
-**No OAuth management** - We handle everything
-**Centralized users** - Shared user database
-**Scalable** - Serve unlimited applications
### **For Administrators:**
-**Single control point** - Manage all OAuth in one place
-**Security oversight** - Monitor all authentication
-**Easy updates** - Update OAuth settings once
-**Cost effective** - One OAuth app for all services
## 🎯 **Use Cases**
- **SaaS platforms** - Multiple products, one authentication
- **Development teams** - Internal tools with GitHub login
- **Open source projects** - Contributor authentication
- **Enterprise** - Internal service authentication
- **API services** - Secure API access with GitHub OAuth
This service completely abstracts away OAuth complexity while providing enterprise-grade authentication for all your applications!
-308
View File
@@ -1,308 +0,0 @@
# Trackeep Main Controller
The **Trackeep Main Controller** is a centralized service that handles authentication, user management, and learning content management for all Trackeep instances. It transforms the original OAuth service into a comprehensive learning management system with a beautiful dashboard interface.
## 🛠️ **Tech Stack**
### **Backend:**
- **Go** - High-performance API server
- **Gin** - HTTP web framework
- **JWT** - Authentication tokens
- **OAuth2** - GitHub integration
### **Frontend:**
- **SolidJS** - Reactive UI framework
- **TypeScript** - Type-safe development
- **TailwindCSS** - Utility-first styling
- **Vite** - Fast build tool
### **Features:**
- **🔐 Centralized Authentication** - GitHub OAuth and email verification for all users
- **📚 Learning Management** - Create and manage free courses with YouTube, ZTM, GitHub, and Fireship resources
- **🖥️ Instance Management** - Register and monitor Trackeep instances
- **📊 Visual Dashboard** - Beautiful Trackeep-inspired UI for management
- **🔗 Secure Connections** - Automatic secure API key handling between instances
### **For Users:**
- **Free Learning** - All courses are completely free (price always $0.00)
- **No Instructors** - Self-paced learning with curated resources
- **Progress Tracking** - Monitor your learning progress across courses
- **Single Sign-On** - One GitHub account for all Trackeep instances
### **For Administrators:**
- **Course Creation** - Easy-to-use interface for creating learning paths
- **Resource Management** - Support for YouTube, Zero to Mastery, GitHub, Fireship links
- **Instance Monitoring** - Track all connected Trackeep instances
- **User Analytics** - Dashboard with comprehensive statistics
## 🚀 **Quick Start**
### **1. Setup the Main Controller**
```bash
# Navigate to the main controller
cd oauth-service
# Install frontend dependencies
npm install
# Build the frontend
npm run build
# Run the service (production mode)
go run main.go
```
### **2. Development Mode**
For development with hot reload:
```bash
# Use the development script (starts both backend and frontend)
./dev.sh
# Or start manually:
# Terminal 1: Backend
go run main.go
# Terminal 2: Frontend dev server
npm run dev
```
### **3. Access the Dashboard**
Open your browser to:
- **Dashboard**: http://localhost:9090/dashboard (production) or http://localhost:5174/dashboard (development)
- **Course Management**: http://localhost:9090/dashboard/courses
- **Instance Management**: http://localhost:9090/dashboard/instances
- **API Documentation**: http://localhost:9090/api/v1
### **4. GitHub OAuth Setup (Optional)**
For full authentication, set up GitHub OAuth:
1. Go to GitHub Settings → Developer settings → OAuth Apps
2. Create a new OAuth app with:
- **Application name**: Trackeep Main Controller
- **Homepage URL**: `http://localhost:9090`
- **Authorization callback URL**: `http://localhost:9090/auth/github/callback`
3. Add credentials to `.env` file
## 📡 **API Endpoints**
### **Authentication:**
- `GET /auth/github` - Initiate GitHub OAuth flow
- `GET /auth/github/callback` - Handle GitHub callback
- `POST /api/v1/email/send` - Send verification code
- `POST /api/v1/email/verify` - Verify email code
### **Course Management:**
- `GET /api/v1/courses` - List all courses
- `POST /api/v1/courses` - Create new course
- `GET /api/v1/courses/:id` - Get course details
- `PUT /api/v1/courses/:id` - Update course
- `DELETE /api/v1/courses/:id` - Delete course
- `GET /api/v1/courses/:id/resources` - Get course resources
- `POST /api/v1/courses/:id/resources` - Add course resource
### **User Progress:**
- `GET /api/v1/progress/:user_id` - Get user's all progress
- `GET /api/v1/progress/:user_id/:course_id` - Get course progress
- `POST /api/v1/progress/:user_id/:course_id` - Update progress
### **Instance Management:**
- `GET /api/v1/instances` - List all instances
- `POST /api/v1/instances` - Register new instance
- `GET /api/v1/instances/:id` - Get instance details
- `PUT /api/v1/instances/:id` - Update instance
- `DELETE /api/v1/instances/:id` - Delete instance
### **Dashboard:**
- `GET /api/v1/dashboard/stats` - Get dashboard statistics
- `GET /api/v1/dashboard/courses` - Get courses for dashboard
- `GET /api/v1/dashboard/users` - Get users for dashboard (admin only)
## 🏗️ **Architecture**
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Trackeep App │ │ Main Controller │ │ GitHub API │
│ │ │ │ │ │
│ OAuth Login ────┼───>│ /auth/github ────>│ OAuth Flow │
│ │ │ │ │ │
│ Course API ─────┼───>│ /api/v1/courses │ │ │
│ │ │ │ │ │
│ Progress Sync ──┼───>│ /api/v1/progress │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
```
## 📚 **Course Structure**
### **Supported Resource Types:**
- **🎥 YouTube** - Video tutorials and playlists
- **🎓 Zero to Mastery** - ZTM courses and content
- **🐙 GitHub** - Repositories, projects, and code examples
- **🔥 Fireship** - Fast-paced tutorials and courses
- **🔗 Links** - Any other web resources
### **Course Example:**
```json
{
"title": "Complete Web Development Bootcamp",
"description": "Learn modern web development from scratch",
"category": "web-development",
"difficulty": "beginner",
"duration": 40,
"price": 0.0,
"tags": ["javascript", "react", "nodejs"],
"resources": [
{
"title": "Introduction to Web Development",
"type": "youtube",
"url": "https://www.youtube.com/watch?v=RW-sB6GeA_Q",
"duration": 45,
"is_required": true
}
]
}
```
## 🔒 **Security Features**
- **🔐 JWT Authentication** - Secure token-based authentication
- **🛡️ API Key Management** - Automatic secure key generation for instances
- **🔗 CORS Support** - Configurable allowed origins
- **✅ CSRF Protection** - State parameter validation
- **📊 Rate Limiting** - GitHub API rate limit awareness
## 🎨 **Dashboard Features**
### **Main Dashboard:**
- 📊 Real-time statistics
- 📚 Recent courses overview
- 🖥️ Active instances monitoring
- 📈 User progress analytics
### **Course Management:**
- Easy course creation wizard
- ✏️ Visual course editing
- 🏷️ Tag-based organization
- 📱 Responsive design
### **Instance Management:**
- 🔗 Secure instance registration
- 📊 Connection status monitoring
- 🔑 API key management
- 📈 Instance analytics
## 🔧 **Configuration**
### **Environment Variables:**
```bash
# Service Configuration
PORT=9090
JWT_SECRET=your-super-secret-jwt-key
# GitHub OAuth (Optional)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
# Email Verification (Optional)
SMTP_HOST=smtp.purelymail.com
SMTP_PORT=587
SMTP_USERNAME=your_purelymail_username
SMTP_PASSWORD=your_purelymail_password
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
```
## 🚀 **Production Deployment**
### **Docker Deployment:**
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download && go build -o trackeep-controller
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/trackeep-controller .
COPY .env .
COPY templates/ ./templates/
EXPOSE 9090
CMD ["./trackeep-controller"]
```
### **Docker Compose:**
```yaml
version: '3.8'
services:
trackeep-controller:
build: ./oauth-service
ports:
- "9090:9090"
environment:
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
```
## 📝 **Benefits**
### **For Learners:**
-**Completely Free** - All courses are $0.00
-**Self-Paced** - Learn at your own speed
-**Quality Content** - Curated YouTube, ZTM, GitHub, Fireship resources
-**Progress Tracking** - Monitor your learning journey
-**Single Sign-On** - One account for all Trackeep instances
### **For Administrators:**
-**Easy Management** - Beautiful dashboard interface
-**Secure Connections** - Automatic API key handling
-**Scalable** - Serve unlimited instances
-**Analytics** - Comprehensive usage statistics
-**Zero Setup** - Works out of the box with sample data
### **For Developers:**
-**RESTful API** - Clean, well-documented endpoints
-**Flexible Resources** - Support for multiple content types
-**Secure by Default** - Built-in authentication and authorization
-**Easy Integration** - Simple API key-based connections
## 🎯 **Use Cases**
- **🎓 Educational Platforms** - Free learning management system
- **👥 Developer Communities** - Share learning resources
- **🏢 Corporate Training** - Internal skill development
- **📚 Course Aggregators** - Curate learning content
- **🚀 Startup Education** - Onboarding and training programs
## 🔄 **Multi-Instance Support**
The Main Controller can serve multiple Trackeep instances:
```javascript
// Instance 1
fetch('http://localhost:9090/api/v1/courses', {
headers: { 'Authorization': 'Bearer instance1_api_key' }
});
// Instance 2
fetch('http://localhost:9090/api/v1/courses', {
headers: { 'Authorization': 'Bearer instance2_api_key' }
});
```
Each instance gets its own API key and can securely access the centralized course catalog and user management.
---
**Trackeep Main Controller** - Complete learning management system with beautiful dashboard and secure multi-instance support. 🚀
@@ -1,198 +0,0 @@
# Trackeep Integration Guide
## Architecture Overview
This OAuth service is designed **only for authentication**. Trackeep instances (user-hosted) handle all GitHub data tracking directly.
## How It Works
### 1. User Authentication Flow
1. User clicks "Login with GitHub" in Trackeep
2. Trackeep redirects to: `https://oauth.tdvorak.dev/auth/github?redirect_uri=https://user-trackeep-instance.com`
3. OAuth service handles GitHub authentication
4. OAuth service redirects back: `https://user-trackeep-instance.com/auth/callback?token=JWT&user=username`
### 2. What Trackeep Receives
The JWT token contains:
```json
{
"user_id": 123,
"github_id": 456789,
"username": "johndoe",
"email": "john@example.com",
"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
"token_type": "bearer",
"expires_at": 1738123456,
"exp": 1738123456,
"iat": 1737518656
}
```
### 3. Trackeep GitHub API Access
Trackeep instances can now make GitHub API calls using the user's `access_token`:
```javascript
// Example: Get user repositories
const response = await fetch('https://api.github.com/user/repos', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json'
}
});
// Example: Get commits for a repo
const commits = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json'
}
});
```
## Trackeep Implementation Guide
### 1. OAuth Login Button
```html
<a href="https://oauth.tdvorak.dev/auth/github?redirect_uri=https://your-trackeep-instance.com">
Login with GitHub
</a>
```
### 2. Handle OAuth Callback
```javascript
// In your /auth/callback route
async function handleOAuthCallback(req, res) {
const { token, user: username } = req.query;
// Decode and verify JWT
const jwtPayload = decodeJWT(token);
// Store user session
req.session.user = {
id: jwtPayload.user_id,
username: jwtPayload.username,
email: jwtPayload.email,
githubAccessToken: jwtPayload.access_token,
tokenType: jwtPayload.token_type,
expiresAt: jwtPayload.expires_at
};
// Redirect to dashboard
res.redirect('/dashboard');
}
```
### 3. GitHub API Helper
```javascript
class GitHubAPI {
constructor(accessToken) {
this.accessToken = accessToken;
}
async makeRequest(url) {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Accept': 'application/vnd.github.v3+json'
}
});
return response.json();
}
async getUserRepos() {
return this.makeRequest('https://api.github.com/user/repos');
}
async getRepoCommits(owner, repo) {
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/commits`);
}
async getRepoPulls(owner, repo) {
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/pulls`);
}
async getBranches(owner, repo) {
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/branches`);
}
}
```
### 4. Track Data Collection
```javascript
// Example: Track repository activity
async function trackRepositoryActivity(user, repoFullName) {
const [owner, repo] = repoFullName.split('/');
const github = new GitHubAPI(user.githubAccessToken);
// Get commits
const commits = await github.getRepoCommits(owner, repo);
// Get pull requests
const pulls = await github.getRepoPulls(owner, repo);
// Store in your local database
await storeActivityData({
userId: user.id,
repo: repoFullName,
commits: commits.length,
pullRequests: pulls.length,
lastActivity: new Date()
});
}
```
## Security Considerations
### 1. Token Storage
- Store GitHub access tokens securely (encrypted at rest)
- Never expose tokens in client-side JavaScript
- Use secure, HTTP-only cookies for session management
### 2. Token Expiration
- Monitor `expires_at` field in JWT
- Refresh tokens before expiration if needed
- Handle token expiry gracefully
### 3. Rate Limiting
- GitHub API has rate limits (5,000 requests/hour for authenticated users)
- Implement caching to reduce API calls
- Handle rate limit responses (HTTP 429)
## Available GitHub Scopes
The OAuth service requests these scopes:
- `user:email` - Read user email addresses
- `read:user` - Read user profile data
- `repo` - Access to repositories (full control)
This allows Trackeep instances to:
- Read repository data
- Access commit history
- Monitor pull requests
- Track branch activity
## API Endpoints
### OAuth Service
- `GET /auth/github` - Initiate OAuth flow
- `GET /auth/github/callback` - Handle GitHub callback
- `GET /api/v1/user/me` - Get current user info
### GitHub API (via access token)
- `GET /user/repos` - User repositories
- `GET /repos/{owner}/{repo}/commits` - Repository commits
- `GET /repos/{owner}/{repo}/pulls` - Pull requests
- `GET /repos/{owner}/{repo}/branches` - Branches
- And all other GitHub API endpoints
## Benefits of This Architecture
1. **Separation of Concerns** - OAuth service only handles authentication
2. **User Privacy** - GitHub data stays in user's Trackeep instance
3. **Scalability** - Each user instance handles its own GitHub API calls
4. **Security** - No centralized GitHub data storage
5. **Flexibility** - Trackeep can implement custom tracking logic
## Example Implementation
See the `examples/` directory for complete implementation examples in different frameworks.
-53
View File
@@ -1,53 +0,0 @@
#!/bin/bash
# Trackeep Main Controller Development Script
# This script starts both the backend API server and frontend dev server
echo "🚀 Starting Trackeep Main Controller Development Environment..."
# Check if we're in the right directory
if [ ! -f "main.go" ]; then
echo "❌ Error: Please run this script from the oauth-service directory"
exit 1
fi
# Start backend server in background
echo "🔧 Starting backend API server on port 9090..."
go run main.go &
BACKEND_PID=$!
# Wait a moment for backend to start
sleep 2
# Start frontend dev server
echo "🎨 Starting frontend dev server on port 5174..."
npm run dev &
FRONTEND_PID=$!
echo ""
echo "✅ Trackeep Main Controller is running!"
echo ""
echo "📊 Dashboard: http://localhost:5174/dashboard"
echo "📚 Courses: http://localhost:5174/dashboard/courses"
echo "🖥️ Instances: http://localhost:5174/dashboard/instances"
echo "🔧 API: http://localhost:9090/api/v1"
echo "💚 Health Check: http://localhost:9090/health"
echo ""
echo "Press Ctrl+C to stop both servers"
echo ""
# Function to kill both processes on exit
cleanup() {
echo ""
echo "🛑 Stopping servers..."
kill $BACKEND_PID 2>/dev/null
kill $FRONTEND_PID 2>/dev/null
echo "✅ All servers stopped"
exit 0
}
# Set up trap to kill processes on Ctrl+C
trap cleanup INT
# Wait for both processes
wait
-49
View File
@@ -1,49 +0,0 @@
version: '3.8'
services:
oauth-service:
build: .
container_name: github-oauth-service
ports:
- "9090:9090"
environment:
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- PORT=9090
- GIN_MODE=release
- CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080,https://yourdomain.com
- DEFAULT_CLIENT_URL=http://localhost:5173
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
volumes:
- ./.env:/app/.env:ro
restart: unless-stopped
networks:
- oauth-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: Redis for session storage (for production)
redis:
image: redis:7-alpine
container_name: oauth-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: unless-stopped
networks:
- oauth-network
command: redis-server --appendonly yes
volumes:
redis-data:
networks:
oauth-network:
driver: bridge
-39
View File
@@ -1,39 +0,0 @@
module trackeep-main-controller
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/joho/godotenv v1.4.0
golang.org/x/oauth2 v0.8.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-104
View File
@@ -1,104 +0,0 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
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-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
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/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/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
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/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
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/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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trackeep Main Controller</title>
</head>
<body>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "trackeep-main-controller-ui",
"version": "1.0.0",
"description": "Trackeep Main Controller Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"solid-js": "^1.8.7",
"@solidjs/router": "^0.8.3",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.8.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-48
View File
@@ -1,48 +0,0 @@
#!/bin/bash
# GitHub OAuth Service Setup Script
echo "🚀 Setting up GitHub OAuth Service..."
# Create directory if it doesn't exist
mkdir -p oauth-service
cd oauth-service
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo "❌ Go is not installed. Please install Go first."
exit 1
fi
# Initialize Go module
echo "📦 Initializing Go module..."
go mod init github-oauth-service
# Install dependencies
echo "📥 Installing dependencies..."
go get github.com/gin-gonic/gin
go get github.com/golang-jwt/jwt/v5
go get github.com/joho/godotenv
go get golang.org/x/oauth2
# Create .env file if it doesn't exist
if [ ! -f .env ]; then
echo "📝 Creating .env file from template..."
cp .env.example .env
echo "⚠️ Please edit .env file with your GitHub OAuth credentials"
fi
# Make the service executable
chmod +x main.go
echo "✅ GitHub OAuth Service setup complete!"
echo ""
echo "📋 Next steps:"
echo "1. Edit oauth-service/.env with your GitHub OAuth credentials"
echo "2. Run: cd oauth-service && go run main.go"
echo "3. Service will start on port 9090"
echo ""
echo "🔗 OAuth endpoints:"
echo "- Initiate: http://localhost:9090/auth/github"
echo "- Callback: http://localhost:9090/auth/github/callback"
echo "- Health: http://localhost:9090/health"
-18
View File
@@ -1,18 +0,0 @@
import { Router, Route } from '@solidjs/router';
import { Dashboard } from './components/Dashboard';
import { CourseManagement } from './components/CourseManagement';
import { InstanceManagement } from './components/InstanceManagement';
import './styles.css';
function App() {
return (
<Router>
<Route path="/" component={Dashboard} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/dashboard/courses" component={CourseManagement} />
<Route path="/dashboard/instances" component={InstanceManagement} />
</Router>
);
}
export default App;
@@ -1,537 +0,0 @@
import { createSignal, onMount, For, Show } from 'solid-js';
interface Course {
id: number;
title: string;
description: string;
category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
duration: number;
price: number;
thumbnail: string;
tags: string[];
resources: CourseResource[];
created_at: string;
updated_at: string;
created_by: number;
is_active: boolean;
}
interface CourseResource {
id: number;
course_id: number;
title: string;
type: 'youtube' | 'ztm' | 'github' | 'fireship' | 'link';
url: string;
description: string;
duration: number;
order: number;
is_required: boolean;
}
interface Instance {
id: number;
name: string;
url: string;
api_key: string;
is_active: boolean;
version: string;
created_at: string;
last_sync: string;
admin_user_id: number;
}
export const CourseManagement = () => {
const [courses, setCourses] = createSignal<Course[]>([]);
const [instances, setInstances] = createSignal<Instance[]>([]);
const [loading, setLoading] = createSignal(true);
const [showModal, setShowModal] = createSignal(false);
const [editingCourse, setEditingCourse] = createSignal<Course | null>(null);
const [tags, setTags] = createSignal<string[]>([]);
const [resources, setResources] = createSignal<CourseResource[]>([]);
const [tagInput, setTagInput] = createSignal('');
// Form state
const [formData, setFormData] = createSignal({
title: '',
category: '',
difficulty: '' as 'beginner' | 'intermediate' | 'advanced' | '',
duration: '',
description: '',
});
const categories = [
'programming',
'design',
'business',
'marketing',
'data-science',
'web-development',
'mobile-development',
'devops',
'other'
];
const resourceTypes = [
{ value: 'youtube', label: 'YouTube', color: '#ff0000' },
{ value: 'ztm', label: 'ZTM', color: '#3b82f6' },
{ value: 'github', label: 'GitHub', color: '#333' },
{ value: 'fireship', label: 'Fireship', color: '#f59e0b' },
{ value: 'link', label: 'Link', color: '#6b7280' }
];
onMount(async () => {
await loadCourses();
await loadInstances();
});
const loadCourses = async () => {
try {
const response = await fetch('/api/v1/courses');
const data = await response.json();
setCourses(data.courses || []);
} catch (error) {
console.error('Error loading courses:', error);
} finally {
setLoading(false);
}
};
const loadInstances = async () => {
try {
const response = await fetch('/api/v1/instances');
const data = await response.json();
setInstances(data.instances || []);
} catch (error) {
console.error('Error loading instances:', error);
}
};
const openCreateModal = () => {
setEditingCourse(null);
setFormData({
title: '',
category: '',
difficulty: '',
duration: '',
description: '',
});
setTags([]);
setResources([]);
setShowModal(true);
};
const openEditModal = (course: Course) => {
setEditingCourse(course);
setFormData({
title: course.title,
category: course.category,
difficulty: course.difficulty,
duration: course.duration.toString(),
description: course.description,
});
setTags(course.tags || []);
setResources(course.resources || []);
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditingCourse(null);
setTags([]);
setResources([]);
};
const addTag = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
const value = tagInput().trim();
if (value && !tags().includes(value)) {
setTags([...tags(), value]);
setTagInput('');
}
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags().filter(tag => tag !== tagToRemove));
};
const addResource = () => {
setResources([...resources(), {
id: Date.now(),
course_id: editingCourse()?.id || 0,
title: '',
type: 'link',
url: '',
description: '',
duration: 0,
order: resources().length + 1,
is_required: false
}]);
};
const updateResource = (index: number, field: keyof CourseResource, value: any) => {
const updatedResources = [...resources()];
updatedResources[index] = { ...updatedResources[index], [field]: value };
setResources(updatedResources);
};
const removeResource = (index: number) => {
setResources(resources().filter((_, i) => i !== index));
};
const saveCourse = async () => {
try {
const courseData = {
...formData(),
duration: parseInt(formData().duration),
tags: tags(),
resources: resources()
};
const url = editingCourse() ? `/api/v1/courses/${editingCourse()!.id}` : '/api/v1/courses';
const method = editingCourse() ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(courseData)
});
if (response.ok) {
closeModal();
await loadCourses();
} else {
const error = await response.json();
alert('Error: ' + (error.error || 'Failed to save course'));
}
} catch (error) {
console.error('Error saving course:', error);
alert('Error: Failed to save course');
}
};
const deleteCourse = async (courseId: number) => {
if (!confirm('Are you sure you want to delete this course?')) return;
try {
const response = await fetch(`/api/v1/courses/${courseId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadCourses();
} else {
const error = await response.json();
alert('Error: ' + (error.error || 'Failed to delete course'));
}
} catch (error) {
console.error('Error deleting course:', error);
alert('Error: Failed to delete course');
}
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'beginner': return 'bg-green-100 text-green-800';
case 'intermediate': return 'bg-orange-100 text-orange-800';
case 'advanced': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
<div class="max-w-7xl mx-auto">
{/* Header */}
<header class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
T
</div>
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
</div>
<nav class="flex gap-2">
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Courses</a>
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
</nav>
</div>
</header>
{/* Main Content */}
<div class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold text-gray-900">Course Management</h2>
<button
onClick={openCreateModal}
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
>
<span>+</span> Create New Course
</button>
</div>
<Show when={loading()} fallback={
<Show when={courses().length > 0} fallback={
<div class="text-center py-16 text-gray-500">
<div class="text-6xl mb-4 opacity-50">📚</div>
<div class="text-xl font-semibold mb-2">No courses yet</div>
<p>Create your first learning course to get started!</p>
</div>
}>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={courses()}>
{(course) => (
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden group">
<div class="h-48 bg-gradient-to-r from-indigo-500 to-purple-600 relative">
<div class="absolute inset-0 flex items-center justify-center text-white text-5xl font-bold">
{course.title.charAt(0).toUpperCase()}
</div>
<div class="absolute top-4 right-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-sm font-semibold text-gray-900">
FREE
</div>
</div>
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">{course.title}</h3>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">{course.description}</p>
<div class="flex justify-between items-center mb-4 text-sm text-gray-500">
<span>{course.category}</span>
<span class={`px-2 py-1 rounded-full text-xs font-medium ${getDifficultyColor(course.difficulty)}`}>
{course.difficulty}
</span>
<span>{course.duration}h</span>
</div>
<div class="flex gap-2">
<button
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
>
👁 View
</button>
<button
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
onClick={() => openEditModal(course)}
>
Edit
</button>
<button
class="flex-1 px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors text-sm"
onClick={() => deleteCourse(course.id)}
>
🗑 Delete
</button>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
}>
<div class="text-center py-8 text-gray-500">Loading courses...</div>
</Show>
</div>
</div>
{/* Course Modal */}
<Show when={showModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-semibold text-gray-900">
{editingCourse() ? 'Edit Course' : 'Create New Course'}
</h3>
<button
onClick={closeModal}
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
>
&times;
</button>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Course Title *</label>
<input
type="text"
value={formData().title}
onInput={(e) => setFormData({ ...formData(), title: e.currentTarget.value })}
placeholder="Course Title"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Category *</label>
<select
value={formData().category}
onChange={(e) => setFormData({ ...formData(), category: e.currentTarget.value })}
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
>
<option value="">Select Category</option>
<For each={categories}>
{(category) => <option value={category}>{category}</option>}
</For>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Difficulty *</label>
<select
value={formData().difficulty}
onChange={(e) => setFormData({ ...formData(), difficulty: e.currentTarget.value as any })}
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
>
<option value="">Select Difficulty</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (hours) *</label>
<input
type="number"
value={formData().duration}
onInput={(e) => setFormData({ ...formData(), duration: e.currentTarget.value })}
min="1"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Description *</label>
<textarea
value={formData().description}
onInput={(e) => setFormData({ ...formData(), description: e.currentTarget.value })}
placeholder="Course description"
rows={4}
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Tags (press Enter to add)</label>
<div class="flex flex-wrap gap-2 p-3 border-2 border-gray-200 rounded-lg min-h-[50px] cursor-text" onClick={(e: MouseEvent) => {
const target = e.currentTarget as HTMLElement;
const input = target.querySelector('input') as HTMLInputElement;
input?.focus();
}}>
<For each={tags()}>
{(tag) => (
<span class="bg-indigo-500 text-white px-2 py-1 rounded-md text-sm flex items-center gap-1">
{tag}
<button type="button" onClick={() => removeTag(tag)} class="font-bold">&times;</button>
</span>
)}
</For>
<input
type="text"
value={tagInput()}
onInput={(e) => setTagInput(e.currentTarget.value)}
onKeyDown={addTag}
placeholder="Add tags..."
class="border-none outline-none flex-1 min-w-[100px] p-1"
/>
</div>
</div>
<div>
<div class="flex justify-between items-center mb-4">
<h4 class="text-lg font-medium text-gray-900">Course Resources</h4>
<button
type="button"
onClick={addResource}
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<span>+</span> Add Resource
</button>
</div>
<div class="space-y-3">
<For each={resources()}>
{(resource, index) => (
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<div class="flex-1 space-y-2">
<input
type="text"
placeholder="Resource Title"
value={resource.title}
onInput={(e) => updateResource(index(), 'title', e.currentTarget.value)}
class="w-full p-2 border border-gray-200 rounded-md"
/>
<div class="flex gap-2">
<select
value={resource.type}
onChange={(e) => updateResource(index(), 'type', e.currentTarget.value)}
class="p-2 border border-gray-200 rounded-md"
>
<For each={resourceTypes}>
{(type) => <option value={type.value}>{type.label}</option>}
</For>
</select>
<input
type="url"
placeholder="URL"
value={resource.url}
onInput={(e) => updateResource(index(), 'url', e.currentTarget.value)}
class="flex-1 p-2 border border-gray-200 rounded-md"
/>
<input
type="number"
placeholder="Duration (min)"
value={resource.duration}
onInput={(e) => updateResource(index(), 'duration', parseInt(e.currentTarget.value) || 0)}
class="w-24 p-2 border border-gray-200 rounded-md"
/>
</div>
</div>
<button
type="button"
onClick={() => removeResource(index())}
class="px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50"
>
&times;
</button>
</div>
)}
</For>
</div>
</div>
<div class="flex gap-3 justify-end">
<button
type="button"
onClick={closeModal}
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={saveCourse}
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
>
Save Course
</button>
</div>
</div>
</div>
</div>
</Show>
</div>
);
};
@@ -1,262 +0,0 @@
import { createSignal, onMount, For, Show } from 'solid-js';
interface DashboardStats {
total_users: number;
total_courses: number;
total_instances: number;
active_courses: number;
total_progress: number;
}
interface Course {
id: number;
title: string;
category: string;
difficulty: string;
duration: number;
thumbnail: string;
created_at: string;
is_active: boolean;
}
interface Instance {
id: number;
name: string;
url: string;
version: string;
is_active: boolean;
created_at: string;
last_sync: string;
api_key: string;
}
export const Dashboard = () => {
const [stats, setStats] = createSignal<DashboardStats>({
total_users: 0,
total_courses: 0,
total_instances: 0,
active_courses: 0,
total_progress: 0
});
const [courses, setCourses] = createSignal<Course[]>([]);
const [instances, setInstances] = createSignal<Instance[]>([]);
const [loading, setLoading] = createSignal(true);
onMount(async () => {
await Promise.all([
loadStats(),
loadCourses(),
loadInstances()
]);
setLoading(false);
});
const loadStats = async () => {
try {
const response = await fetch('/api/v1/dashboard/stats');
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Error loading stats:', error);
}
};
const loadCourses = async () => {
try {
const response = await fetch('/api/v1/dashboard/courses');
const data = await response.json();
setCourses(data.courses || []);
} catch (error) {
console.error('Error loading courses:', error);
}
};
const loadInstances = async () => {
try {
const response = await fetch('/api/v1/instances');
const data = await response.json();
setInstances(data.instances || []);
} catch (error) {
console.error('Error loading instances:', error);
}
};
const formatDate = (dateString: string) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'beginner': return 'bg-green-100 text-green-800';
case 'intermediate': return 'bg-orange-100 text-orange-800';
case 'advanced': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
<div class="max-w-7xl mx-auto">
{/* Header */}
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
T
</div>
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
</div>
<nav class="flex gap-2">
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Dashboard</a>
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
</nav>
</div>
</header>
{/* Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
<div class="w-12 h-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
👥
</div>
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_users}</div>
<div class="text-gray-600 font-medium">Total Users</div>
</div>
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
<div class="w-12 h-12 bg-gradient-to-r from-green-500 to-green-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
📚
</div>
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().active_courses}</div>
<div class="text-gray-600 font-medium">Active Courses</div>
</div>
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
<div class="w-12 h-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
🖥
</div>
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_instances}</div>
<div class="text-gray-600 font-medium">Connected Instances</div>
</div>
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
<div class="w-12 h-12 bg-gradient-to-r from-orange-500 to-orange-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
📈
</div>
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_progress}</div>
<div class="text-gray-600 font-medium">Learning Progress</div>
</div>
</div>
{/* Main Content */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Recent Courses */}
<div class="lg:col-span-2">
<div class="glass rounded-2xl p-6 shadow-xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900">Recent Courses</h2>
<a href="/dashboard/courses" class="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors">
Manage Courses
</a>
</div>
<Show when={loading()} fallback={
<Show when={courses().length > 0} fallback={
<div class="text-center py-12 text-gray-500">
<div class="text-5xl mb-4 opacity-50">📚</div>
<div class="text-lg font-semibold mb-2">No courses yet</div>
<p>Create your first course to get started!</p>
</div>
}>
<div class="space-y-4">
<For each={courses().slice(0, 5)}>
{(course) => (
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="w-12 h-12 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold">
{course.title.charAt(0).toUpperCase()}
</div>
<div class="flex-1">
<div class="font-medium text-gray-900">{course.title}</div>
<div class="text-sm text-gray-600">{course.category} {course.difficulty} {course.duration}h</div>
</div>
<div class="flex gap-2">
<button
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
title="View"
>
👁
</button>
<button
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
onClick={() => window.location.href = `/dashboard/courses?edit=${course.id}`}
title="Edit"
>
</button>
</div>
</div>
)}
</For>
</div>
</Show>
}>
<div class="text-center py-8 text-gray-500">Loading courses...</div>
</Show>
</div>
</div>
{/* Active Instances */}
<div>
<div class="glass rounded-2xl p-6 shadow-xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900">Active Instances</h2>
<a href="/dashboard/instances" class="text-indigo-600 hover:text-indigo-700 text-sm font-medium">
View All
</a>
</div>
<Show when={loading()} fallback={
<Show when={instances().length > 0} fallback={
<div class="text-center py-12 text-gray-500">
<div class="text-5xl mb-4 opacity-50">🖥</div>
<div class="text-lg font-semibold mb-2">No instances</div>
<p>Register your first instance to get started!</p>
</div>
}>
<div class="space-y-3">
<For each={instances().slice(0, 3)}>
{(instance) => (
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
<div class="flex-1">
<div class="font-medium text-gray-900">{instance.name}</div>
<div class="text-sm text-gray-600">{instance.version}</div>
</div>
<button
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
onClick={() => window.open(`/api/v1/instances/${instance.id}`, '_blank')}
title="View"
>
🔗
</button>
</div>
)}
</For>
</div>
</Show>
}>
<div class="text-center py-8 text-gray-500">Loading instances...</div>
</Show>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -1,388 +0,0 @@
import { createSignal, onMount, For, Show } from 'solid-js';
interface Instance {
id: number;
name: string;
url: string;
api_key: string;
is_active: boolean;
version: string;
created_at: string;
last_sync: string;
admin_user_id: number;
}
export const InstanceManagement = () => {
const [instances, setInstances] = createSignal<Instance[]>([]);
const [loading, setLoading] = createSignal(true);
const [showModal, setShowModal] = createSignal(false);
const [editingInstance, setEditingInstance] = createSignal<Instance | null>(null);
// Form state
const [formData, setFormData] = createSignal({
name: '',
url: '',
version: ''
});
onMount(async () => {
await loadInstances();
setLoading(false);
});
const loadInstances = async () => {
try {
const response = await fetch('/api/v1/instances');
const data = await response.json();
setInstances(data.instances || []);
} catch (error) {
console.error('Error loading instances:', error);
}
};
const openCreateModal = () => {
setEditingInstance(null);
setFormData({
name: '',
url: '',
version: ''
});
setShowModal(true);
};
const openEditModal = (instance: Instance) => {
setEditingInstance(instance);
setFormData({
name: instance.name,
url: instance.url,
version: instance.version || ''
});
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setEditingInstance(null);
};
const saveInstance = async () => {
try {
const url = editingInstance() ? `/api/v1/instances/${editingInstance()!.id}` : '/api/v1/instances';
const method = editingInstance() ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(formData())
});
if (response.ok) {
closeModal();
await loadInstances();
if (!editingInstance()) {
const result = await response.json();
if (result.api_key) {
alert(`🎉 Instance registered successfully!\n\nAPI Key: ${result.api_key}\n\nSave this key securely - it will not be shown again.`);
}
}
} else {
const error = await response.json();
alert('Error: ' + (error.error || 'Failed to save instance'));
}
} catch (error) {
console.error('Error saving instance:', error);
alert('Error: Failed to save instance');
}
};
const deleteInstance = async (instanceId: number) => {
if (!confirm('Are you sure you want to delete this instance? This action cannot be undone.')) return;
try {
const response = await fetch(`/api/v1/instances/${instanceId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadInstances();
} else {
const error = await response.json();
alert('Error: ' + (error.error || 'Failed to delete instance'));
}
} catch (error) {
console.error('Error deleting instance:', error);
alert('Error: Failed to delete instance');
}
};
const testConnection = async (instance: Instance) => {
try {
const response = await fetch(`${instance.url}/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
alert('✅ Connection successful! Instance is responding.');
} else {
alert('❌ Connection failed. Instance returned an error.');
}
} catch (error) {
alert('❌ Connection failed. Unable to reach the instance.');
}
};
const copyApiKey = (apiKey: string, event: MouseEvent) => {
navigator.clipboard.writeText(apiKey).then(() => {
// Show feedback (you could implement a toast here)
const btn = event.target as HTMLButtonElement;
const originalText = btn.textContent;
btn.textContent = 'Copied!';
(btn as HTMLButtonElement).style.background = '#10b981';
setTimeout(() => {
btn.textContent = originalText;
(btn as HTMLButtonElement).style.background = '';
}, 2000);
});
};
const formatDate = (dateString: string) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
return (
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
<div class="max-w-7xl mx-auto">
{/* Header */}
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
T
</div>
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
</div>
<nav class="flex gap-2">
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Instances</a>
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
</nav>
</div>
</header>
{/* Main Content */}
<div class="glass rounded-2xl p-6 shadow-xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold text-gray-900">Instance Management</h2>
<button
onClick={openCreateModal}
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
>
<span>+</span> Register New Instance
</button>
</div>
<Show when={loading()} fallback={
<Show when={instances().length > 0} fallback={
<div class="text-center py-16 text-gray-500">
<div class="text-6xl mb-4 opacity-50">🖥</div>
<div class="text-xl font-semibold mb-2">No instances registered</div>
<p>Register your first Trackeep instance to get started!</p>
</div>
}>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={instances()}>
{(instance) => (
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden relative">
<div class={`absolute top-4 right-4 w-3 h-3 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'} ${instance.is_active ? 'animate-pulse' : ''}`}></div>
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 mb-1">{instance.name}</h3>
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
class="text-indigo-600 hover:text-indigo-700 text-sm mb-2 block"
>
{instance.url}
</a>
<div class="flex items-center gap-2 text-sm text-gray-600">
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>{instance.is_active ? 'Active' : 'Inactive'}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Version</div>
<div class="text-sm font-medium text-gray-900">{instance.version || 'Unknown'}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Created</div>
<div class="text-sm font-medium text-gray-900">{formatDate(instance.created_at)}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Last Sync</div>
<div class="text-sm font-medium text-gray-900">{formatDate(instance.last_sync)}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Instance ID</div>
<div class="text-sm font-medium text-gray-900">#{instance.id}</div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-3 mb-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">API Key</div>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={instance.api_key}
class="flex-1 text-xs font-mono bg-transparent border-none outline-none text-gray-600"
/>
<button
onClick={(e: MouseEvent) => copyApiKey(instance.api_key, e)}
class="px-2 py-1 bg-indigo-500 text-white text-xs rounded hover:bg-indigo-600 transition-colors"
>
Copy
</button>
</div>
</div>
<div class="grid grid-cols-3 gap-2 pt-4 border-t border-gray-200">
<div class="text-center">
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 100)}</div>
<div class="text-xs text-gray-500">Users</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 50)}</div>
<div class="text-xs text-gray-500">Courses</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 1000)}</div>
<div class="text-xs text-gray-500">API Calls</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
onClick={() => testConnection(instance)}
title="Test Connection"
>
🔗
</button>
<button
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
onClick={() => openEditModal(instance)}
title="Edit"
>
</button>
<button
class="flex-1 p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors text-sm"
onClick={() => deleteInstance(instance.id)}
title="Delete"
>
🗑
</button>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
}>
<div class="text-center py-8 text-gray-500">Loading instances...</div>
</Show>
</div>
</div>
{/* Instance Modal */}
<Show when={showModal()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-2xl p-8 max-w-md w-full">
<div class="flex justify-between items-center mb-6">
<h3 class="text-2xl font-semibold text-gray-900">
{editingInstance() ? 'Edit Instance' : 'Register New Instance'}
</h3>
<button
onClick={closeModal}
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
>
&times;
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Instance Name *</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder="My Trackeep Instance"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Instance URL *</label>
<input
type="url"
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder="https://myapp.trackeep.com"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Version</label>
<input
type="text"
value={formData().version}
onInput={(e) => setFormData({ ...formData(), version: e.currentTarget.value })}
placeholder="1.0.0"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
<div class="flex gap-3 justify-end mt-6">
<button
type="button"
onClick={closeModal}
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={saveInstance}
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
>
{editingInstance() ? 'Update Instance' : 'Register Instance'}
</button>
</div>
</div>
</div>
</Show>
</div>
);
};
-15
View File
@@ -1,15 +0,0 @@
import { render } from 'solid-js/web';
import { Router } from '@solidjs/router';
import App from './App';
const root = document.getElementById('root');
if (root) {
render(() => (
<Router>
<App />
</Router>
), root);
} else {
console.error('Root element not found');
}
-47
View File
@@ -1,47 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles for Trackeep-inspired UI */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Glassmorphism effects */
.glass {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Custom animations */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
-26
View File
@@ -1,26 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#6366f1',
dark: '#4f46e5'
},
secondary: '#8b5cf6',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
dark: '#1f2937',
gray: '#6b7280',
light: '#f3f4f6',
white: '#ffffff'
}
},
},
plugins: [],
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES6"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": ["src"],
"exclude": ["node_modules"]
}
-27
View File
@@ -1,27 +0,0 @@
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
export default defineConfig({
plugins: [solid()],
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:9090',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:9090',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:9090',
changeOrigin: true,
}
}
},
build: {
outDir: '../static',
emptyOutDir: true
}
});
+84 -83
View File
@@ -44,32 +44,38 @@ docker-compose up -d
The `docker-compose.prod.yml` file uses environment variables with sensible defaults: The `docker-compose.prod.yml` file uses environment variables with sensible defaults:
```yaml ```yaml
version: '3.8'
services: services:
trackeep-frontend: trackeep-frontend:
image: 'ghcr.io/dvorinka/trackeep/frontend:latest' image: 'ghcr.io/dvorinka/trackeep/frontend:latest'
ports: ports:
- '80:80' - "${FRONTEND_PORT:-80}:80"
- '443:443' - "${HTTPS_PORT:-443}:443"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false} - VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- VITE_API_URL=${VITE_API_URL:-http://localhost:8080}
- FRONTEND_PORT=${FRONTEND_PORT:-80}
- BACKEND_PORT=${BACKEND_PORT:-8080}
depends_on: depends_on:
- trackeep-backend - trackeep-backend
restart: unless-stopped restart: unless-stopped
networks: networks:
- trackeep-network - trackeep-network
healthcheck:
test: ["CMD-SHELL", "pgrep nginx > /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
trackeep-backend: trackeep-backend:
image: 'ghcr.io/dvorinka/trackeep/backend:latest' image: 'ghcr.io/dvorinka/trackeep/backend:latest'
ports: ports:
- '8080:8080' - "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
environment: environment:
- PORT=${PORT:-8080} - BACKEND_PORT=${BACKEND_PORT:-8080}
- FRONTEND_PORT=${FRONTEND_PORT:-80}
- GIN_MODE=${GIN_MODE:-release} - GIN_MODE=${GIN_MODE:-release}
- READ_TIMEOUT=${READ_TIMEOUT:-15s}
- WRITE_TIMEOUT=${WRITE_TIMEOUT:-15s}
- IDLE_TIMEOUT=${IDLE_TIMEOUT:-60s}
- SHUTDOWN_TIMEOUT=${SHUTDOWN_TIMEOUT:-30s}
- DB_TYPE=${DB_TYPE:-postgres} - DB_TYPE=${DB_TYPE:-postgres}
- DB_HOST=${DB_HOST:-postgres} - DB_HOST=${DB_HOST:-postgres}
- DB_PORT=${DB_PORT:-5432} - DB_PORT=${DB_PORT:-5432}
@@ -79,19 +85,15 @@ services:
- DB_SSL_MODE=${DB_SSL_MODE:-disable} - DB_SSL_MODE=${DB_SSL_MODE:-disable}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- UPLOAD_DIR=${UPLOAD_DIR:-./uploads} - UPLOAD_DIR=${UPLOAD_DIR:-./uploads}
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760} - MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
- 'CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}' - 'CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}'
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false} - VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
- SEARCH_API_PROVIDER=${SEARCH_API_PROVIDER:-demo}
- SEARCH_RESULTS_LIMIT=${SEARCH_RESULTS_LIMIT:-10}
- SEARCH_CACHE_TTL=${SEARCH_CACHE_TTL:-300}
- SEARCH_RATE_LIMIT=${SEARCH_RATE_LIMIT:-100}
- 'OAUTH_SERVICE_URL=${OAUTH_SERVICE_URL:-http://localhost:9090}'
- AUTO_UPDATE_CHECK=${AUTO_UPDATE_CHECK:-false} - AUTO_UPDATE_CHECK=${AUTO_UPDATE_CHECK:-false}
- UPDATE_CHECK_INTERVAL=${UPDATE_CHECK_INTERVAL:-24h} - UPDATE_CHECK_INTERVAL=${UPDATE_CHECK_INTERVAL:-24h}
- PRERELEASE_UPDATES=${PRERELEASE_UPDATES:-false} - PRERELEASE_UPDATES=${PRERELEASE_UPDATES:-false}
- DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379}
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
volumes: volumes:
- './data:/data' - './data:/data'
- './uploads:/app/uploads' - './uploads:/app/uploads'
@@ -107,30 +109,55 @@ services:
- '--no-verbose' - '--no-verbose'
- '--tries=1' - '--tries=1'
- '--spider' - '--spider'
- 'http://localhost:8080/health' - "http://localhost:${BACKEND_PORT:-8080}/health"
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
postgres: postgres:
image: 'postgres:15-alpine' image: 'postgres:15-alpine'
ports:
- "${DB_PORT:-5432}:5432"
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-trackeep} POSTGRES_DB: ${DB_NAME:-trackeep}
POSTGRES_USER: ${POSTGRES_USER:-trackeep} POSTGRES_USER: ${DB_USER:-trackeep}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes: volumes:
- 'postgres_data:/var/lib/postgresql/data' - 'postgres_data:/var/lib/postgres/data'
restart: unless-stopped restart: unless-stopped
networks: networks:
- trackeep-network - trackeep-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trackeep} -d ${POSTGRES_DB:-trackeep}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
container_name: dragonfly
ports:
- "${DRAGONFLY_PORT:-6379}:6379"
volumes:
- dragonfly_data:/data
command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2
environment:
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
restart: unless-stopped
networks:
- trackeep-network
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes: volumes:
postgres_data: null postgres_data: null
dragonfly_data: null
networks: networks:
trackeep-network: trackeep-network:
@@ -139,26 +166,34 @@ networks:
### Service Architecture ### Service Architecture
Trackeep production deployment consists of **3 essential services**: Trackeep production deployment consists of **4 essential services**:
#### **🎯 Frontend Service** #### **🎯 Frontend Service**
- **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest` - **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest`
- **Ports**: `80:80`, `443:443` - **Ports**: `${FRONTEND_PORT:-80}:80`, `${HTTPS_PORT:-443}:443`
- **Purpose**: Web interface and user experience - **Purpose**: Web interface and user experience
- **Health**: Depends on backend service - **Health**: nginx process check
#### **🔧 Backend Service** #### **🔧 Backend Service**
- **Image**: `ghcr.io/dvorinka/trackeep/backend:latest` - **Image**: `ghcr.io/dvorinka/trackeep/backend:latest`
- **Ports**: `8080:8080` - **Ports**: `${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}`
- **Purpose**: API server and business logic - **Purpose**: API server and business logic
- **Health**: Built-in health check endpoint - **Health**: HTTP health check endpoint
#### **🗄️ Database Service** #### **🗄️ Database Service**
- **Image**: `postgres:15-alpine` - **Image**: `postgres:15-alpine`
- **Ports**: `${DB_PORT:-5432}:5432`
- **Purpose**: Data persistence and storage - **Purpose**: Data persistence and storage
- **Health**: PostgreSQL readiness check - **Health**: PostgreSQL readiness check
- **Storage**: Persistent volume for data - **Storage**: Persistent volume for data
#### **🐉 DragonflyDB Service**
- **Image**: `ghcr.io/dragonflydb/dragonfly:latest`
- **Ports**: `${DRAGONFLY_PORT:-6379}:6379`
- **Purpose**: In-memory caching and session storage
- **Health**: Redis-cli ping check
- **Storage**: Persistent volume for cache data
### Required Environment Variables ### Required Environment Variables
Create a `.env` file from the provided `.env.example` and configure these required variables: Create a `.env` file from the provided `.env.example` and configure these required variables:
@@ -166,11 +201,12 @@ Create a `.env` file from the provided `.env.example` and configure these requir
```env ```env
# Database Configuration # Database Configuration
DB_PASSWORD=your_secure_password DB_PASSWORD=your_secure_password
POSTGRES_PASSWORD=your_secure_password
# Security Configuration # Security Configuration
JWT_SECRET=your_jwt_secret_key JWT_SECRET=your_jwt_secret_key
ENCRYPTION_KEY=your_32_character_encryption_key
# DragonflyDB Configuration
DRAGONFLY_PASSWORD=your_dragonfly_password
``` ```
### AI Services Configuration ### AI Services Configuration
@@ -422,9 +458,9 @@ DISABLE_CHINESE_AI=true
``` ```
4. **Access the application** 4. **Access the application**
- Frontend: http://localhost:5173 - Frontend: http://localhost:${FRONTEND_PORT:-80}
- Backend API: http://localhost:8080 - Backend API: http://localhost:${BACKEND_PORT:-8080}
- Health Check: http://localhost:8080/health - Health Check: http://localhost:${BACKEND_PORT:-8080}/health
### Demo Login ### Demo Login
- Email: `demo@trackeep.com` - Email: `demo@trackeep.com`
@@ -495,79 +531,44 @@ Key environment variables to configure:
```bash ```bash
# Server Configuration # Server Configuration
PORT=8080 FRONTEND_PORT=80
FRONTEND_PORT=5173 BACKEND_PORT=8080
GIN_MODE=debug VITE_API_URL=http://localhost:8080
GIN_MODE=release
# Database Configuration # Database Configuration
DB_TYPE=sqlite DB_TYPE=postgres
DB_HOST=localhost DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
DB_USER=trackeep DB_USER=trackeep
DB_PASSWORD=your_password_here DB_PASSWORD=your_password_here
DB_NAME=trackeep DB_NAME=trackeep
DB_SSL_MODE=disable DB_SSL_MODE=disable
# SQLite (for development) # DragonflyDB Configuration
SQLITE_DB_PATH=./trackeep.db DRAGONFLY_ADDR=dragonfly:6379
DRAGONFLY_PORT=6379
DRAGONFLY_PASSWORD=your_dragonfly_password
# JWT Configuration # JWT Configuration
# JWT_SECRET is auto-generated on startup and stored in jwt_secret.key # Generate a secure 64-character hex string using: openssl rand -hex 32
# You can override by setting JWT_SECRET environment variable if needed JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
# Encryption Configuration
# ENCRYPTION_KEY is auto-generated on startup and stored in encryption.key
# You can override by setting ENCRYPTION_KEY environment variable if needed
# File Upload Configuration # File Upload Configuration
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760 MAX_FILE_SIZE=10485760
# CORS Configuration # CORS Configuration
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 CORS_ALLOWED_ORIGINS=*
# SMTP Configuration for Password Reset
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your_email@gmail.com
SMTP_PASSWORD=your_app_password
SMTP_FROM_EMAIL=your_email@gmail.com
SMTP_FROM_NAME=Trackeep
# Demo Mode Configuration # Demo Mode Configuration
VITE_DEMO_MODE=false VITE_DEMO_MODE=false
# AI Services (All Optional) # Auto Update Configuration
# Chinese AI Services (Budget-friendly) AUTO_UPDATE_CHECK=false
LONGCAT_API_KEY=your_longcat_api_key_here UPDATE_CHECK_INTERVAL=24h
LONGCAT_BASE_URL=https://api.longcat.chat PRERELEASE_UPDATES=false
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com
# Western AI Services
MISTRAL_API_KEY=your_mistral_api_key_here
MISTRAL_MODEL=mistral-small-latest
GROK_API_KEY=your_grok_api_key_here
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_BASE_URL=https://api.openai.com
# Local AI (Complete Privacy)
OLLAMA_BASE_URL=http://localhost:11434
# AI Control (Disable what you don't want)
DISABLE_AI=false
DISABLE_LONGCAT=false
DISABLE_DEEPSEEK=false
DISABLE_MISTRAL=false
DISABLE_GROK=false
DISABLE_OPENAI=false
DISABLE_CHINESE_AI=false
DISABLE_ALL_CLOUD_AI=false
# OAuth Configuration
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
``` ```
## Contributing ## Contributing
+4 -1
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -27,6 +27,9 @@ WORKDIR /root/
# Copy the binary from builder stage # Copy the binary from builder stage
COPY --from=builder /app/main . COPY --from=builder /app/main .
# Copy migrations directory
COPY --from=builder /app/migrations ./migrations
# Create necessary directories # Create necessary directories
RUN mkdir -p /app/uploads /data RUN mkdir -p /app/uploads /data
+27 -10
View File
@@ -2,12 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"github.com/trackeep/backend/migrations"
"go.uber.org/zap"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var DB *gorm.DB var DB *gorm.DB
@@ -26,12 +26,20 @@ func getJWTSecret() string {
// InitDatabase initializes the database connection // InitDatabase initializes the database connection
func InitDatabase() { func InitDatabase() {
// Initialize logger first
InitLogger()
logger := GetLogger()
// Check if demo mode is enabled
if os.Getenv("VITE_DEMO_MODE") == "true" {
logger.Info("Demo mode enabled - skipping database initialization")
return
}
var err error var err error
// Configure GORM logger // Configure GORM
gormConfig := &gorm.Config{ gormConfig := &gorm.Config{}
Logger: logger.Default.LogMode(logger.Info),
}
dbType := os.Getenv("DB_TYPE") dbType := os.Getenv("DB_TYPE")
if dbType == "" { if dbType == "" {
@@ -49,19 +57,28 @@ func InitDatabase() {
os.Getenv("DB_SSL_MODE"), os.Getenv("DB_SSL_MODE"),
) )
DB, err = gorm.Open(postgres.Open(dsn), gormConfig) DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
log.Println("Using PostgreSQL database") logger.Info("Using PostgreSQL database")
default: default:
log.Fatal("Unsupported database type: " + dbType) logger.Fatal("Unsupported database type", zap.String("type", dbType))
} }
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) logger.Fatal("Failed to connect to database", zap.Error(err))
} }
log.Println("Database connected successfully") logger.Info("Database connected successfully")
// Run database migrations
if err := migrations.RunMigrations(); err != nil {
logger.Fatal("Failed to run database migrations", zap.Error(err))
}
} }
// GetDB returns the database instance // GetDB returns the database instance
func GetDB() *gorm.DB { func GetDB() *gorm.DB {
// In demo mode, return nil since no database is available
if os.Getenv("VITE_DEMO_MODE") == "true" {
return nil
}
return DB return DB
} }
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Logger *zap.Logger
// InitLogger initializes the Zap logger
func InitLogger() {
// Get log level from environment
logLevel := os.Getenv("LOG_LEVEL")
if logLevel == "" {
logLevel = "info"
}
// Parse log level
var level zapcore.Level
switch logLevel {
case "debug":
level = zapcore.DebugLevel
case "info":
level = zapcore.InfoLevel
case "warn":
level = zapcore.WarnLevel
case "error":
level = zapcore.ErrorLevel
default:
level = zapcore.InfoLevel
}
// Check if we're in production mode
isProduction := os.Getenv("GIN_MODE") == "release"
// Configure encoder
var encoder zapcore.Encoder
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
if isProduction {
encoder = zapcore.NewJSONEncoder(encoderConfig)
} else {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
}
// Configure output
writeSyncer := zapcore.AddSync(os.Stdout)
// Create core
core := zapcore.NewCore(encoder, writeSyncer, level)
// Create logger
Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
// Replace global logger
zap.ReplaceGlobals(Logger)
Logger.Info("Logger initialized",
zap.String("level", logLevel),
zap.Bool("production", isProduction),
)
}
// GetLogger returns the configured logger instance
func GetLogger() *zap.Logger {
if Logger == nil {
// Fallback to default logger if not initialized
logger, _ := zap.NewProduction()
return logger
}
return Logger
}
// SyncLogger flushes any buffered log entries
func SyncLogger() {
if Logger != nil {
_ = Logger.Sync()
}
}
+156
View File
@@ -0,0 +1,156 @@
package examples
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
"github.com/trackeep/backend/internal/db"
"github.com/trackeep/backend/internal/db/sqlc"
)
// UserServiceExample demonstrates how to use sqlc with typed queries
type UserServiceExample struct {
db *db.DB
}
// NewUserServiceExample creates a new user service example
func NewUserServiceExample(database *db.DB) *UserServiceExample {
return &UserServiceExample{
db: database,
}
}
// CreateUserExample shows how to create a user with typed queries
func (s *UserServiceExample) CreateUserExample(ctx context.Context, email, passwordHash, firstName, lastName string) (sqlc.User, error) {
// Use typed query - no SQL strings, no reflection
user, err := s.db.CreateUser(ctx, sqlc.CreateUserParams{
Email: email,
PasswordHash: passwordHash,
FirstName: &firstName,
LastName: &lastName,
IsActive: &[]bool{true}[0],
IsVerified: &[]bool{false}[0],
})
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to create user: %w", err)
}
// Convert CreateUserRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// GetUserExample shows how to get a user by ID
func (s *UserServiceExample) GetUserExample(ctx context.Context, userID pgtype.UUID) (sqlc.User, error) {
// Use typed query
user, err := s.db.GetUserByID(ctx, userID)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to get user: %w", err)
}
// Convert GetUserByIDRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// SearchUsersExample shows how to search users with pagination
func (s *UserServiceExample) SearchUsersExample(ctx context.Context, limit, offset int32) ([]sqlc.User, error) {
// Use typed query with parameters
users, err := s.db.ListUsers(ctx, sqlc.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, fmt.Errorf("failed to list users: %w", err)
}
// Convert ListUsersRow to User
result := make([]sqlc.User, len(users))
for i, user := range users {
result[i] = sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
return result, nil
}
// TransactionExample shows how to use transactions with sqlc
func (s *UserServiceExample) TransactionExample(ctx context.Context, email, passwordHash string) (sqlc.User, error) {
// Start transaction
tx, err := s.db.BeginTx(ctx)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Create user within transaction
user, err := tx.CreateUser(ctx, sqlc.CreateUserParams{
Email: email,
PasswordHash: passwordHash,
IsActive: &[]bool{true}[0],
IsVerified: &[]bool{false}[0],
})
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to create user in transaction: %w", err)
}
// Update last login within transaction
err = tx.UpdateLastLogin(ctx, user.ID)
if err != nil {
return sqlc.User{}, fmt.Errorf("failed to update last login: %w", err)
}
// Commit transaction
if err := tx.Commit(); err != nil {
return sqlc.User{}, fmt.Errorf("failed to commit transaction: %w", err)
}
// Convert CreateUserRow to User
return sqlc.User{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
AvatarUrl: user.AvatarUrl,
IsActive: user.IsActive,
IsVerified: user.IsVerified,
LastLogin: user.LastLogin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
+18 -10
View File
@@ -1,6 +1,6 @@
module github.com/trackeep/backend module github.com/trackeep/backend
go 1.24.0 go 1.25.0
require ( require (
github.com/PuerkitoBio/goquery v1.11.0 github.com/PuerkitoBio/goquery v1.11.0
@@ -12,8 +12,9 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.47.0 github.com/pressly/goose/v3 v3.27.0
golang.org/x/net v0.48.0 golang.org/x/crypto v0.48.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.17.0 golang.org/x/oauth2 v0.17.0
gorm.io/driver/postgres v1.5.4 gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
@@ -27,7 +28,7 @@ require (
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/boombuler/barcode v1.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect
@@ -45,8 +46,9 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
@@ -55,21 +57,27 @@ require (
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // 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/nlnwa/whatwg-url v0.6.2 // indirect github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect github.com/temoto/robotstxt v1.1.2 // 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.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/appengine v1.6.8 // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+54 -19
View File
@@ -17,8 +17,8 @@ github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
@@ -34,6 +34,8 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@@ -78,14 +80,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -109,15 +115,21 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
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=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q= github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@@ -134,10 +146,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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=
@@ -147,8 +165,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -156,6 +175,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -166,8 +189,10 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 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/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -183,8 +208,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -194,6 +219,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -208,8 +235,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -230,8 +257,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -244,8 +271,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -260,4 +287,12 @@ gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+35 -4
View File
@@ -11,16 +11,16 @@ import (
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/trackeep/backend/config" "github.com/trackeep/backend/config"
"github.com/trackeep/backend/models" "github.com/trackeep/backend/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
type LoginRequest struct { type LoginRequest struct {
@@ -63,17 +63,48 @@ type Claims struct {
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
GitHubID int `json:"github_id,omitempty"`
AccessToken string `json:"access_token,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// getDurationEnv parses duration from environment variable with fallback
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
seconds, err := strconv.Atoi(value)
if err != nil {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return time.Duration(seconds) * time.Second
}
// GenerateJWT creates a new JWT token for a user // GenerateJWT creates a new JWT token for a user
func GenerateJWT(user models.User) (string, error) { func GenerateJWT(user models.User) (string, error) {
return generateJWT(user, "")
}
func GenerateJWTWithGitHubAccessToken(user models.User, accessToken string) (string, error) {
return generateJWT(user, accessToken)
}
func generateJWT(user models.User, accessToken string) (string, error) {
claims := &Claims{ claims := &Claims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
Username: user.Username, Username: user.Username,
GitHubID: user.GitHubID,
AccessToken: accessToken,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "trackeep", Issuer: "trackeep",
}, },
+62 -103
View File
@@ -8,13 +8,12 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"time" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"gorm.io/gorm"
"github.com/trackeep/backend/config" "github.com/trackeep/backend/config"
"github.com/trackeep/backend/models" "github.com/trackeep/backend/models"
@@ -53,6 +52,8 @@ type GitHubRepo struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
Description string `json:"description"` Description string `json:"description"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
Private bool `json:"private"`
Stargazers int `json:"stargazers_count"` Stargazers int `json:"stargazers_count"`
Forks int `json:"forks_count"` Forks int `json:"forks_count"`
Watchers int `json:"watchers_count"` Watchers int `json:"watchers_count"`
@@ -66,6 +67,14 @@ type GitHubRepo struct {
// GitHubLogin initiates the GitHub OAuth flow // GitHubLogin initiates the GitHub OAuth flow
func GitHubLogin(c *gin.Context) { func GitHubLogin(c *gin.Context) {
frontendRedirect := resolveFrontendRedirectURL(c.Request)
callbackURL := buildOAuthCallbackURL(c.Request, frontendRedirect)
if oauthServiceURL := getOAuthServiceURL(); oauthServiceURL != "" && callbackURL != "" {
redirectURL := fmt.Sprintf("%s/auth/github?redirect_uri=%s", oauthServiceURL, url.QueryEscape(callbackURL))
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
return
}
if githubOAuthConfig == nil { if githubOAuthConfig == nil {
initGitHubOAuth() initGitHubOAuth()
} }
@@ -119,55 +128,35 @@ func GitHubCallback(c *gin.Context) {
} }
// Get or create user in database // Get or create user in database
db := c.MustGet("db").(*gorm.DB) db := config.GetDB()
var existingUser models.User if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
// First try to find by GitHub ID
err = db.Where("github_id = ?", user.ID).First(&existingUser).Error
if err != nil {
// If not found by GitHub ID, try by email
err = db.Where("email = ?", user.Email).First(&existingUser).Error
if err != nil {
// Create new user
newUser := models.User{
Username: user.Login,
Email: user.Email,
FullName: user.Name,
GitHubID: user.ID,
AvatarURL: user.AvatarURL,
Provider: "github",
}
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return return
} }
existingUser = newUser existingUser, err := upsertCentralizedOAuthUser(db, centralizedOAuthUser{
} else { GitHubID: user.ID,
// Update existing user with GitHub info Username: user.Login,
existingUser.GitHubID = user.ID Email: user.Email,
existingUser.AvatarURL = user.AvatarURL Name: user.Name,
existingUser.Provider = "github" AvatarURL: user.AvatarURL,
db.Save(&existingUser)
}
}
// Generate JWT token
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": existingUser.ID,
"email": existingUser.Email,
"username": existingUser.Username,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
}) })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
return
}
tokenString, err := jwtToken.SignedString([]byte(config.JWTSecret)) tokenString, err := GenerateJWTWithGitHubAccessToken(*existingUser, token.AccessToken)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return return
} }
// Redirect to frontend with token // Redirect to frontend with token
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), tokenString) redirectURL := buildFrontendCallbackRedirectURL("", tokenString)
if redirectURL == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
return
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
} }
@@ -259,69 +248,41 @@ func HandleOAuthCallback(c *gin.Context) {
return return
} }
// Parse the JWT from the OAuth service validationResponse, err := validateCentralizedOAuthToken(c.Request.Context(), token)
claims := jwt.MapClaims{} if err != nil {
parsedToken, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
// Use the OAuth service's JWT secret (should be shared)
return []byte(os.Getenv("OAUTH_JWT_SECRET")), nil
})
if err != nil || !parsedToken.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
return return
} }
// Extract user information from OAuth service
username, _ := claims["username"].(string)
email, _ := claims["email"].(string)
githubID, _ := claims["github_id"]
accessToken, _ := claims["access_token"].(string)
// Get database // Get database
db := c.MustGet("db").(*gorm.DB) db := config.GetDB()
if db == nil {
// Find or create user in local database c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
var user models.User
err = db.Where("email = ?", email).First(&user).Error
if err != nil {
// Create new user
newUser := models.User{
Username: username,
Email: email,
GitHubID: int(githubID.(float64)), // JWT numbers are float64
Provider: "github",
}
if err := db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return return
} }
user = newUser localUser, err := upsertCentralizedOAuthUser(db, validationResponse.User)
} else { if err != nil {
// Update existing user with GitHub info c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
user.GitHubID = int(githubID.(float64)) return
user.Provider = "github"
db.Save(&user)
} }
// Generate Trackeep JWT token claims, err := parseOAuthTokenClaimsUnverified(token)
trackeepToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ if err != nil {
"user_id": user.ID, c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token claims"})
"email": user.Email, return
"username": user.Username, }
"github_id": user.GitHubID,
"access_token": accessToken, // Pass through the GitHub access token
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
})
trackeepTokenString, err := trackeepToken.SignedString([]byte(os.Getenv("JWT_SECRET"))) trackeepTokenString, err := GenerateJWTWithGitHubAccessToken(*localUser, getAccessTokenFromOAuthClaims(claims))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return return
} }
// Redirect to frontend with Trackeep token redirectURL := buildFrontendCallbackRedirectURL(c.Query("frontend_redirect"), trackeepTokenString)
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), trackeepTokenString) if redirectURL == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
return
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL) c.Redirect(http.StatusTemporaryRedirect, redirectURL)
} }
@@ -343,7 +304,11 @@ func GetCurrentUserWithGitHub(c *gin.Context) {
func GetGitHubRepos(c *gin.Context) { func GetGitHubRepos(c *gin.Context) {
userID := c.GetUint("user_id") userID := c.GetUint("user_id")
db := c.MustGet("db").(*gorm.DB) db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var user models.User var user models.User
if err := db.First(&user, userID).Error; err != nil { if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
@@ -368,26 +333,20 @@ func GetGitHubRepos(c *gin.Context) {
tokenString = authHeader[7:] tokenString = authHeader[7:]
} }
// Parse the JWT to get the GitHub access token from the centralized OAuth service claims, err := ValidateJWT(tokenString)
claims := jwt.MapClaims{} if err != nil {
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return return
} }
// Extract GitHub access token from the OAuth service JWT githubAccessToken := strings.TrimSpace(claims.AccessToken)
githubAccessToken, ok := claims["access_token"] if githubAccessToken == "" {
if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found. Please reconnect GitHub."})
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found"})
return return
} }
// Fetch repositories using the GitHub access token // Fetch repositories using the GitHub access token
repos, err := fetchGitHubRepos(githubAccessToken.(string)) repos, err := fetchGitHubRepos(githubAccessToken)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
return return
+944
View File
@@ -0,0 +1,944 @@
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/trackeep/backend/config"
"github.com/trackeep/backend/models"
"gorm.io/gorm"
)
var githubRepoFullNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
type gitHubInstallationReposResponse struct {
Repositories []GitHubRepo `json:"repositories"`
}
type gitHubAppInstallationDetails struct {
ID int64 `json:"id"`
Account struct {
Login string `json:"login"`
Type string `json:"type"`
} `json:"account"`
}
type gitHubInstallationTokenResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
type gitHubBackupRequest struct {
Repositories []string `json:"repositories"`
Source string `json:"source"`
}
type gitHubBackupResult struct {
Repository string `json:"repository"`
Status string `json:"status"`
LocalPath string `json:"local_path"`
Source string `json:"source"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// GetGitHubAppStatus returns install/configuration status for GitHub App integration.
func GetGitHubAppStatus(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
response := gin.H{
"app_slug": getGitHubAppSlug(),
"install_enabled": isGitHubAppInstallEnabled(),
"credentials_configured": hasGitHubAppCredentials(),
"installed": false,
}
if db == nil {
c.JSON(http.StatusOK, response)
return
}
installation, err := getUserGitHubInstallation(db, userID)
if err == nil {
response["installed"] = true
response["installation"] = installation
}
c.JSON(http.StatusOK, response)
}
// GetGitHubAppInstallURL creates a one-time state and returns an install URL for the configured GitHub App.
func GetGitHubAppInstallURL(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
if !isGitHubAppInstallEnabled() {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App slug is not configured"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
state := generateRandomString(24)
expiresAt := time.Now().Add(15 * time.Minute)
stateRecord := models.GitHubAppInstallState{
UserID: userID,
State: state,
ExpiresAt: expiresAt,
}
if err := db.Create(&stateRecord).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create install state"})
return
}
installURL := fmt.Sprintf(
"https://github.com/apps/%s/installations/new?state=%s",
url.PathEscape(getGitHubAppSlug()),
url.QueryEscape(state),
)
c.JSON(http.StatusOK, gin.H{
"install_url": installURL,
"expires_at": expiresAt,
})
}
// GitHubAppInstallCallback handles GitHub App setup callback and links installation to a Trackeep user.
func GitHubAppInstallCallback(c *gin.Context) {
state := strings.TrimSpace(c.Query("state"))
installationRaw := strings.TrimSpace(c.Query("installation_id"))
setupAction := strings.TrimSpace(c.Query("setup_action"))
if state == "" || installationRaw == "" {
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "missing_state_or_installation")
return
}
installationID, err := strconv.ParseInt(installationRaw, 10, 64)
if err != nil || installationID <= 0 {
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "invalid_installation_id")
return
}
db := config.GetDB()
if db == nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "database_unavailable")
return
}
var stateRecord models.GitHubAppInstallState
if err := db.Where("state = ?", state).First(&stateRecord).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "invalid_state")
return
}
if stateRecord.UsedAt != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_already_used")
return
}
if time.Now().After(stateRecord.ExpiresAt) {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_expired")
return
}
accountLogin := ""
accountType := ""
lastValidated := (*time.Time)(nil)
if hasGitHubAppCredentials() {
details, detailsErr := fetchGitHubAppInstallationDetails(c.Request.Context(), installationID)
if detailsErr == nil && details != nil {
accountLogin = details.Account.Login
accountType = details.Account.Type
now := time.Now()
lastValidated = &now
}
}
var installation models.GitHubAppInstallation
lookupErr := db.Where("installation_id = ?", installationID).First(&installation).Error
switch {
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
installation = models.GitHubAppInstallation{
UserID: stateRecord.UserID,
InstallationID: installationID,
AppSlug: getGitHubAppSlug(),
AccountLogin: accountLogin,
AccountType: accountType,
LastValidated: lastValidated,
}
if err := db.Create(&installation).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_store_installation")
return
}
case lookupErr != nil:
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_lookup_failed")
return
default:
updates := map[string]interface{}{
"user_id": stateRecord.UserID,
"app_slug": getGitHubAppSlug(),
"account_login": accountLogin,
"account_type": accountType,
}
if lastValidated != nil {
updates["last_validated"] = lastValidated
}
if err := db.Model(&installation).Updates(updates).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_update_installation")
return
}
}
usedAt := time.Now()
if err := db.Model(&stateRecord).Update("used_at", usedAt).Error; err != nil {
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_finalize_state")
return
}
redirectToGitHubIntegrationPage(c, true, installationID, setupAction, "")
}
// GetGitHubAppRepos returns repositories available through the user's GitHub App installation.
func GetGitHubAppRepos(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
installation, err := getUserGitHubInstallation(db, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App is not installed for this user"})
return
}
accessToken, _, err := createGitHubInstallationAccessToken(c.Request.Context(), installation.InstallationID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to create GitHub App installation token: " + err.Error()})
return
}
repos, err := fetchGitHubInstallationRepos(accessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch installation repos: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"source": "github_app",
"installation_id": installation.InstallationID,
"repos": repos,
})
}
// GetGitHubBackups lists local GitHub repository backups for the authenticated user.
func GetGitHubBackups(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var backups []models.GitHubRepoBackup
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").Find(&backups).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository backups"})
return
}
c.JSON(http.StatusOK, gin.H{
"backup_root": getGitHubBackupRoot(),
"backups": backups,
})
}
// BackupGitHubRepositories clones or updates selected repositories in local mirror storage.
func BackupGitHubRepositories(c *gin.Context) {
userID := getGitHubRequestUserID(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
db := config.GetDB()
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
var req gitHubBackupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if len(req.Repositories) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "At least one repository must be provided"})
return
}
accessToken, source, installationID, err := resolveGitHubBackupToken(c, db, userID, req.Source)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := os.MkdirAll(getGitHubBackupRoot(), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup directory"})
return
}
knownRepos := make(map[string]GitHubRepo)
switch source {
case "oauth":
repos, reposErr := fetchGitHubRepos(accessToken)
if reposErr == nil {
for _, repo := range repos {
knownRepos[strings.ToLower(repo.FullName)] = repo
}
}
case "github_app":
repos, reposErr := fetchGitHubInstallationRepos(accessToken)
if reposErr == nil {
for _, repo := range repos {
knownRepos[strings.ToLower(repo.FullName)] = repo
}
}
}
results := make([]gitHubBackupResult, 0, len(req.Repositories))
successCount := 0
failedCount := 0
seen := make(map[string]struct{})
for _, rawRepo := range req.Repositories {
repoFullName, normalizeErr := normalizeGitHubRepoFullName(rawRepo)
if normalizeErr != nil {
failedCount++
results = append(results, gitHubBackupResult{
Repository: strings.TrimSpace(rawRepo),
Status: "error",
Source: source,
Error: normalizeErr.Error(),
})
continue
}
if _, exists := seen[repoFullName]; exists {
continue
}
seen[repoFullName] = struct{}{}
repoInfo, hasInfo := knownRepos[strings.ToLower(repoFullName)]
if !hasInfo {
repoDetails, fetchErr := fetchGitHubRepoByFullName(accessToken, repoFullName)
if fetchErr == nil && repoDetails != nil {
repoInfo = *repoDetails
}
}
if repoInfo.FullName == "" {
repoInfo.FullName = repoFullName
parts := strings.SplitN(repoFullName, "/", 2)
if len(parts) == 2 {
repoInfo.Name = parts[1]
}
repoInfo.CloneURL = fmt.Sprintf("https://github.com/%s.git", repoFullName)
}
localPath := buildGitHubBackupPath(userID, repoFullName)
repoCtx, cancel := context.WithTimeout(c.Request.Context(), getGitHubBackupTimeout())
sizeBytes, backupErr := backupGitHubRepositoryMirror(repoCtx, accessToken, repoFullName, localPath)
cancel()
result := gitHubBackupResult{
Repository: repoFullName,
LocalPath: localPath,
Source: source,
}
now := time.Now()
record := models.GitHubRepoBackup{
UserID: userID,
RepositoryID: int64(repoInfo.ID),
RepositoryName: repoInfo.Name,
RepositoryFullName: repoFullName,
DefaultBranch: repoInfo.DefaultBranch,
CloneURL: repoInfo.CloneURL,
LocalPath: localPath,
Source: source,
InstallationID: installationID,
LastBackupAt: &now,
}
if backupErr != nil {
failedCount++
result.Status = "error"
result.Error = backupErr.Error()
record.LastBackupStatus = "error"
record.LastBackupError = backupErr.Error()
record.LastBackupSize = 0
} else {
successCount++
result.Status = "success"
result.SizeBytes = sizeBytes
record.LastBackupStatus = "success"
record.LastBackupError = ""
record.LastBackupSize = sizeBytes
}
if upsertErr := upsertGitHubBackupRecord(db, record); upsertErr != nil {
if result.Status == "success" {
result.Status = "error"
result.Error = "backup persisted but metadata update failed: " + upsertErr.Error()
successCount--
failedCount++
}
}
results = append(results, result)
}
c.JSON(http.StatusOK, gin.H{
"source": source,
"installation_id": installationID,
"backed_up": successCount,
"failed": failedCount,
"results": results,
})
}
func getGitHubRequestUserID(c *gin.Context) uint {
userID := c.GetUint("user_id")
if userID == 0 {
userID = c.GetUint("userID")
}
return userID
}
func getUserGitHubInstallation(db *gorm.DB, userID uint) (*models.GitHubAppInstallation, error) {
var installation models.GitHubAppInstallation
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").First(&installation).Error; err != nil {
return nil, err
}
return &installation, nil
}
func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
source := strings.ToLower(strings.TrimSpace(requestedSource))
switch source {
case "", "oauth":
accessToken, err := getGitHubOAuthAccessTokenFromHeader(c)
if err == nil {
return accessToken, "oauth", nil, nil
}
accessToken, installationID, appErr := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
if appErr == nil {
return accessToken, "github_app", &installationID, nil
}
return "", "", nil, fmt.Errorf("no usable GitHub OAuth token and GitHub App fallback failed")
case "github_app", "app":
accessToken, installationID, err := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
if err != nil {
return "", "", nil, err
}
return accessToken, "github_app", &installationID, nil
default:
return "", "", nil, fmt.Errorf("unsupported source '%s'", requestedSource)
}
}
func getGitHubOAuthAccessTokenFromHeader(c *gin.Context) (string, error) {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if authHeader == "" {
return "", errors.New("authorization header required")
}
tokenString := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
tokenString = strings.TrimSpace(authHeader[7:])
}
if tokenString == "" {
return "", errors.New("invalid authorization header")
}
claims, err := ValidateJWT(tokenString)
if err != nil {
return "", err
}
accessToken := strings.TrimSpace(claims.AccessToken)
if accessToken == "" {
return "", errors.New("github oauth token missing in jwt")
}
return accessToken, nil
}
func getGitHubAppAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, int64, error) {
installation, err := getUserGitHubInstallation(db, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", 0, errors.New("GitHub App not installed for this user")
}
return "", 0, err
}
accessToken, _, err := createGitHubInstallationAccessToken(ctx, installation.InstallationID)
if err != nil {
return "", 0, err
}
return accessToken, installation.InstallationID, nil
}
func upsertGitHubBackupRecord(db *gorm.DB, record models.GitHubRepoBackup) error {
var existing models.GitHubRepoBackup
err := db.Where("user_id = ? AND repository_full_name = ?", record.UserID, record.RepositoryFullName).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return db.Create(&record).Error
case err != nil:
return err
default:
updates := map[string]interface{}{
"repository_id": record.RepositoryID,
"repository_name": record.RepositoryName,
"default_branch": record.DefaultBranch,
"clone_url": record.CloneURL,
"local_path": record.LocalPath,
"source": record.Source,
"installation_id": record.InstallationID,
"last_backup_at": record.LastBackupAt,
"last_backup_status": record.LastBackupStatus,
"last_backup_error": record.LastBackupError,
"last_backup_size": record.LastBackupSize,
}
return db.Model(&existing).Updates(updates).Error
}
}
func normalizeGitHubRepoFullName(raw string) (string, error) {
normalized := strings.TrimSpace(raw)
normalized = strings.TrimSuffix(normalized, ".git")
normalized = strings.TrimPrefix(normalized, "https://github.com/")
normalized = strings.TrimPrefix(normalized, "http://github.com/")
normalized = strings.TrimPrefix(normalized, "github.com/")
normalized = strings.Trim(normalized, "/")
if !githubRepoFullNamePattern.MatchString(normalized) {
return "", fmt.Errorf("invalid repository '%s', expected owner/repo", raw)
}
return normalized, nil
}
func buildGitHubBackupPath(userID uint, repoFullName string) string {
parts := strings.SplitN(repoFullName, "/", 2)
owner := "unknown"
repo := repoFullName
if len(parts) == 2 {
owner = parts[0]
repo = parts[1]
}
return filepath.Join(getGitHubBackupRoot(), fmt.Sprintf("user-%d", userID), owner, repo+".git")
}
func getGitHubBackupRoot() string {
root := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_ROOT"))
if root == "" {
root = filepath.Join("data", "github-backups")
}
absolutePath, err := filepath.Abs(root)
if err != nil {
return root
}
return absolutePath
}
func getGitHubBackupTimeout() time.Duration {
timeoutRaw := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_TIMEOUT"))
if timeoutRaw == "" {
return 10 * time.Minute
}
parsed, err := time.ParseDuration(timeoutRaw)
if err != nil || parsed <= 0 {
return 10 * time.Minute
}
return parsed
}
func backupGitHubRepositoryMirror(ctx context.Context, accessToken, repoFullName, localPath string) (int64, error) {
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return 0, fmt.Errorf("failed to create backup parent directory: %w", err)
}
repoURL := fmt.Sprintf("https://github.com/%s.git", repoFullName)
gitAuthHeader := "http.extraHeader=Authorization: Bearer " + accessToken
cloneRequired := true
if info, err := os.Stat(localPath); err == nil {
if !info.IsDir() {
return 0, fmt.Errorf("backup path exists and is not a directory: %s", localPath)
}
if _, configErr := os.Stat(filepath.Join(localPath, "config")); configErr == nil {
cloneRequired = false
} else if errors.Is(configErr, os.ErrNotExist) {
if removeErr := os.RemoveAll(localPath); removeErr != nil {
return 0, fmt.Errorf("failed to reset invalid backup directory: %w", removeErr)
}
} else {
return 0, fmt.Errorf("failed to inspect existing backup directory: %w", configErr)
}
} else if !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("failed to access backup path: %w", err)
}
var cmd *exec.Cmd
if cloneRequired {
cmd = exec.CommandContext(ctx, "git", "-c", gitAuthHeader, "clone", "--mirror", repoURL, localPath)
} else {
cmd = exec.CommandContext(ctx, "git", "-C", localPath, "-c", gitAuthHeader, "remote", "update", "--prune")
}
output, err := cmd.CombinedOutput()
if err != nil {
commandOutput := strings.TrimSpace(string(output))
if commandOutput == "" {
commandOutput = err.Error()
}
return 0, fmt.Errorf("git backup failed: %s", commandOutput)
}
sizeBytes, err := calculateDirectorySize(localPath)
if err != nil {
return 0, fmt.Errorf("backup completed but failed to calculate size: %w", err)
}
return sizeBytes, nil
}
func calculateDirectorySize(root string) (int64, error) {
var totalSize int64
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
totalSize += info.Size()
return nil
})
if err != nil {
return 0, err
}
return totalSize, nil
}
func fetchGitHubInstallationRepos(accessToken string) ([]GitHubRepo, error) {
req, err := http.NewRequest("GET", "https://api.github.com/installation/repositories?per_page=100", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var response gitHubInstallationReposResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
return response.Repositories, nil
}
func fetchGitHubRepoByFullName(accessToken, repoFullName string) (*GitHubRepo, error) {
parts := strings.SplitN(repoFullName, "/", 2)
if len(parts) != 2 {
return nil, errors.New("invalid repository full name")
}
repoURL := fmt.Sprintf(
"https://api.github.com/repos/%s/%s",
url.PathEscape(parts[0]),
url.PathEscape(parts[1]),
)
req, err := http.NewRequest("GET", repoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var repo GitHubRepo
if err := json.Unmarshal(body, &repo); err != nil {
return nil, err
}
return &repo, nil
}
func isGitHubAppInstallEnabled() bool {
return getGitHubAppSlug() != ""
}
func hasGitHubAppCredentials() bool {
return strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) != ""
}
func getGitHubAppSlug() string {
return strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
}
func createGitHubInstallationAccessToken(ctx context.Context, installationID int64) (string, time.Time, error) {
if !hasGitHubAppCredentials() {
return "", time.Time{}, errors.New("GitHub App credentials are not fully configured")
}
appJWT, err := createGitHubAppJWT()
if err != nil {
return "", time.Time{}, err
}
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID)
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, nil)
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Authorization", "Bearer "+appJWT)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", time.Time{}, fmt.Errorf("GitHub token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var payload gitHubInstallationTokenResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", time.Time{}, err
}
if strings.TrimSpace(payload.Token) == "" {
return "", time.Time{}, errors.New("GitHub returned an empty installation token")
}
var expiresAt time.Time
if payload.ExpiresAt != "" {
parsed, parseErr := time.Parse(time.RFC3339, payload.ExpiresAt)
if parseErr == nil {
expiresAt = parsed
}
}
return payload.Token, expiresAt, nil
}
func fetchGitHubAppInstallationDetails(ctx context.Context, installationID int64) (*gitHubAppInstallationDetails, error) {
if !hasGitHubAppCredentials() {
return nil, errors.New("GitHub App credentials are not configured")
}
appJWT, err := createGitHubAppJWT()
if err != nil {
return nil, err
}
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d", installationID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+appJWT)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub installation endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var details gitHubAppInstallationDetails
if err := json.Unmarshal(body, &details); err != nil {
return nil, err
}
return &details, nil
}
func createGitHubAppJWT() (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
if appID == "" {
return "", errors.New("GITHUB_APP_ID is not configured")
}
privateKeyPEM, err := loadGitHubAppPrivateKey()
if err != nil {
return "", err
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("failed to parse GitHub App private key: %w", err)
}
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: appID,
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign GitHub App JWT: %w", err)
}
return signedToken, nil
}
func loadGitHubAppPrivateKey() ([]byte, error) {
raw := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
if raw == "" {
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is not configured")
}
normalized := strings.ReplaceAll(raw, "\\n", "\n")
if strings.Contains(normalized, "BEGIN ") {
return []byte(normalized), nil
}
decoded, err := base64.StdEncoding.DecodeString(normalized)
if err != nil {
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is neither PEM nor base64-encoded PEM")
}
return decoded, nil
}
func redirectToGitHubIntegrationPage(c *gin.Context, success bool, installationID int64, setupAction, errorCode string) {
frontendURL := strings.TrimSpace(os.Getenv("FRONTEND_URL"))
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
frontendURL = strings.TrimRight(frontendURL, "/")
params := url.Values{}
if success {
params.Set("github_app_installed", "1")
params.Set("installation_id", strconv.FormatInt(installationID, 10))
if setupAction != "" {
params.Set("setup_action", setupAction)
}
} else {
params.Set("github_app_error", errorCode)
if installationID > 0 {
params.Set("installation_id", strconv.FormatInt(installationID, 10))
}
}
redirectURL := fmt.Sprintf("%s/app/github?%s", frontendURL, params.Encode())
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func truncateString(value string, limit int) string {
if len(value) <= limit {
return value
}
if limit < 4 {
return value[:limit]
}
return value[:limit-3] + "..."
}
+365
View File
@@ -0,0 +1,365 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/trackeep/backend/models"
)
const defaultOAuthServiceURL = "https://oauth.trackeep.org"
type centralizedOAuthUser struct {
ID int `json:"id"`
GitHubID int `json:"github_id"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
type centralizedOAuthValidationResponse struct {
Token string `json:"token"`
User centralizedOAuthUser `json:"user"`
}
func getOAuthServiceURL() string {
value := strings.TrimSpace(os.Getenv("OAUTH_SERVICE_URL"))
if value == "" {
value = strings.TrimSpace(os.Getenv("VITE_OAUTH_SERVICE_URL"))
}
if value == "" {
value = defaultOAuthServiceURL
}
return strings.TrimRight(value, "/")
}
func headerValue(headers http.Header, key string) string {
raw := strings.TrimSpace(headers.Get(key))
if raw == "" {
return ""
}
for _, part := range strings.Split(raw, ",") {
candidate := strings.TrimSpace(part)
if candidate != "" {
return candidate
}
}
return ""
}
func backendPublicBaseURL(r *http.Request) string {
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_API_URL")); baseURL != "" {
return strings.TrimRight(baseURL, "/")
}
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); baseURL != "" {
return strings.TrimRight(baseURL, "/")
}
scheme := "http"
if forwardedProto := headerValue(r.Header, "X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
} else if r.TLS != nil {
scheme = "https"
}
host := headerValue(r.Header, "X-Forwarded-Host")
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
return fmt.Sprintf("%s://%s", scheme, host)
}
func normalizeFrontendRedirectURL(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
if parsed.Path == "" || parsed.Path == "/" {
parsed.Path = "/auth/callback"
}
return parsed.String()
}
func resolveFrontendRedirectURL(r *http.Request) string {
if value := normalizeFrontendRedirectURL(r.URL.Query().Get("frontend_redirect")); value != "" {
return value
}
if value := normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL")); value != "" {
return value
}
if origin := normalizeFrontendRedirectURL(r.Header.Get("Origin")); origin != "" {
return origin
}
referer := strings.TrimSpace(r.Header.Get("Referer"))
if referer != "" {
if parsed, err := url.Parse(referer); err == nil && parsed.Scheme != "" && parsed.Host != "" {
return normalizeFrontendRedirectURL((&url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: "/auth/callback",
}).String())
}
}
return ""
}
func buildOAuthCallbackURL(r *http.Request, frontendRedirect string) string {
baseURL := backendPublicBaseURL(r)
if baseURL == "" {
return ""
}
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/oauth/callback")
if err != nil {
return ""
}
if frontendRedirect != "" {
query := callbackURL.Query()
query.Set("frontend_redirect", frontendRedirect)
callbackURL.RawQuery = query.Encode()
}
return callbackURL.String()
}
func buildFrontendCallbackRedirectURL(frontendRedirect, token string) string {
redirectTarget := normalizeFrontendRedirectURL(frontendRedirect)
if redirectTarget == "" {
redirectTarget = normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL"))
}
if redirectTarget == "" {
return ""
}
parsed, err := url.Parse(redirectTarget)
if err != nil {
return ""
}
query := parsed.Query()
query.Set("token", token)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func validateCentralizedOAuthToken(ctx context.Context, token string) (*centralizedOAuthValidationResponse, error) {
serviceURL := getOAuthServiceURL()
if serviceURL == "" {
return nil, fmt.Errorf("oauth service url not configured")
}
requestBody, err := json.Marshal(map[string]string{"token": token})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/api/v1/auth/oauth/callback", bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("oauth service validation failed: %s", message)
}
var response centralizedOAuthValidationResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
return &response, nil
}
func parseOAuthTokenClaimsUnverified(token string) (jwt.MapClaims, error) {
parser := jwt.NewParser()
parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, err
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
func getAccessTokenFromOAuthClaims(claims jwt.MapClaims) string {
accessToken, _ := claims["access_token"].(string)
return strings.TrimSpace(accessToken)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func uniqueUsername(base string, db *gorm.DB, excludeUserID uint) string {
candidate := strings.TrimSpace(base)
if candidate == "" {
candidate = "user"
}
for suffix := 0; ; suffix++ {
username := candidate
if suffix > 0 {
username = fmt.Sprintf("%s-%d", candidate, suffix+1)
}
var existing models.User
err := db.Where("username = ?", username).First(&existing).Error
if err == nil {
if excludeUserID != 0 && existing.ID == excludeUserID {
return username
}
continue
}
if err == gorm.ErrRecordNotFound {
return username
}
return username
}
}
func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser) (*models.User, error) {
var user models.User
var err error
normalizedEmail := strings.TrimSpace(controllerUser.Email)
normalizedUsername := firstNonEmpty(controllerUser.Username, strings.Split(normalizedEmail, "@")[0], "user")
fullName := firstNonEmpty(controllerUser.Name, controllerUser.Username, normalizedEmail)
provider := "email"
if controllerUser.GitHubID != 0 {
provider = "github"
err = db.Where("github_id = ?", controllerUser.GitHubID).First(&user).Error
} else {
err = gorm.ErrRecordNotFound
}
if err != nil && normalizedEmail != "" {
err = db.Where("email = ?", normalizedEmail).First(&user).Error
}
if err == nil {
updates := map[string]interface{}{
"email": normalizedEmail,
"username": uniqueUsername(normalizedUsername, db, user.ID),
"full_name": fullName,
"avatar_url": controllerUser.AvatarURL,
"provider": provider,
}
if controllerUser.GitHubID != 0 {
updates["github_id"] = controllerUser.GitHubID
}
now := time.Now()
updates["last_login_at"] = &now
if err := db.Model(&user).Updates(updates).Error; err != nil {
return nil, err
}
if err := db.First(&user, user.ID).Error; err != nil {
return nil, err
}
return &user, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
var userCount int64
if err := db.Model(&models.User{}).Count(&userCount).Error; err != nil {
return nil, err
}
randomPassword := generateRandomString(32)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
role := "user"
if userCount == 0 {
role = "admin"
}
now := time.Now()
user = models.User{
Email: normalizedEmail,
Username: uniqueUsername(normalizedUsername, db, 0),
Password: string(hashedPassword),
FullName: fullName,
Role: role,
Theme: "dark",
GitHubID: controllerUser.GitHubID,
AvatarURL: controllerUser.AvatarURL,
Provider: provider,
LastLoginAt: &now,
}
if err := db.Create(&user).Error; err != nil {
return nil, err
}
_ = ensureMessagingDefaults(db, user.ID)
return &user, nil
}
+95
View File
@@ -0,0 +1,95 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestValidateCentralizedOAuthToken(t *testing.T) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST request, got %s", r.Method)
}
if r.URL.Path != "/api/v1/auth/oauth/callback" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if body["token"] != "controller-token" {
t.Fatalf("unexpected token payload: %#v", body)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(centralizedOAuthValidationResponse{
Token: "controller-token",
User: centralizedOAuthUser{
ID: 42,
GitHubID: 99,
Username: "octocat",
Email: "octocat@example.com",
},
})
}))
defer server.Close()
t.Setenv("OAUTH_SERVICE_URL", server.URL)
t.Setenv("VITE_OAUTH_SERVICE_URL", "")
response, err := validateCentralizedOAuthToken(context.Background(), "controller-token")
if err != nil {
t.Fatalf("validateCentralizedOAuthToken returned error: %v", err)
}
if response.User.Username != "octocat" {
t.Fatalf("unexpected user returned: %#v", response.User)
}
}
func TestBuildOAuthCallbackURLPreservesFrontendRedirect(t *testing.T) {
frontendRedirect := "https://app.example.com/auth/callback?next=%2Fapp"
req := httptest.NewRequest(http.MethodGet, "http://internal/api/v1/auth/github?frontend_redirect="+url.QueryEscape(frontendRedirect), nil)
req.Host = "api.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.example.com")
resolvedFrontendRedirect := resolveFrontendRedirectURL(req)
if resolvedFrontendRedirect != frontendRedirect {
t.Fatalf("unexpected frontend redirect: %s", resolvedFrontendRedirect)
}
callbackURL := buildOAuthCallbackURL(req, resolvedFrontendRedirect)
expected := "https://api.example.com/api/v1/auth/oauth/callback?frontend_redirect=" + url.QueryEscape(frontendRedirect)
if callbackURL != expected {
t.Fatalf("unexpected callback URL: got %s want %s", callbackURL, expected)
}
}
func TestParseOAuthTokenClaimsUnverified(t *testing.T) {
signedToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": 1,
"access_token": "gho_test_token",
}).SignedString([]byte("test-secret"))
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
claims, err := parseOAuthTokenClaimsUnverified(signedToken)
if err != nil {
t.Fatalf("parseOAuthTokenClaimsUnverified returned error: %v", err)
}
if accessToken := getAccessTokenFromOAuthClaims(claims); accessToken != "gho_test_token" {
t.Fatalf("unexpected access token: %s", accessToken)
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func GetUpdateSettingsForAPI(userID int) (UpdateSettings, error) {
func getDefaultUpdateSettings() UpdateSettings { func getDefaultUpdateSettings() UpdateSettings {
return UpdateSettings{ return UpdateSettings{
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.tdvorak.dev"), OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.trackeep.org"),
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false), AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"), UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false), PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
+87
View File
@@ -0,0 +1,87 @@
package db
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/trackeep/backend/internal/db/sqlc"
)
// DB wraps the sqlc DB with additional functionality
type DB struct {
*sqlc.Queries
pool *pgxpool.Pool
}
// NewDB creates a new database connection
func NewDB() (*DB, error) {
// Get database connection string
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
os.Getenv("DB_HOST"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_NAME"),
os.Getenv("DB_PORT"),
os.Getenv("DB_SSL_MODE"),
)
// Create connection pool
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Create queries instance
queries := sqlc.New(pool)
return &DB{
Queries: queries,
pool: pool,
}, nil
}
// Close closes the database connection
func (db *DB) Close() error {
db.pool.Close()
return nil
}
// BeginTx starts a transaction
func (db *DB) BeginTx(ctx context.Context) (*DB, error) {
tx, err := db.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
return &DB{
Queries: sqlc.New(tx),
pool: nil, // Not using pool in transaction mode
}, nil
}
// Commit commits the transaction
func (db *DB) Commit() error {
// This would need to be implemented with transaction tracking
// For now, transactions should be handled by the caller
return nil
}
// Rollback rolls back the transaction
func (db *DB) Rollback() error {
// This would need to be implemented with transaction tracking
// For now, transactions should be handled by the caller
return nil
}
// GetPool returns the underlying connection pool
func (db *DB) GetPool() *pgxpool.Pool {
return db.pool
}
+340
View File
@@ -0,0 +1,340 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: bookmarks.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddBookmarkTag = `-- name: AddBookmarkTag :exec
INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`
type AddBookmarkTagParams struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error {
_, err := q.db.Exec(ctx, AddBookmarkTag, arg.BookmarkID, arg.TagID)
return err
}
const CreateBookmark = `-- name: CreateBookmark :one
INSERT INTO bookmarks (title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
`
type CreateBookmarkParams struct {
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
UserID pgtype.UUID `json:"userId"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
}
func (q *Queries) CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, CreateBookmark,
arg.Title,
arg.Url,
arg.Description,
arg.FaviconUrl,
arg.ScreenshotUrl,
arg.UserID,
arg.IsArchived,
arg.IsFavorite,
)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteBookmark = `-- name: DeleteBookmark :exec
DELETE FROM bookmarks WHERE id = $1 AND user_id = $2
`
type DeleteBookmarkParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error {
_, err := q.db.Exec(ctx, DeleteBookmark, arg.ID, arg.UserID)
return err
}
const GetBookmarkByID = `-- name: GetBookmarkByID :one
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE id = $1 AND user_id = $2 LIMIT 1
`
type GetBookmarkByIDParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, GetBookmarkByID, arg.ID, arg.UserID)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetBookmarksByTag = `-- name: GetBookmarksByTag :many
SELECT b.id, b.title, b.url, b.description, b.favicon_url, b.screenshot_url, b.user_id, b.is_archived, b.is_favorite, b.created_at, b.updated_at
FROM bookmarks b
INNER JOIN bookmark_tags bt ON b.id = bt.bookmark_id
INNER JOIN tags t ON bt.tag_id = t.id
WHERE t.id = $1 AND b.user_id = $2
ORDER BY b.created_at DESC
LIMIT $3 OFFSET $4
`
type GetBookmarksByTagParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, GetBookmarksByTag,
arg.ID,
arg.UserID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetBookmarksByUser = `-- name: GetBookmarksByUser :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type GetBookmarksByUserParams struct {
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, GetBookmarksByUser, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveBookmarkTag = `-- name: RemoveBookmarkTag :exec
DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2
`
type RemoveBookmarkTagParams struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error {
_, err := q.db.Exec(ctx, RemoveBookmarkTag, arg.BookmarkID, arg.TagID)
return err
}
const SearchBookmarks = `-- name: SearchBookmarks :many
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
FROM bookmarks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2 OR
url ILIKE $2
)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`
type SearchBookmarksParams struct {
UserID pgtype.UUID `json:"userId"`
Title string `json:"title"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error) {
rows, err := q.db.Query(ctx, SearchBookmarks,
arg.UserID,
arg.Title,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Bookmark{}
for rows.Next() {
var i Bookmark
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateBookmark = `-- name: UpdateBookmark :one
UPDATE bookmarks
SET title = $2,
url = $3,
description = $4,
favicon_url = $5,
screenshot_url = $6,
is_archived = $7,
is_favorite = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $9
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
`
type UpdateBookmarkParams struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error) {
row := q.db.QueryRow(ctx, UpdateBookmark,
arg.ID,
arg.Title,
arg.Url,
arg.Description,
arg.FaviconUrl,
arg.ScreenshotUrl,
arg.IsArchived,
arg.IsFavorite,
arg.UserID,
)
var i Bookmark
err := row.Scan(
&i.ID,
&i.Title,
&i.Url,
&i.Description,
&i.FaviconUrl,
&i.ScreenshotUrl,
&i.UserID,
&i.IsArchived,
&i.IsFavorite,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+115
View File
@@ -0,0 +1,115 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
)
type AuditLog struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID pgtype.UUID `json:"resourceId"`
OldValues []byte `json:"oldValues"`
NewValues []byte `json:"newValues"`
IpAddress *netip.Addr `json:"ipAddress"`
UserAgent *string `json:"userAgent"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
}
type Bookmark struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Url string `json:"url"`
Description *string `json:"description"`
FaviconUrl *string `json:"faviconUrl"`
ScreenshotUrl *string `json:"screenshotUrl"`
UserID pgtype.UUID `json:"userId"`
IsArchived *bool `json:"isArchived"`
IsFavorite *bool `json:"isFavorite"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type BookmarkTag struct {
BookmarkID pgtype.UUID `json:"bookmarkId"`
TagID pgtype.UUID `json:"tagId"`
}
type File struct {
ID pgtype.UUID `json:"id"`
Filename string `json:"filename"`
OriginalName string `json:"originalName"`
FileSize int64 `json:"fileSize"`
MimeType *string `json:"mimeType"`
FilePath string `json:"filePath"`
ThumbnailPath *string `json:"thumbnailPath"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type FileTag struct {
FileID pgtype.UUID `json:"fileId"`
TagID pgtype.UUID `json:"tagId"`
}
type Note struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Content *string `json:"content"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type NoteTag struct {
NoteID pgtype.UUID `json:"noteId"`
TagID pgtype.UUID `json:"tagId"`
}
type Tag struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Color *string `json:"color"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type Task struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
type TaskTag struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
+42
View File
@@ -0,0 +1,42 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error
AddTaskTag(ctx context.Context, arg AddTaskTagParams) error
CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error
DeleteTask(ctx context.Context, arg DeleteTaskParams) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error)
GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error)
GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error)
GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error)
GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error)
GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error)
GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error)
ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error)
RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error
RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error
SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error)
SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error)
UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error)
UpdateLastLogin(ctx context.Context, id pgtype.UUID) error
UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error)
UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error)
}
var _ Querier = (*Queries)(nil)
+395
View File
@@ -0,0 +1,395 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tasks.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddTaskTag = `-- name: AddTaskTag :exec
INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
`
type AddTaskTagParams struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) AddTaskTag(ctx context.Context, arg AddTaskTagParams) error {
_, err := q.db.Exec(ctx, AddTaskTag, arg.TaskID, arg.TagID)
return err
}
const CreateTask = `-- name: CreateTask :one
INSERT INTO tasks (title, description, status, priority, due_date, user_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
`
type CreateTaskParams struct {
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, CreateTask,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.UserID,
)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteTask = `-- name: DeleteTask :exec
DELETE FROM tasks WHERE id = $1 AND user_id = $2
`
type DeleteTaskParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) DeleteTask(ctx context.Context, arg DeleteTaskParams) error {
_, err := q.db.Exec(ctx, DeleteTask, arg.ID, arg.UserID)
return err
}
const GetTaskByID = `-- name: GetTaskByID :one
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE id = $1 AND user_id = $2 LIMIT 1
`
type GetTaskByIDParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) {
row := q.db.QueryRow(ctx, GetTaskByID, arg.ID, arg.UserID)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetTasksByStatus = `-- name: GetTasksByStatus :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND status = $2
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4
`
type GetTasksByStatusParams struct {
UserID pgtype.UUID `json:"userId"`
Status *string `json:"status"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByStatus,
arg.UserID,
arg.Status,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetTasksByTag = `-- name: GetTasksByTag :many
SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, t.user_id, t.created_at, t.updated_at
FROM tasks t
INNER JOIN task_tags tt ON t.id = tt.task_id
INNER JOIN tags tg ON tt.tag_id = tg.id
WHERE tg.id = $1 AND t.user_id = $2
ORDER BY
CASE t.priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
t.due_date ASC NULLS LAST,
t.created_at DESC
LIMIT $3 OFFSET $4
`
type GetTasksByTagParams struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByTag,
arg.ID,
arg.UserID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetTasksByUser = `-- name: GetTasksByUser :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $2 OFFSET $3
`
type GetTasksByUserParams struct {
UserID pgtype.UUID `json:"userId"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error) {
rows, err := q.db.Query(ctx, GetTasksByUser, arg.UserID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveTaskTag = `-- name: RemoveTaskTag :exec
DELETE FROM task_tags WHERE task_id = $1 AND tag_id = $2
`
type RemoveTaskTagParams struct {
TaskID pgtype.UUID `json:"taskId"`
TagID pgtype.UUID `json:"tagId"`
}
func (q *Queries) RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error {
_, err := q.db.Exec(ctx, RemoveTaskTag, arg.TaskID, arg.TagID)
return err
}
const SearchTasks = `-- name: SearchTasks :many
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND (
title ILIKE $2 OR
description ILIKE $2
)
ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
due_date ASC NULLS LAST,
created_at DESC
LIMIT $3 OFFSET $4
`
type SearchTasksParams struct {
UserID pgtype.UUID `json:"userId"`
Title string `json:"title"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error) {
rows, err := q.db.Query(ctx, SearchTasks,
arg.UserID,
arg.Title,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Task{}
for rows.Next() {
var i Task
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateTask = `-- name: UpdateTask :one
UPDATE tasks
SET title = $2,
description = $3,
status = $4,
priority = $5,
due_date = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $7
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
`
type UpdateTaskParams struct {
ID pgtype.UUID `json:"id"`
Title string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
DueDate pgtype.Timestamp `json:"dueDate"`
UserID pgtype.UUID `json:"userId"`
}
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
row := q.db.QueryRow(ctx, UpdateTask,
arg.ID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.DueDate,
arg.UserID,
)
var i Task
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.DueDate,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+273
View File
@@ -0,0 +1,273 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: users.sql
package sqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, first_name, last_name, avatar_url, is_active, is_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
`
type CreateUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
}
type CreateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
row := q.db.QueryRow(ctx, CreateUser,
arg.Email,
arg.PasswordHash,
arg.FirstName,
arg.LastName,
arg.AvatarUrl,
arg.IsActive,
arg.IsVerified,
)
var i CreateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1
`
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteUser, id)
return err
}
const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE email = $1 LIMIT 1
`
type GetUserByEmailRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, GetUserByEmail, email)
var i GetUserByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByID = `-- name: GetUserByID :one
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
WHERE id = $1 LIMIT 1
`
type GetUserByIDRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, GetUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListUsers = `-- name: ListUsers :many
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
type ListUsersParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListUsersRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
rows, err := q.db.Query(ctx, ListUsers, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUsersRow{}
for rows.Next() {
var i ListUsersRow
if err := rows.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLastLogin = `-- name: UpdateLastLogin :exec
UPDATE users
SET last_login = CURRENT_TIMESTAMP
WHERE id = $1
`
func (q *Queries) UpdateLastLogin(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UpdateLastLogin, id)
return err
}
const UpdateUser = `-- name: UpdateUser :one
UPDATE users
SET first_name = $2,
last_name = $3,
avatar_url = $4,
is_active = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
`
type UpdateUserParams struct {
ID pgtype.UUID `json:"id"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
}
type UpdateUserRow struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
AvatarUrl *string `json:"avatarUrl"`
IsActive *bool `json:"isActive"`
IsVerified *bool `json:"isVerified"`
LastLogin pgtype.Timestamp `json:"lastLogin"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
row := q.db.QueryRow(ctx, UpdateUser,
arg.ID,
arg.FirstName,
arg.LastName,
arg.AvatarUrl,
arg.IsActive,
)
var i UpdateUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.FirstName,
&i.LastName,
&i.AvatarUrl,
&i.IsActive,
&i.IsVerified,
&i.LastLogin,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+11 -4
View File
@@ -149,7 +149,7 @@ func main() {
// }() // }()
// Set Gin mode // Set Gin mode
if os.Getenv("GIN_MODE") == "release" { if os.Getenv("GIN_MODE") == "release" || os.Getenv("GIN_MODE") == "production" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
@@ -159,6 +159,7 @@ func main() {
// Middleware // Middleware
r.Use(gin.Logger()) r.Use(gin.Logger())
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(middleware.CORSMiddleware())
r.Use(middleware.CacheMiddleware(cacheConfig)) // Add DragonflyDB cache middleware r.Use(middleware.CacheMiddleware(cacheConfig)) // Add DragonflyDB cache middleware
r.Use(middleware.CacheInvalidationMiddleware(dragonflyClient)) // Add cache invalidation r.Use(middleware.CacheInvalidationMiddleware(dragonflyClient)) // Add cache invalidation
r.Use(middleware.SessionMiddleware()) // Add session middleware r.Use(middleware.SessionMiddleware()) // Add session middleware
@@ -172,8 +173,6 @@ func main() {
// Apply general rate limiting to all endpoints // Apply general rate limiting to all endpoints
r.Use(middleware.GeneralRateLimit(rateLimiters["general"])) r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
r.Use(middleware.CORSMiddleware())
r.GET("/health", handlers.HealthCheck) r.GET("/health", handlers.HealthCheck)
r.GET("/ready", handlers.ReadinessCheck) r.GET("/ready", handlers.ReadinessCheck)
r.GET("/live", handlers.LivenessCheck) r.GET("/live", handlers.LivenessCheck)
@@ -231,7 +230,7 @@ func main() {
auth.POST("/login", handlers.Login) auth.POST("/login", handlers.Login)
auth.POST("/login-totp", handlers.LoginWithTOTP) auth.POST("/login-totp", handlers.LoginWithTOTP)
auth.POST("/logout", handlers.Logout) auth.POST("/logout", handlers.Logout)
auth.GET("/me", handlers.GetCurrentUserWithGitHub) auth.GET("/me", handlers.AuthMiddleware(), handlers.GetCurrentUserWithGitHub)
auth.POST("/password-reset", handlers.RequestPasswordReset) auth.POST("/password-reset", handlers.RequestPasswordReset)
auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset) auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset)
@@ -241,11 +240,19 @@ func main() {
auth.GET("/oauth/callback", handlers.HandleOAuthCallback) auth.GET("/oauth/callback", handlers.HandleOAuthCallback)
} }
// GitHub App callback (public for GitHub redirect)
v1.GET("/github/app/callback", handlers.GitHubAppInstallCallback)
// GitHub routes (protected) // GitHub routes (protected)
github := v1.Group("/github") github := v1.Group("/github")
github.Use(handlers.AuthMiddleware()) github.Use(handlers.AuthMiddleware())
{ {
github.GET("/repos", handlers.GetGitHubRepos) github.GET("/repos", handlers.GetGitHubRepos)
github.GET("/app/status", handlers.GetGitHubAppStatus)
github.GET("/app/install-url", handlers.GetGitHubAppInstallURL)
github.GET("/app/repos", handlers.GetGitHubAppRepos)
github.GET("/backups", handlers.GetGitHubBackups)
github.POST("/backups", handlers.BackupGitHubRepositories)
} }
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest) v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
+20
View File
@@ -44,6 +44,12 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
return return
} }
// Skip caching for auth/bootstrap requests and authenticated traffic.
if shouldSkipCache(c) {
c.Next()
return
}
// Generate cache key // Generate cache key
cacheKey := generateCacheKey(c, config.KeyPrefix) cacheKey := generateCacheKey(c, config.KeyPrefix)
@@ -82,6 +88,20 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
} }
} }
func shouldSkipCache(c *gin.Context) bool {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/v1/auth/") {
return true
}
if c.GetHeader("Authorization") != "" || c.GetHeader("Cookie") != "" {
return true
}
return false
}
// generateCacheKey creates a unique cache key for the request // generateCacheKey creates a unique cache key for the request
func generateCacheKey(c *gin.Context, prefix string) string { func generateCacheKey(c *gin.Context, prefix string) string {
// Include path, query params, and user ID if available // Include path, query params, and user ID if available
+5 -13
View File
@@ -27,24 +27,16 @@ func CORSMiddleware() gin.HandlerFunc {
} }
origin := c.Request.Header.Get("Origin") origin := c.Request.Header.Get("Origin")
allowed := false
if allowedOrigins == "*" { // Always set CORS headers
allowed = true
} else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
allowed = true
break
}
}
}
if allowed {
if allowedOrigins == "*" { if allowedOrigins == "*" {
c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Origin", "*")
} else { } else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Origin", origin)
break
}
} }
} }
+89 -100
View File
@@ -2,14 +2,12 @@ package middleware
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt"
"io" "io"
"log"
"os"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/trackeep/backend/config"
"go.uber.org/zap"
) )
// LoggerConfig holds configuration for the logger // LoggerConfig holds configuration for the logger
@@ -19,17 +17,14 @@ type LoggerConfig struct {
EnableJSON bool EnableJSON bool
} }
// Logger returns a middleware that logs HTTP requests // GetLogger returns the logger instance
func (lc LoggerConfig) GetLogger() *zap.Logger {
return config.GetLogger()
}
// Logger returns a middleware that logs HTTP requests using Zap
func Logger(config LoggerConfig) gin.HandlerFunc { func Logger(config LoggerConfig) gin.HandlerFunc {
// Create log file if specified logger := config.GetLogger()
var file *os.File
if config.LogFile != "" {
var err error
file, err = os.OpenFile(config.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Printf("Failed to open log file: %v", err)
}
}
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Create log entry // Create log entry
@@ -45,7 +40,9 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
} }
// Add user ID if available // Add user ID if available
if userID, exists := param.Keys["user_id"]; exists { var userID interface{}
if uid, exists := param.Keys["user_id"]; exists {
userID = uid
entry["user_id"] = userID entry["user_id"] = userID
} }
@@ -54,40 +51,39 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
entry["error"] = param.ErrorMessage entry["error"] = param.ErrorMessage
} }
// Format output // Log with Zap
var output string
if config.EnableJSON {
jsonData, _ := json.Marshal(entry)
output = string(jsonData) + "\n"
} else {
output = fmt.Sprintf("[%s] %s %s %d %s %s %s",
entry["timestamp"],
entry["method"],
entry["path"],
entry["status"],
entry["latency"],
entry["client_ip"],
entry["user_agent"],
)
if userID, exists := entry["user_id"]; exists {
output += fmt.Sprintf(" user_id:%v", userID)
}
if param.ErrorMessage != "" { if param.ErrorMessage != "" {
output += fmt.Sprintf(" error:%s", param.ErrorMessage) logger.Error("HTTP request",
} zap.String("method", param.Method),
output += "\n" zap.String("path", param.Path),
zap.Int("status", param.StatusCode),
zap.Duration("latency", param.Latency),
zap.String("client_ip", param.ClientIP),
zap.String("user_agent", param.Request.UserAgent()),
zap.Any("user_id", userID),
zap.String("error", param.ErrorMessage),
)
} else {
logger.Info("HTTP request",
zap.String("method", param.Method),
zap.String("path", param.Path),
zap.Int("status", param.StatusCode),
zap.Duration("latency", param.Latency),
zap.String("client_ip", param.ClientIP),
zap.String("user_agent", param.Request.UserAgent()),
zap.Any("user_id", userID),
)
} }
// Write to file and console // Return empty string since Zap handles output
if file != nil { return ""
file.WriteString(output)
}
return output
}) })
} }
// RequestLogger logs detailed request information // RequestLogger logs detailed request information using Zap
func RequestLogger() gin.HandlerFunc { func RequestLogger() gin.HandlerFunc {
logger := config.GetLogger()
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()
path := c.Request.URL.Path path := c.Request.URL.Path
@@ -104,12 +100,6 @@ func RequestLogger() gin.HandlerFunc {
// Calculate latency // Calculate latency
latency := time.Since(start) latency := time.Since(start)
// Get client IP
clientIP := c.ClientIP()
// Get status code
statusCode := c.Writer.Status()
// Get request ID // Get request ID
requestID := c.GetHeader("X-Request-ID") requestID := c.GetHeader("X-Request-ID")
if requestID == "" { if requestID == "" {
@@ -122,46 +112,52 @@ func RequestLogger() gin.HandlerFunc {
userID = uid userID = uid
} }
// Create log entry // Log request body for POST/PUT requests (excluding sensitive data)
logEntry := map[string]interface{}{ var requestBody string
"timestamp": start.Format(time.RFC3339), if c.Request.Method == "POST" || c.Request.Method == "PUT" {
"request_id": requestID, requestBody = logRequestBody(c)
"method": c.Request.Method, }
"path": path,
"query": raw, // Create log fields
"status": statusCode, fields := []zap.Field{
"latency_ms": latency.Milliseconds(), zap.String("request_id", requestID),
"client_ip": clientIP, zap.String("method", c.Request.Method),
"user_agent": c.Request.UserAgent(), zap.String("path", path),
"referer": c.Request.Referer(), zap.String("query", raw),
"content_type": c.GetHeader("Content-Type"), zap.Int("status", c.Writer.Status()),
"content_length": c.Request.ContentLength, zap.Duration("latency_ms", latency),
zap.String("client_ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("referer", c.Request.Referer()),
zap.String("content_type", c.GetHeader("Content-Type")),
zap.Int64("content_length", c.Request.ContentLength),
} }
if userID != nil { if userID != nil {
logEntry["user_id"] = userID fields = append(fields, zap.Any("user_id", userID))
} }
// Log request body for POST/PUT requests (excluding sensitive data) if requestBody != "" {
if c.Request.Method == "POST" || c.Request.Method == "PUT" { fields = append(fields, zap.String("request_body", requestBody))
body := logRequestBody(c)
if body != "" {
logEntry["request_body"] = body
}
} }
// Log response size
if c.Writer.Size() > 0 { if c.Writer.Size() > 0 {
logEntry["response_size"] = c.Writer.Size() fields = append(fields, zap.Int("response_size", c.Writer.Size()))
} }
// Log errors
if len(c.Errors) > 0 { if len(c.Errors) > 0 {
logEntry["errors"] = c.Errors.String() fields = append(fields, zap.String("errors", c.Errors.String()))
} }
// Write structured log // Log based on status code
logJSON(logEntry) statusCode := c.Writer.Status()
if statusCode >= 500 {
logger.Error("HTTP request", fields...)
} else if statusCode >= 400 {
logger.Warn("HTTP request", fields...)
} else {
logger.Info("HTTP request", fields...)
}
} }
} }
@@ -194,17 +190,6 @@ func logRequestBody(c *gin.Context) string {
return string(bodyBytes) return string(bodyBytes)
} }
// logJSON writes structured JSON logs
func logJSON(data map[string]interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
log.Printf("Failed to marshal log entry: %v", err)
return
}
log.Println(string(jsonData))
}
// SecurityLogger logs security-related events // SecurityLogger logs security-related events
func SecurityLogger() gin.HandlerFunc { func SecurityLogger() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
@@ -232,19 +217,21 @@ func SecurityLogger() gin.HandlerFunc {
} }
} }
// logSecurityEvent logs security-related events // logSecurityEvent logs security-related events using Zap
func logSecurityEvent(eventType string, data map[string]interface{}) { func logSecurityEvent(eventType string, data map[string]interface{}) {
event := map[string]interface{}{ logger := config.GetLogger()
"event_type": "security",
"event": eventType, fields := []zap.Field{
"timestamp": time.Now().Format(time.RFC3339), zap.String("event_type", "security"),
zap.String("event", eventType),
zap.String("timestamp", time.Now().Format(time.RFC3339)),
} }
for k, v := range data { for k, v := range data {
event[k] = v fields = append(fields, zap.Any(k, v))
} }
logJSON(event) logger.Warn("Security event", fields...)
} }
// PerformanceLogger logs performance metrics // PerformanceLogger logs performance metrics
@@ -269,17 +256,19 @@ func PerformanceLogger() gin.HandlerFunc {
} }
} }
// logPerformanceEvent logs performance-related events // logPerformanceEvent logs performance-related events using Zap
func logPerformanceEvent(eventType string, data map[string]interface{}) { func logPerformanceEvent(eventType string, data map[string]interface{}) {
event := map[string]interface{}{ logger := config.GetLogger()
"event_type": "performance",
"event": eventType, fields := []zap.Field{
"timestamp": time.Now().Format(time.RFC3339), zap.String("event_type", "performance"),
zap.String("event", eventType),
zap.String("timestamp", time.Now().Format(time.RFC3339)),
} }
for k, v := range data { for k, v := range data {
event[k] = v fields = append(fields, zap.Any(k, v))
} }
logJSON(event) logger.Info("Performance event", fields...)
} }
+149
View File
@@ -0,0 +1,149 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
is_verified BOOLEAN DEFAULT false,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Tags table
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
color VARCHAR(7) DEFAULT '#39b9ff',
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(name, user_id)
);
-- Bookmarks table
CREATE TABLE IF NOT EXISTS bookmarks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
favicon_url TEXT,
screenshot_url TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
is_archived BOOLEAN DEFAULT false,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bookmark tags junction table
CREATE TABLE IF NOT EXISTS bookmark_tags (
bookmark_id UUID REFERENCES bookmarks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (bookmark_id, tag_id)
);
-- Tasks table
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
priority VARCHAR(10) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
due_date TIMESTAMP,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Task tags junction table
CREATE TABLE IF NOT EXISTS task_tags (
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Note tags junction table
CREATE TABLE IF NOT EXISTS note_tags (
note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
-- Files table
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
file_path TEXT NOT NULL,
thumbnail_path TEXT,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- File tags junction table
CREATE TABLE IF NOT EXISTS file_tags (
file_id UUID REFERENCES files(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_id ON bookmarks(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
-- +goose Down
-- Drop tables in reverse order due to foreign key constraints
DROP TABLE IF EXISTS file_tags;
DROP TABLE IF EXISTS note_tags;
DROP TABLE IF EXISTS task_tags;
DROP TABLE IF EXISTS bookmark_tags;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS bookmarks;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS users;

Some files were not shown because too many files have changed in this diff Show More