Compare commits
9 Commits
bugfix-tim
...
a91a8e9dfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a91a8e9dfa | ||
|
|
fb94745588 | ||
|
|
8eddbed00b | ||
|
|
e83d247cf9 | ||
|
|
88866f73e6 | ||
| ca521000bf | |||
|
|
a7ab55932f | ||
|
|
64211e6a49 | ||
| cd03d8751e |
251
.agents/skills/feature-planning/SKILL.md
Normal file
251
.agents/skills/feature-planning/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Feature Planning Skill
|
||||
|
||||
This skill provides a structured workflow for implementing new features in the TimeTracker project. Use this skill when the user requests a new feature or significant functionality change.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
```
|
||||
1. Requirements Discovery (iterative)
|
||||
└── Clarify edge cases, acceptance criteria, constraints
|
||||
|
||||
2. Feature Plan Creation
|
||||
└── docs/features/{feature-name}.md
|
||||
|
||||
3. Implementation
|
||||
└── Use plan as single source of truth
|
||||
```
|
||||
|
||||
## Phase 1: Requirements Discovery
|
||||
|
||||
**Goal:** Understand exactly what needs to be built before writing any code.
|
||||
|
||||
### Questions to Ask
|
||||
|
||||
Ask targeted questions to clarify:
|
||||
|
||||
#### Core Functionality
|
||||
- What is the primary purpose of this feature?
|
||||
- What user problem does it solve?
|
||||
- How should users interact with this feature?
|
||||
|
||||
#### Data & API
|
||||
- What new data needs to be stored?
|
||||
- What existing data structures are affected?
|
||||
- What API endpoints are needed (if any)?
|
||||
|
||||
#### User Interface
|
||||
- Where in the UI should this feature appear?
|
||||
- What views or components are needed?
|
||||
- What user interactions are required?
|
||||
|
||||
#### Edge Cases
|
||||
- What happens when inputs are invalid?
|
||||
- How should errors be handled?
|
||||
- What are the boundary conditions?
|
||||
- Are there any race conditions to consider?
|
||||
|
||||
#### Constraints
|
||||
- Are there performance requirements?
|
||||
- Any security considerations?
|
||||
- Browser/device compatibility?
|
||||
- Integration with existing features?
|
||||
|
||||
### Iteration Process
|
||||
|
||||
1. **Initial Questions** — Ask 3-5 focused questions based on the feature description
|
||||
2. **Wait for Answers** — Let the programmer respond
|
||||
3. **Follow-up Questions** — Dig deeper based on responses
|
||||
4. **Confirm Understanding** — Summarize your understanding and ask for confirmation
|
||||
5. **Iterate** — Repeat until requirements are clear
|
||||
|
||||
### Example Question Flow
|
||||
|
||||
```
|
||||
User: "Add the ability to export time entries to CSV"
|
||||
|
||||
Agent: "Before I start planning, I have a few questions:
|
||||
|
||||
1. Should the export include all entries or only filtered entries (if filters are active)?
|
||||
2. What columns should be included in the CSV? (date, duration, project, client, description?)
|
||||
3. Should the CSV include break minutes and net duration, or just total time?
|
||||
4. Is there a date range limit, or can users export all historical data?
|
||||
5. Should the export be triggered from the Time Entries page, or from a separate Export page?"
|
||||
```
|
||||
|
||||
## Phase 2: Feature Plan
|
||||
|
||||
**Goal:** Create a comprehensive plan document before implementation.
|
||||
|
||||
### Plan Location
|
||||
|
||||
Create the plan at: `docs/features/{feature-name}.md`
|
||||
|
||||
Use kebab-case for the filename (e.g., `csv-export.md`, `dark-mode.md`).
|
||||
|
||||
### Plan Template
|
||||
|
||||
```markdown
|
||||
# Feature: {Feature Name}
|
||||
|
||||
## Overview
|
||||
|
||||
Brief description of what this feature does and why it's needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- Requirement 1
|
||||
- Requirement 2
|
||||
- Requirement 3
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Performance: ...
|
||||
- Security: ...
|
||||
- Usability: ...
|
||||
|
||||
### Constraints
|
||||
- Constraint 1
|
||||
- Constraint 2
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture Decisions
|
||||
- Decision 1 and rationale
|
||||
- Decision 2 and rationale
|
||||
|
||||
### Database Changes
|
||||
- New tables/columns
|
||||
- Migrations needed
|
||||
- Data migration strategy (if any)
|
||||
|
||||
### API Changes
|
||||
- New endpoints
|
||||
- Modified endpoints
|
||||
- Request/response formats
|
||||
|
||||
### Frontend Changes
|
||||
- New components
|
||||
- Modified components
|
||||
- State management approach
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Step 1: Backend - Database**
|
||||
- Create migration
|
||||
- Update Prisma schema
|
||||
- Regenerate client
|
||||
|
||||
2. **Step 2: Backend - Service**
|
||||
- Add service methods
|
||||
- Add validation schemas
|
||||
|
||||
3. **Step 3: Backend - Routes**
|
||||
- Create route handlers
|
||||
- Add middleware
|
||||
|
||||
4. **Step 4: Frontend - API Client**
|
||||
- Add API functions
|
||||
|
||||
5. **Step 5: Frontend - Components**
|
||||
- Create/update components
|
||||
- Add to routes if needed
|
||||
|
||||
6. **Step 6: Testing**
|
||||
- Manual testing steps
|
||||
- Edge case verification
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `backend/src/services/export.service.ts`
|
||||
- `frontend/src/hooks/useExport.ts`
|
||||
|
||||
### Modified Files
|
||||
- `backend/src/routes/timeEntry.routes.ts` — Add export endpoint
|
||||
- `frontend/src/pages/TimeEntriesPage.tsx` — Add export button
|
||||
- `frontend/src/api/timeEntries.ts` — Add export function
|
||||
|
||||
### Database
|
||||
- No changes required (or specify migration)
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Handling |
|
||||
|------|----------|
|
||||
| No entries match filter | Show empty state, export empty CSV with headers |
|
||||
| Very large export (>10k entries) | Stream response, show progress indicator |
|
||||
| User cancels export mid-stream | Gracefully close connection |
|
||||
| Invalid date range | Return 400 error with clear message |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
1. Navigate to Time Entries page
|
||||
2. Apply date filter
|
||||
3. Click Export button
|
||||
4. Verify CSV downloads with correct data
|
||||
5. Open CSV and verify format
|
||||
|
||||
### Edge Case Testing
|
||||
1. Export with no entries
|
||||
2. Export with 1000+ entries
|
||||
3. Export with special characters in descriptions
|
||||
4. Export while timer is running
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] Question 1 (to be resolved during implementation)
|
||||
- [ ] Question 2
|
||||
```
|
||||
|
||||
### Plan Review
|
||||
|
||||
After creating the plan:
|
||||
|
||||
1. Present the plan to the programmer
|
||||
2. Ask for feedback and approval
|
||||
3. Make requested changes
|
||||
4. Get final approval before proceeding to implementation
|
||||
|
||||
## Phase 3: Implementation
|
||||
|
||||
**Goal:** Implement the feature exactly as planned.
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Read the plan first** — Start by reading the full plan file
|
||||
2. **Follow the plan** — Implement step by step as outlined
|
||||
3. **Update if needed** — If implementation differs from plan, update the plan file
|
||||
4. **Document changes** — After completion, update relevant documentation
|
||||
|
||||
### Implementation Checklist
|
||||
|
||||
- [ ] Read `docs/features/{feature-name}.md`
|
||||
- [ ] Implement database changes (if any)
|
||||
- [ ] Implement backend service logic
|
||||
- [ ] Implement backend routes
|
||||
- [ ] Implement frontend API client
|
||||
- [ ] Implement frontend components
|
||||
- [ ] Run linting: `npm run lint`
|
||||
- [ ] Manual testing
|
||||
- [ ] Update plan if implementation differs
|
||||
- [ ] Update `project.md` if requirements changed
|
||||
- [ ] Update `README.md` if API changed
|
||||
- [ ] Update `AGENTS.md` if patterns changed
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Commands
|
||||
- Frontend lint: `npm run lint` (in `frontend/`)
|
||||
- Backend build: `npm run build` (in `backend/`)
|
||||
- DB migration: `npm run db:migrate` (in `backend/`)
|
||||
- DB generate: `npm run db:generate` (in `backend/`)
|
||||
|
||||
### File Locations
|
||||
- Backend routes: `backend/src/routes/`
|
||||
- Backend services: `backend/src/services/`
|
||||
- Backend schemas: `backend/src/schemas/`
|
||||
- Frontend pages: `frontend/src/pages/`
|
||||
- Frontend hooks: `frontend/src/hooks/`
|
||||
- Frontend API: `frontend/src/api/`
|
||||
- Feature plans: `docs/features/`
|
||||
92
AGENTS.md
92
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — Codebase Guide for AI Coding Agents
|
||||
|
||||
This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. Read it in full before making changes.
|
||||
This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. **Read it in full before making changes.**
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -18,17 +18,103 @@ This document describes the structure, conventions, and commands for the `vibe_c
|
||||
├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL)
|
||||
│ └── src/
|
||||
│ ├── auth/ # OIDC + JWT logic
|
||||
│ ├── config/ # Configuration constants
|
||||
│ ├── errors/ # AppError subclasses
|
||||
│ ├── middleware/# Express middlewares
|
||||
│ ├── prisma/ # Prisma client singleton
|
||||
│ ├── routes/ # Express routers (xxx.routes.ts)
|
||||
│ ├── schemas/ # Zod validation schemas
|
||||
│ └── services/ # Business logic classes (xxx.service.ts)
|
||||
│ ├── services/ # Business logic classes (xxx.service.ts)
|
||||
│ ├── types/ # TypeScript interfaces
|
||||
│ └── utils/ # Utility functions
|
||||
├── ios/ # Native iOS app (Swift/Xcode)
|
||||
├── timetracker-chart/ # Helm chart for Kubernetes deployment
|
||||
├── helm/ # Helm chart for Kubernetes deployment
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## AI Agent Workflow
|
||||
|
||||
### Before Making Changes
|
||||
1. Read this file completely
|
||||
2. Read `project.md` for feature requirements
|
||||
3. Read `README.md` for setup instructions
|
||||
4. Understand the specific task or feature request
|
||||
|
||||
### During Development
|
||||
1. Follow all code conventions in this document
|
||||
2. Write clean, maintainable code
|
||||
3. Add inline comments only when necessary for clarity
|
||||
4. Run linting before completing: `npm run lint`
|
||||
|
||||
### After Making Changes
|
||||
**Always update documentation.** See [Documentation Maintenance](#documentation-maintenance).
|
||||
|
||||
## Feature Development Workflow
|
||||
|
||||
**For new features, AI agents MUST follow this process before writing any code.**
|
||||
|
||||
### Phase 1: Requirements Discovery
|
||||
1. Ask clarifying questions about the feature request
|
||||
2. Identify edge cases, constraints, and acceptance criteria
|
||||
3. Confirm understanding with the programmer
|
||||
4. Iterate until requirements are clear
|
||||
|
||||
### Phase 2: Feature Plan
|
||||
1. Create `docs/features/{feature-name}.md` with the feature plan
|
||||
2. Include: overview, requirements, technical approach, file changes, edge cases, testing
|
||||
3. Present plan for review
|
||||
4. Iterate until approved by the programmer
|
||||
|
||||
### Phase 3: Implementation
|
||||
1. Use the approved plan as the single source of truth
|
||||
2. Implement step by step following the plan
|
||||
3. Update the plan if implementation differs
|
||||
4. Update documentation after completion
|
||||
|
||||
**See the `feature-planning` skill for detailed workflow and templates.**
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
**Every code change requires a documentation review.** When you modify the codebase, check whether documentation needs updating.
|
||||
|
||||
### Documentation Files and Their Purposes
|
||||
|
||||
| File | Purpose | Update When |
|
||||
|------|---------|-------------|
|
||||
| `AGENTS.md` | Code conventions, commands, architecture patterns | Changing conventions, adding new patterns, modifying architecture |
|
||||
| `README.md` | Setup instructions, API reference, features list | Adding endpoints, changing environment variables, adding features |
|
||||
| `project.md` | Requirements, data model, functional specifications | Modifying business logic, adding entities, changing validation rules |
|
||||
|
||||
### Update Rules
|
||||
|
||||
#### Update `AGENTS.md` When:
|
||||
- Adding a new coding pattern or convention
|
||||
- Changing the project structure (new directories, reorganization)
|
||||
- Adding or modifying build/lint/test commands
|
||||
- Introducing a new architectural pattern
|
||||
- Changing state management or error handling approaches
|
||||
|
||||
#### Update `README.md` When:
|
||||
- Adding, removing, or modifying API endpoints
|
||||
- Changing environment variables or configuration
|
||||
- Adding new features visible to users
|
||||
- Modifying setup or installation steps
|
||||
- Changing the technology stack
|
||||
|
||||
#### Update `project.md` When:
|
||||
- Adding or modifying business requirements
|
||||
- Changing the data model or relationships
|
||||
- Adding new validation rules
|
||||
- Modifying functional specifications
|
||||
- Updating security or non-functional requirements
|
||||
|
||||
### Documentation Format Rules
|
||||
- Use Markdown formatting
|
||||
- Keep entries concise and actionable
|
||||
- Match the existing tone and style
|
||||
- Use code blocks for commands and code examples
|
||||
- Maintain alphabetical or logical ordering in lists
|
||||
|
||||
## Build, Lint, and Dev Commands
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
|
||||
39
DOCS.md
Normal file
39
DOCS.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Documentation Guide
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Purpose | When to Update |
|
||||
|------|---------|----------------|
|
||||
| `AGENTS.md` | Code conventions, commands, architecture, AI agent workflow | Adding patterns, changing conventions, modifying structure, updating agent workflow |
|
||||
| `README.md` | Setup instructions, API reference, features list | New endpoints, config changes, new features, technology stack changes |
|
||||
| `project.md` | Requirements, data model, functional specifications | Business logic changes, new entities, validation rules, UI requirements |
|
||||
| `DOCS.md` | Documentation standards and index | Documentation process changes, new documentation files |
|
||||
| `docs/features/*.md` | Feature implementation plans | Created during feature development, updated if implementation differs |
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
| Skill | Purpose | When to Use |
|
||||
|-------|---------|-------------|
|
||||
| `feature-planning` | Structured workflow for new features | When implementing new features or significant functionality changes |
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
- Use Markdown formatting
|
||||
- Keep entries concise and actionable
|
||||
- Use code blocks for commands and examples
|
||||
- Match existing tone and style
|
||||
- Maintain logical ordering in lists
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
### AI Agents Must Update Documentation When:
|
||||
1. Adding new code patterns or conventions
|
||||
2. Modifying API endpoints or configuration
|
||||
3. Changing business logic or data models
|
||||
4. Adding new features or entities
|
||||
|
||||
### Review Checklist
|
||||
- [ ] Documentation reflects code changes
|
||||
- [ ] Examples are accurate and tested
|
||||
- [ ] Formatting is consistent
|
||||
- [ ] No outdated information remains
|
||||
25
README.md
25
README.md
@@ -10,6 +10,10 @@ A multi-user web application for tracking time spent working on projects. Users
|
||||
- **Time Tracking** - Start/stop timer with live elapsed time display
|
||||
- **Manual Entry** - Add time entries manually for past work
|
||||
- **Validation** - Overlap prevention and end-time validation
|
||||
- **Statistics** - View aggregated time tracking data by project and client
|
||||
- **Client Targets** - Set hourly targets per client with weekly/monthly periods
|
||||
- **API Keys** - Generate API keys for external tools and AI agents
|
||||
- **MCP Integration** - Model Context Protocol endpoint for AI agent access
|
||||
- **Responsive UI** - Works on desktop and mobile
|
||||
|
||||
## Architecture
|
||||
@@ -125,6 +129,27 @@ APP_URL="http://localhost:5173"
|
||||
- `POST /api/timer/start` - Start timer
|
||||
- `PUT /api/timer` - Update timer (set project)
|
||||
- `POST /api/timer/stop` - Stop timer (creates entry)
|
||||
- `POST /api/timer/cancel` - Cancel timer without saving
|
||||
|
||||
### Client Targets
|
||||
|
||||
- `GET /api/client-targets` - List targets with balance
|
||||
- `POST /api/client-targets` - Create target
|
||||
- `PUT /api/client-targets/:id` - Update target
|
||||
- `DELETE /api/client-targets/:id` - Delete target
|
||||
- `POST /api/client-targets/:id/corrections` - Add correction
|
||||
- `DELETE /api/client-targets/:id/corrections/:correctionId` - Delete correction
|
||||
|
||||
### API Keys
|
||||
|
||||
- `GET /api/api-keys` - List API keys
|
||||
- `POST /api/api-keys` - Create API key
|
||||
- `DELETE /api/api-keys/:id` - Revoke API key
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
- `GET /mcp` - SSE stream for server-initiated messages
|
||||
- `POST /mcp` - JSON-RPC requests (tool invocations)
|
||||
|
||||
## Data Model
|
||||
|
||||
|
||||
648
backend/package-lock.json
generated
648
backend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "timetracker-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@quixo3/prisma-session-store": "^3.1.19",
|
||||
"cors": "^2.8.5",
|
||||
@@ -471,6 +472,367 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
|
||||
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.7.0",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -771,6 +1133,39 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -983,6 +1378,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -1193,6 +1602,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -1239,6 +1669,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
@@ -1292,6 +1740,28 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1456,6 +1926,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1494,6 +1973,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1503,6 +1991,18 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -1522,6 +2022,18 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -1808,6 +2320,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
@@ -1832,6 +2353,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
@@ -1852,6 +2382,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
@@ -1993,6 +2532,15 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -2003,6 +2551,55 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"is-promise": "^4.0.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"path-to-regexp": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router/node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -2092,6 +2689,27 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -2321,6 +2939,27 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -2335,6 +2974,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@quixo3/prisma-session-store": "^3.1.19",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"key_hash" VARCHAR(64) NOT NULL,
|
||||
"prefix" VARCHAR(16) NOT NULL,
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"user_id" VARCHAR(255) NOT NULL,
|
||||
|
||||
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -20,6 +20,7 @@ model User {
|
||||
timeEntries TimeEntry[]
|
||||
ongoingTimer OngoingTimer?
|
||||
clientTargets ClientTarget[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -151,3 +152,18 @@ model Session {
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255)
|
||||
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hex
|
||||
prefix String @db.VarChar(16) // first chars of raw key for display
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
|
||||
import timeEntryRoutes from "./routes/timeEntry.routes";
|
||||
import timerRoutes from "./routes/timer.routes";
|
||||
import clientTargetRoutes from "./routes/clientTarget.routes";
|
||||
import apiKeyRoutes from "./routes/apiKey.routes";
|
||||
import mcpRoutes from "./routes/mcp.routes";
|
||||
|
||||
async function main() {
|
||||
// Validate configuration
|
||||
@@ -70,6 +72,8 @@ async function main() {
|
||||
app.use("/time-entries", timeEntryRoutes);
|
||||
app.use("/timer", timerRoutes);
|
||||
app.use("/client-targets", clientTargetRoutes);
|
||||
app.use("/api-keys", apiKeyRoutes);
|
||||
app.use("/mcp", mcpRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../prisma/client';
|
||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||
import { verifyBackendJwt } from '../auth/jwt';
|
||||
import { ApiKeyService } from '../services/apiKey.service';
|
||||
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
export async function requireAuth(
|
||||
req: AuthenticatedRequest,
|
||||
@@ -17,11 +20,33 @@ export async function requireAuth(
|
||||
return next();
|
||||
}
|
||||
|
||||
// 2. Bearer JWT auth (iOS / native clients)
|
||||
// 2. Bearer token auth (JWT or API key)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
|
||||
|
||||
// 2a. API key — detected by the "sk_" prefix
|
||||
if (token.startsWith('sk_')) {
|
||||
try {
|
||||
const user = await apiKeyService.verify(token);
|
||||
if (!user) {
|
||||
console.warn(`${tag} -> API key verification failed: key not found`);
|
||||
res.status(401).json({ error: 'Unauthorized: invalid API key' });
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
console.log(`${tag} -> API key auth OK (user: ${req.user.id})`);
|
||||
return next();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`${tag} -> API key verification error: ${message}`);
|
||||
res.status(401).json({ error: `Unauthorized: ${message}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. JWT (iOS / native clients)
|
||||
try {
|
||||
req.user = verifyBackendJwt(token);
|
||||
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);
|
||||
|
||||
51
backend/src/routes/apiKey.routes.ts
Normal file
51
backend/src/routes/apiKey.routes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { validateBody, validateParams } from '../middleware/validation';
|
||||
import { ApiKeyService } from '../services/apiKey.service';
|
||||
import { CreateApiKeySchema, IdSchema } from '../schemas';
|
||||
import type { AuthenticatedRequest } from '../types';
|
||||
|
||||
const router = Router();
|
||||
const apiKeyService = new ApiKeyService();
|
||||
|
||||
// GET /api-keys - List user's API keys
|
||||
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const keys = await apiKeyService.list(req.user!.id);
|
||||
res.json(keys);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api-keys - Create a new API key
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
validateBody(CreateApiKeySchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
const created = await apiKeyService.create(req.user!.id, req.body.name);
|
||||
res.status(201).json(created);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api-keys/:id - Revoke an API key
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAuth,
|
||||
validateParams(IdSchema),
|
||||
async (req: AuthenticatedRequest, res, next) => {
|
||||
try {
|
||||
await apiKeyService.delete(req.params.id, req.user!.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
455
backend/src/routes/mcp.routes.ts
Normal file
455
backend/src/routes/mcp.routes.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||
import { ClientService } from '../services/client.service';
|
||||
import { ProjectService } from '../services/project.service';
|
||||
import { TimeEntryService } from '../services/timeEntry.service';
|
||||
import { TimerService } from '../services/timer.service';
|
||||
import { ClientTargetService } from '../services/clientTarget.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Service instances — shared, stateless
|
||||
const clientService = new ClientService();
|
||||
const projectService = new ProjectService();
|
||||
const timeEntryService = new TimeEntryService();
|
||||
const timerService = new TimerService();
|
||||
const clientTargetService = new ClientTargetService();
|
||||
|
||||
/**
|
||||
* Build and return a fresh stateless McpServer pre-populated with all tools
|
||||
* scoped to the given authenticated user.
|
||||
*/
|
||||
function buildMcpServer(user: AuthenticatedUser): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'timetracker',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Clients
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
'list_clients',
|
||||
{
|
||||
description: 'List all clients for the authenticated user.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const clients = await clientService.findAll(userId);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(clients, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_client',
|
||||
{
|
||||
description: 'Create a new client.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).max(255).describe('Client name'),
|
||||
description: z.string().max(1000).optional().describe('Optional description'),
|
||||
},
|
||||
},
|
||||
async ({ name, description }) => {
|
||||
const client = await clientService.create(userId, { name, description });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_client',
|
||||
{
|
||||
description: 'Update an existing client.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Client ID'),
|
||||
name: z.string().min(1).max(255).optional().describe('New name'),
|
||||
description: z.string().max(1000).optional().describe('New description'),
|
||||
},
|
||||
},
|
||||
async ({ id, name, description }) => {
|
||||
const client = await clientService.update(id, userId, { name, description });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_client',
|
||||
{
|
||||
description: 'Soft-delete a client (and its projects).',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Client ID'),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
await clientService.delete(id, userId);
|
||||
return { content: [{ type: 'text', text: `Client ${id} deleted.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Projects
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
'list_projects',
|
||||
{
|
||||
description: 'List all projects, optionally filtered by clientId.',
|
||||
inputSchema: {
|
||||
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||
},
|
||||
},
|
||||
async ({ clientId }) => {
|
||||
const projects = await projectService.findAll(userId, clientId);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_project',
|
||||
{
|
||||
description: 'Create a new project under a client.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).max(255).describe('Project name'),
|
||||
clientId: z.string().uuid().describe('Client ID the project belongs to'),
|
||||
description: z.string().max(1000).optional().describe('Optional description'),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().describe('Hex color code, e.g. #FF5733'),
|
||||
},
|
||||
},
|
||||
async ({ name, clientId, description, color }) => {
|
||||
const project = await projectService.create(userId, { name, clientId, description, color });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_project',
|
||||
{
|
||||
description: 'Update an existing project.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Project ID'),
|
||||
name: z.string().min(1).max(255).optional().describe('New name'),
|
||||
description: z.string().max(1000).optional().describe('New description'),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).nullable().optional().describe('Hex color or null to clear'),
|
||||
clientId: z.string().uuid().optional().describe('Move project to a different client'),
|
||||
},
|
||||
},
|
||||
async ({ id, name, description, color, clientId }) => {
|
||||
const project = await projectService.update(id, userId, { name, description, color, clientId });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_project',
|
||||
{
|
||||
description: 'Soft-delete a project.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Project ID'),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
await projectService.delete(id, userId);
|
||||
return { content: [{ type: 'text', text: `Project ${id} deleted.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Time entries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
'list_time_entries',
|
||||
{
|
||||
description: 'List time entries with optional filters. Returns paginated results.',
|
||||
inputSchema: {
|
||||
startDate: z.string().datetime().optional().describe('Filter entries starting at or after this ISO datetime'),
|
||||
endDate: z.string().datetime().optional().describe('Filter entries starting at or before this ISO datetime'),
|
||||
projectId: z.string().uuid().optional().describe('Filter by project ID'),
|
||||
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||
page: z.number().int().min(1).optional().default(1).describe('Page number (default 1)'),
|
||||
limit: z.number().int().min(1).max(100).optional().default(50).describe('Results per page (max 100, default 50)'),
|
||||
},
|
||||
},
|
||||
async (filters) => {
|
||||
const result = await timeEntryService.findAll(userId, filters);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_time_entry',
|
||||
{
|
||||
description: 'Create a manual time entry.',
|
||||
inputSchema: {
|
||||
projectId: z.string().uuid().describe('Project ID'),
|
||||
startTime: z.string().datetime().describe('Start time as ISO datetime string'),
|
||||
endTime: z.string().datetime().describe('End time as ISO datetime string'),
|
||||
breakMinutes: z.number().int().min(0).optional().describe('Break duration in minutes (default 0)'),
|
||||
description: z.string().max(1000).optional().describe('Optional description'),
|
||||
},
|
||||
},
|
||||
async ({ projectId, startTime, endTime, breakMinutes, description }) => {
|
||||
const entry = await timeEntryService.create(userId, { projectId, startTime, endTime, breakMinutes, description });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_time_entry',
|
||||
{
|
||||
description: 'Update an existing time entry.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Time entry ID'),
|
||||
startTime: z.string().datetime().optional().describe('New start time'),
|
||||
endTime: z.string().datetime().optional().describe('New end time'),
|
||||
breakMinutes: z.number().int().min(0).optional().describe('New break duration in minutes'),
|
||||
description: z.string().max(1000).optional().describe('New description'),
|
||||
projectId: z.string().uuid().optional().describe('Move to a different project'),
|
||||
},
|
||||
},
|
||||
async ({ id, ...data }) => {
|
||||
const entry = await timeEntryService.update(id, userId, data);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_time_entry',
|
||||
{
|
||||
description: 'Delete a time entry.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Time entry ID'),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
await timeEntryService.delete(id, userId);
|
||||
return { content: [{ type: 'text', text: `Time entry ${id} deleted.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_statistics',
|
||||
{
|
||||
description: 'Get aggregated time-tracking statistics, grouped by project and client.',
|
||||
inputSchema: {
|
||||
startDate: z.string().datetime().optional().describe('Filter from this ISO datetime'),
|
||||
endDate: z.string().datetime().optional().describe('Filter until this ISO datetime'),
|
||||
projectId: z.string().uuid().optional().describe('Filter by project ID'),
|
||||
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||
},
|
||||
},
|
||||
async (filters) => {
|
||||
const stats = await timeEntryService.getStatistics(userId, filters);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Timer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
'get_timer',
|
||||
{
|
||||
description: 'Get the current running timer, or null if none is active.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const timer = await timerService.getOngoingTimer(userId);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'start_timer',
|
||||
{
|
||||
description: 'Start a new timer. Fails if a timer is already running.',
|
||||
inputSchema: {
|
||||
projectId: z.string().uuid().optional().describe('Assign the timer to a project (can be set later)'),
|
||||
},
|
||||
},
|
||||
async ({ projectId }) => {
|
||||
const timer = await timerService.start(userId, { projectId });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'stop_timer',
|
||||
{
|
||||
description: 'Stop the running timer and save it as a time entry. A project must be assigned.',
|
||||
inputSchema: {
|
||||
projectId: z.string().uuid().optional().describe('Assign/override the project before stopping'),
|
||||
},
|
||||
},
|
||||
async ({ projectId }) => {
|
||||
const entry = await timerService.stop(userId, { projectId });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'cancel_timer',
|
||||
{
|
||||
description: 'Cancel the running timer without saving a time entry.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
await timerService.cancel(userId);
|
||||
return { content: [{ type: 'text', text: 'Timer cancelled.' }] };
|
||||
}
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client targets
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
server.registerTool(
|
||||
'list_client_targets',
|
||||
{
|
||||
description: 'List all client hour targets with computed balance for each period.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const targets = await clientTargetService.findAll(userId);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(targets, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_client_target',
|
||||
{
|
||||
description: 'Create a new hour target for a client.',
|
||||
inputSchema: {
|
||||
clientId: z.string().uuid().describe('Client ID'),
|
||||
targetHours: z.number().positive().max(168).describe('Target hours per period'),
|
||||
periodType: z.enum(['weekly', 'monthly']).describe('Period type: weekly or monthly'),
|
||||
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).describe('Working days, e.g. ["MON","TUE","WED","THU","FRI"]'),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date in YYYY-MM-DD format'),
|
||||
},
|
||||
},
|
||||
async (data) => {
|
||||
const target = await clientTargetService.create(userId, data);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_client_target',
|
||||
{
|
||||
description: 'Update an existing client hour target.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Target ID'),
|
||||
targetHours: z.number().positive().max(168).optional().describe('New target hours per period'),
|
||||
periodType: z.enum(['weekly', 'monthly']).optional().describe('New period type'),
|
||||
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).optional().describe('New working days'),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('New start date in YYYY-MM-DD'),
|
||||
},
|
||||
},
|
||||
async ({ id, ...data }) => {
|
||||
const target = await clientTargetService.update(id, userId, data);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_client_target',
|
||||
{
|
||||
description: 'Delete a client hour target.',
|
||||
inputSchema: {
|
||||
id: z.string().uuid().describe('Target ID'),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
await clientTargetService.delete(id, userId);
|
||||
return { content: [{ type: 'text', text: `Client target ${id} deleted.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'add_target_correction',
|
||||
{
|
||||
description: 'Add a manual hour correction to a client target (e.g. for holidays or overtime carry-over).',
|
||||
inputSchema: {
|
||||
targetId: z.string().uuid().describe('Client target ID'),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date of correction in YYYY-MM-DD format'),
|
||||
hours: z.number().min(-1000).max(1000).describe('Hours to add (negative to deduct)'),
|
||||
description: z.string().max(255).optional().describe('Optional reason for the correction'),
|
||||
},
|
||||
},
|
||||
async ({ targetId, date, hours, description }) => {
|
||||
const correction = await clientTargetService.addCorrection(targetId, userId, { date, hours, description });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(correction, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_target_correction',
|
||||
{
|
||||
description: 'Delete a manual hour correction from a client target.',
|
||||
inputSchema: {
|
||||
targetId: z.string().uuid().describe('Client target ID'),
|
||||
correctionId: z.string().uuid().describe('Correction ID'),
|
||||
},
|
||||
},
|
||||
async ({ targetId, correctionId }) => {
|
||||
await clientTargetService.deleteCorrection(targetId, correctionId, userId);
|
||||
return { content: [{ type: 'text', text: `Correction ${correctionId} deleted.` }] };
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler — one fresh McpServer + transport per request (stateless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleMcpRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const user = req.user!;
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const mcpServer = buildMcpServer(user);
|
||||
|
||||
// Ensure the server is cleaned up when the response finishes
|
||||
res.on('close', () => {
|
||||
transport.close().catch(() => undefined);
|
||||
mcpServer.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
await mcpServer.connect(transport);
|
||||
await transport.handleRequest(req as unknown as Request, res, req.body);
|
||||
}
|
||||
|
||||
// GET /mcp — SSE stream for server-initiated messages
|
||||
router.get('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
handleMcpRequest(req, res).catch((err) => {
|
||||
console.error('[MCP] GET error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// POST /mcp — JSON-RPC requests
|
||||
router.post('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
handleMcpRequest(req, res).catch((err) => {
|
||||
console.error('[MCP] POST error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /mcp — session termination (stateless: always 405)
|
||||
router.delete('/', (_req, res: Response) => {
|
||||
res.status(405).json({ error: 'Sessions are not supported (stateless mode)' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -95,3 +95,7 @@ export const CreateCorrectionSchema = z.object({
|
||||
hours: z.number().min(-1000).max(1000),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const CreateApiKeySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
99
backend/src/services/apiKey.service.ts
Normal file
99
backend/src/services/apiKey.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { prisma } from '../prisma/client';
|
||||
import { NotFoundError } from '../errors/AppError';
|
||||
import type { AuthenticatedUser } from '../types';
|
||||
|
||||
const KEY_PREFIX_LENGTH = 12; // chars shown in UI
|
||||
|
||||
function hashKey(rawKey: string): string {
|
||||
return createHash('sha256').update(rawKey).digest('hex');
|
||||
}
|
||||
|
||||
function generateRawKey(): string {
|
||||
return `sk_${randomUUID().replace(/-/g, '')}`;
|
||||
}
|
||||
|
||||
export interface ApiKeyListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
rawKey: string; // returned once only
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class ApiKeyService {
|
||||
async create(userId: string, name: string): Promise<CreatedApiKey> {
|
||||
const rawKey = generateRawKey();
|
||||
const keyHash = hashKey(rawKey);
|
||||
const prefix = rawKey.slice(0, KEY_PREFIX_LENGTH);
|
||||
|
||||
const record = await prisma.apiKey.create({
|
||||
data: { userId, name, keyHash, prefix },
|
||||
});
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
prefix: record.prefix,
|
||||
rawKey,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(userId: string): Promise<ApiKeyListItem[]> {
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
prefix: k.prefix,
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const existing = await prisma.apiKey.findFirst({ where: { id, userId } });
|
||||
if (!existing) {
|
||||
throw new NotFoundError('API key not found');
|
||||
}
|
||||
await prisma.apiKey.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a raw API key string. Returns the owning user or null.
|
||||
* Updates lastUsedAt on success.
|
||||
*/
|
||||
async verify(rawKey: string): Promise<AuthenticatedUser | null> {
|
||||
const keyHash = hashKey(rawKey);
|
||||
const record = await prisma.apiKey.findUnique({
|
||||
where: { keyHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
// Update lastUsedAt in the background — don't await to keep latency low
|
||||
prisma.apiKey
|
||||
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return {
|
||||
id: record.user.id,
|
||||
username: record.user.username,
|
||||
fullName: record.user.fullName,
|
||||
email: record.user.email,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export interface CreateProjectInput {
|
||||
export interface UpdateProjectInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
color?: string | null;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
# Client Targets v2 — Feature Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
|
||||
|
||||
- Targets can be set on a **weekly or monthly** period.
|
||||
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
|
||||
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
|
||||
- The **start date** can be any calendar day (no longer restricted to Mondays).
|
||||
- Manual **balance corrections** are preserved and continue to work as before.
|
||||
|
||||
---
|
||||
|
||||
## 1. Target Configuration
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|---|---|---|
|
||||
| `periodType` | `WEEKLY \| MONTHLY` | Required |
|
||||
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
|
||||
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
|
||||
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
|
||||
| `clientId` | UUID | Must belong to the authenticated user |
|
||||
|
||||
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Period Definitions
|
||||
|
||||
| `periodType` | Period start | Period end |
|
||||
|---|---|---|
|
||||
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
|
||||
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Balance Calculation — Overview
|
||||
|
||||
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
|
||||
|
||||
Each period is classified as either **completed** or **ongoing**.
|
||||
|
||||
```
|
||||
total_balance_seconds = SUM( balance_seconds ) over all periods
|
||||
```
|
||||
|
||||
Positive = overtime. Negative = undertime.
|
||||
|
||||
---
|
||||
|
||||
## 4. Completed Period Balance
|
||||
|
||||
A period is **completed** when its end date is strictly before today.
|
||||
|
||||
```
|
||||
balance = tracked_hours + correction_hours - period_target_hours
|
||||
```
|
||||
|
||||
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
|
||||
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
|
||||
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
|
||||
|
||||
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
|
||||
|
||||
---
|
||||
|
||||
## 5. First Period Pro-ration
|
||||
|
||||
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
|
||||
|
||||
### Monthly
|
||||
|
||||
```
|
||||
full_period_days = total calendar days in that month
|
||||
remaining_days = (last day of month) − startDate + 1 // inclusive
|
||||
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
|
||||
```
|
||||
|
||||
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
|
||||
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
|
||||
|
||||
### Weekly
|
||||
|
||||
```
|
||||
full_period_days = 7
|
||||
remaining_days = Sunday of that calendar week − startDate + 1 // inclusive
|
||||
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
|
||||
```
|
||||
|
||||
**Example:** startDate = Wednesday, target = 40 h/week.
|
||||
`remaining_days = 5 (Wed–Sun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
|
||||
|
||||
All periods after the first use the full `weeklyOrMonthlyHours`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ongoing Period Balance (Current Period)
|
||||
|
||||
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
|
||||
|
||||
### Step 1 — Period target hours
|
||||
|
||||
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
|
||||
|
||||
### Step 2 — Daily rate
|
||||
|
||||
```
|
||||
working_days_in_period = COUNT of days in [period_start, period_end]
|
||||
that match the working day pattern
|
||||
daily_rate_hours = period_target_hours / working_days_in_period
|
||||
```
|
||||
|
||||
The rate is fixed at the start of the period and does not change as time passes.
|
||||
|
||||
### Step 3 — Elapsed working days
|
||||
|
||||
```
|
||||
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
|
||||
that match the working day pattern
|
||||
```
|
||||
|
||||
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
|
||||
- If today does not match the working day pattern, it is not counted.
|
||||
|
||||
### Step 4 — Expected hours so far
|
||||
|
||||
```
|
||||
expected_hours = elapsed_working_days × daily_rate_hours
|
||||
```
|
||||
|
||||
### Step 5 — Balance
|
||||
|
||||
```
|
||||
tracked_hours = SUM of time entries for this client in [period_start, today]
|
||||
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
|
||||
balance = tracked_hours + correction_hours − expected_hours
|
||||
```
|
||||
|
||||
### Worked example
|
||||
|
||||
> Target: 40 h/month. Working days: Mon + Wed.
|
||||
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
|
||||
> `daily_rate_hours = 40 / 8 = 5 h`.
|
||||
> 3 working days have elapsed → `expected_hours = 15 h`.
|
||||
> Tracked so far: 13 h, no corrections.
|
||||
> `balance = 13 − 15 = −2 h` (2 hours behind).
|
||||
|
||||
---
|
||||
|
||||
## 7. Manual Balance Corrections
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|---|---|---|
|
||||
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
|
||||
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
|
||||
| `description` | string | Optional, max 255 chars |
|
||||
|
||||
- The system automatically assigns a correction to the period that contains its `date`.
|
||||
- Corrections in **completed periods** are included in the completed period formula (§4).
|
||||
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
|
||||
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
|
||||
- A correction whose `date` is before `startDate` is rejected with a validation error.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
|
||||
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
|
||||
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
|
||||
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
|
||||
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
|
||||
| Today is not a working day | `elapsed_working_days` does not include today |
|
||||
| Correction date before `startDate` | Rejected with a validation error |
|
||||
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
|
||||
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
|
||||
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
|
||||
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Model Changes
|
||||
|
||||
### `ClientTarget` table — additions / changes
|
||||
|
||||
| Column | Change | Notes |
|
||||
|---|---|---|
|
||||
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
|
||||
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
|
||||
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
|
||||
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
|
||||
|
||||
### `BalanceCorrection` table — no structural changes
|
||||
|
||||
Date-to-period assignment is computed at query time, not stored.
|
||||
|
||||
---
|
||||
|
||||
## 10. API Changes
|
||||
|
||||
### `ClientTargetWithBalance` response shape
|
||||
|
||||
```typescript
|
||||
interface ClientTargetWithBalance {
|
||||
id: string
|
||||
clientId: string
|
||||
clientName: string
|
||||
userId: string
|
||||
periodType: "weekly" | "monthly"
|
||||
targetHours: number // renamed from weeklyHours
|
||||
workingDays: string[] // e.g. ["MON", "WED"]
|
||||
startDate: string // YYYY-MM-DD
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
corrections: BalanceCorrection[]
|
||||
totalBalanceSeconds: number // running total across all periods
|
||||
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
|
||||
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
|
||||
periods: PeriodBalance[] // replaces weeks[]
|
||||
}
|
||||
|
||||
interface PeriodBalance {
|
||||
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
|
||||
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
|
||||
targetHours: number // pro-rated for first period
|
||||
trackedSeconds: number
|
||||
correctionHours: number
|
||||
balanceSeconds: number
|
||||
isOngoing: boolean
|
||||
// only present when isOngoing = true
|
||||
dailyRateHours?: number
|
||||
workingDaysInPeriod?: number
|
||||
elapsedWorkingDays?: number
|
||||
expectedHours?: number
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoint changes
|
||||
|
||||
| Method | Path | Change |
|
||||
|---|---|---|
|
||||
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
|
||||
| `PUT /client-targets/:id` | Update | Accepts same new fields |
|
||||
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
|
||||
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
|
||||
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
|
||||
|
||||
### Zod schema changes
|
||||
|
||||
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
|
||||
- Add `periodType: z.enum(["weekly", "monthly"])`
|
||||
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
|
||||
- Rename `weeklyHours` → `targetHours`
|
||||
- Remove Monday-only regex constraint from `startDate`
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend Changes
|
||||
|
||||
### Types (`frontend/src/types/index.ts`)
|
||||
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks` → `periods: PeriodBalance[]`; replace `currentWeek*` → `currentPeriod*`
|
||||
- Add `PeriodBalance` interface
|
||||
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
|
||||
|
||||
### Hook (`frontend/src/hooks/useClientTargets.ts`)
|
||||
- No structural changes; mutations pass through new fields
|
||||
|
||||
### API client (`frontend/src/api/clientTargets.ts`)
|
||||
- No structural changes; payload shapes updated
|
||||
|
||||
### `ClientsPage` — `ClientTargetPanel`
|
||||
- Working day selector (checkboxes: Mon–Sun, at least one required)
|
||||
- Period type selector (Weekly / Monthly)
|
||||
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
|
||||
- Start date picker: free date input (no week-picker)
|
||||
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
|
||||
- Expanded period list replaces the expanded week list
|
||||
|
||||
### `DashboardPage`
|
||||
- "Weekly Targets" widget renamed to "Targets"
|
||||
- "This week" label becomes "This week" / "This month" dynamically
|
||||
- `currentWeek*` fields replaced with `currentPeriod*`
|
||||
0
docs/features/.gitkeep
Normal file
0
docs/features/.gitkeep
Normal file
131
docs/features/timer-breaks.md
Normal file
131
docs/features/timer-breaks.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Feature: Timer Breaks (Pause During Work)
|
||||
|
||||
## Overview
|
||||
Allow users to take breaks while a timer is running. When on break, elapsed time is frozen and break time is tracked. When resumed, break time accumulates and is subtracted from the displayed work time.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Timer States
|
||||
1. **Running** — normal state, elapsed time ticking
|
||||
2. **On Break** — elapsed time frozen, break time ticking, Stop/Cancel buttons disabled
|
||||
3. **Stopped** — no timer active
|
||||
|
||||
### UI Changes (TimerWidget)
|
||||
- Add a **"Break"** button (amber, `Pause` icon) next to Stop when timer is running
|
||||
- When on break:
|
||||
- Change pulsing dot color from red to amber
|
||||
- Elapsed time frozen at net work time
|
||||
- Show break time below: `Break: Xm XXs` (live-ticking)
|
||||
- Replace "Break" button with **"Resume"** button (green, `Play` icon)
|
||||
- **Disable** Stop and Cancel buttons (tooltip: "Resume before stopping")
|
||||
|
||||
### Duration Calculations
|
||||
- **Work time (displayed):** `now - startTime - totalBreakSeconds`
|
||||
- Where `totalBreakSeconds = (breakMinutes * 60) + (now - breakStart if on break)`
|
||||
- When on break: frozen at `(breakStart - startTime - breakMinutes * 60)`
|
||||
- **Break time (displayed):** `breakMinutes * 60 + (now - breakStart if on break)`
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Database Schema (`backend/prisma/schema.prisma`)
|
||||
Add two fields to `OngoingTimer`:
|
||||
```prisma
|
||||
model OngoingTimer {
|
||||
// ... existing fields ...
|
||||
breakMinutes Int @default(0) @map("break_minutes")
|
||||
breakStart DateTime? @map("break_start") @db.Timestamptz()
|
||||
}
|
||||
```
|
||||
Run: `npx prisma migrate dev --name add_timer_break_fields`
|
||||
|
||||
### 2. Backend Service (`backend/src/services/timer.service.ts`)
|
||||
|
||||
**New method `startBreak(userId)`:**
|
||||
- Get ongoing timer, throw `NotFoundError` if none
|
||||
- Check `timer.breakStart` is null (not already on break), throw `BadRequestError` if on break
|
||||
- Update: `breakStart = new Date()`
|
||||
- Return updated timer
|
||||
|
||||
**New method `endBreak(userId)`:**
|
||||
- Get ongoing timer, throw `NotFoundError` if none
|
||||
- Check `timer.breakStart` is not null, throw `BadRequestError` if not on break
|
||||
- Calculate additional break minutes: `Math.floor((now - breakStart) / 60000)`
|
||||
- Update: `breakMinutes += additionalMinutes`, `breakStart = null`
|
||||
- Return updated timer
|
||||
|
||||
**Modify `stop(userId)`:**
|
||||
- Before creating time entry, check `timer.breakStart` is null — throw `BadRequestError("Cannot stop timer while on break")` if break is active
|
||||
- When creating `TimeEntry`, set `breakMinutes: timer.breakMinutes`
|
||||
|
||||
**Modify `cancel(userId)`:**
|
||||
- Check `timer.breakStart` is null — throw `BadRequestError("Cannot cancel timer while on break")` if break is active
|
||||
|
||||
### 3. Backend Routes (`backend/src/routes/timer.routes.ts`)
|
||||
Add two new routes (both require auth, no body validation):
|
||||
```
|
||||
POST /api/timer/break → timerService.startBreak(userId)
|
||||
POST /api/timer/resume → timerService.endBreak(userId)
|
||||
```
|
||||
|
||||
### 4. MCP Tools (`backend/src/routes/mcp.routes.ts`)
|
||||
Add two MCP tools: `pause_timer` and `resume_timer`.
|
||||
|
||||
### 5. Frontend Types (`frontend/src/types/index.ts`)
|
||||
Update `OngoingTimer` interface:
|
||||
```typescript
|
||||
export interface OngoingTimer {
|
||||
// ... existing fields ...
|
||||
breakMinutes: number;
|
||||
breakStart: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Frontend API (`frontend/src/api/timer.ts`)
|
||||
Add two methods:
|
||||
```typescript
|
||||
startBreak: async (): Promise<OngoingTimer> => { ... }
|
||||
endBreak: async (): Promise<OngoingTimer> => { ... }
|
||||
```
|
||||
|
||||
### 7. Frontend TimerContext (`frontend/src/contexts/TimerContext.tsx`)
|
||||
- Add `breakSeconds` state (live-updating, similar to `elapsedSeconds`)
|
||||
- Expose `isOnBreak` derived boolean (`ongoingTimer?.breakStart !== null`)
|
||||
- Update elapsed time calculation:
|
||||
- Running: `(now - startTime) - (breakMinutes * 60) - (now - breakStart if on break)`
|
||||
- On break: `(breakStart - startTime) - (breakMinutes * 60)` (frozen)
|
||||
- Break seconds: `(breakMinutes * 60) + (now - breakStart if on break)`
|
||||
- Add `startBreak()` and `endBreak()` callbacks
|
||||
- Expose `breakSeconds` and `isOnBreak` in context value
|
||||
|
||||
### 8. Frontend TimerWidget (`frontend/src/components/TimerWidget.tsx`)
|
||||
- Import `Pause` icon from lucide-react
|
||||
- Add Break/Resume button between project selector and Stop button
|
||||
- Show break time display when `breakSeconds > 0` or `isOnBreak`
|
||||
- Change dot color to amber when on break
|
||||
- Disable Stop/Cancel when on break with tooltip
|
||||
|
||||
## Files to Modify (in order)
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `backend/prisma/schema.prisma` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
|
||||
| 2 | `backend/src/services/timer.service.ts` | Add `startBreak()`, `endBreak()`, modify `stop()` and `cancel()` |
|
||||
| 3 | `backend/src/routes/timer.routes.ts` | Add `/break` and `/resume` routes |
|
||||
| 4 | `backend/src/routes/mcp.routes.ts` | Add `pause_timer` and `resume_timer` MCP tools |
|
||||
| 5 | `frontend/src/types/index.ts` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
|
||||
| 6 | `frontend/src/api/timer.ts` | Add `startBreak()`, `endBreak()` API methods |
|
||||
| 7 | `frontend/src/contexts/TimerContext.tsx` | Add break state, `breakSeconds`, `isOnBreak`, break methods |
|
||||
| 8 | `frontend/src/components/TimerWidget.tsx` | Add break UI (button, display, disabled states) |
|
||||
|
||||
## Edge Cases
|
||||
- Break start must be after timer start (always true since break is clicked after start)
|
||||
- Break duration naturally cannot exceed work duration (breakStart > startTime)
|
||||
- On stop: reject if break is active (user must resume first)
|
||||
- On cancel: reject if break is active (user must resume first)
|
||||
- Break minutes accumulate across multiple break/resume cycles
|
||||
- Timer refetch (every 60s) will sync break state from server
|
||||
|
||||
## Verification
|
||||
- Run `npm run lint` in both `frontend/` and `backend/`
|
||||
- Run `npm run build` in both `frontend/` and `backend/`
|
||||
- Manual testing: start timer → break → verify elapsed frozen, break ticking → resume → verify break added to total → stop → verify time entry has correct breakMinutes
|
||||
@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
|
||||
import { ClientsPage } from "./pages/ClientsPage";
|
||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||
import { StatisticsPage } from "./pages/StatisticsPage";
|
||||
import { ApiKeysPage } from "./pages/ApiKeysPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -33,6 +34,7 @@ function App() {
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="statistics" element={<StatisticsPage />} />
|
||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
18
frontend/src/api/apiKeys.ts
Normal file
18
frontend/src/api/apiKeys.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import apiClient from './client';
|
||||
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
|
||||
|
||||
export const apiKeysApi = {
|
||||
getAll: async (): Promise<ApiKey[]> => {
|
||||
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
||||
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api-keys/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
@@ -40,6 +41,7 @@ export function Navbar() {
|
||||
const managementItems = [
|
||||
{ to: "/clients", label: "Clients", icon: Briefcase },
|
||||
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
||||
{ to: "/api-keys", label: "API Keys", icon: Key },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
34
frontend/src/hooks/useApiKeys.ts
Normal file
34
frontend/src/hooks/useApiKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiKeysApi } from '@/api/apiKeys';
|
||||
import type { CreateApiKeyInput } from '@/types';
|
||||
|
||||
export function useApiKeys() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: apiKeys, isLoading, error } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: apiKeysApi.getAll,
|
||||
});
|
||||
|
||||
const createApiKey = useMutation({
|
||||
mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteApiKey = useMutation({
|
||||
mutationFn: (id: string) => apiKeysApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
isLoading,
|
||||
error,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
};
|
||||
}
|
||||
243
frontend/src/pages/ApiKeysPage.tsx
Normal file
243
frontend/src/pages/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react";
|
||||
import { useApiKeys } from "@/hooks/useApiKeys";
|
||||
import type { CreatedApiKey } from "@/types";
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return "Never";
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newKeyName.trim()) return;
|
||||
setCreateError(null);
|
||||
try {
|
||||
const key = await createApiKey.mutateAsync({ name: newKeyName.trim() });
|
||||
setCreatedKey(key);
|
||||
setNewKeyName("");
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyKey() {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.rawKey);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
}
|
||||
|
||||
function handleCloseCreateModal() {
|
||||
setShowCreateModal(false);
|
||||
setCreatedKey(null);
|
||||
setNewKeyName("");
|
||||
setCreateError(null);
|
||||
setCopiedKey(false);
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
try {
|
||||
await deleteApiKey.mutateAsync(id);
|
||||
setRevokeConfirmId(null);
|
||||
} catch (_err) {
|
||||
// error rendered below the table row via deleteApiKey.error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-6 w-6 text-gray-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint.
|
||||
The raw key is only shown once at creation time — store it securely.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error instanceof Error ? error.message : "Failed to load API keys"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteApiKey.isError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{deleteApiKey.error instanceof Error
|
||||
? deleteApiKey.error.message
|
||||
: "Failed to revoke API key"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
|
||||
) : !apiKeys || apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
|
||||
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
|
||||
{key.prefix}…
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{revokeConfirmId === key.id ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-xs text-red-600">Revoke?</span>
|
||||
<button
|
||||
onClick={() => handleRevoke(key.id)}
|
||||
disabled={deleteApiKey.isPending}
|
||||
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(null)}
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(key.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Revoke</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{createdKey ? (
|
||||
/* One-time key reveal */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-800">
|
||||
Copy this key now. <strong>It will not be shown again.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
|
||||
{createdKey.rawKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedKey ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Name input form */
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Key name
|
||||
</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="e.g. My Claude Agent"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-red-600 text-sm">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleCloseCreateModal}
|
||||
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{createdKey ? "Done" : "Cancel"}
|
||||
</button>
|
||||
{!createdKey && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newKeyName.trim() || createApiKey.isPending}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createApiKey.isPending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,3 +210,19 @@ export interface CreateCorrectionInput {
|
||||
hours: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
rawKey: string; // returned only on creation
|
||||
}
|
||||
|
||||
export interface CreateApiKeyInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
98
project.md
98
project.md
@@ -40,16 +40,22 @@ A multi-user web application for tracking time spent working on projects. Users
|
||||
| **Project** | A project belonging to a client | User, belongs to one Client |
|
||||
| **TimeEntry** | A completed time tracking record | User (explicit), belongs to one Project |
|
||||
| **OngoingTimer** | An active timer while tracking is in progress | User (explicit), belongs to one Project (optional) |
|
||||
| **ClientTarget** | Hourly target for a client per period | User, belongs to one Client |
|
||||
| **BalanceCorrection** | Manual hour adjustment for a target | Belongs to one ClientTarget |
|
||||
| **ApiKey** | API key for external tool access | User |
|
||||
|
||||
### Relationships
|
||||
|
||||
```
|
||||
User
|
||||
├── Client (one-to-many)
|
||||
│ └── Project (one-to-many)
|
||||
│ └── TimeEntry (one-to-many, explicit user reference)
|
||||
│ ├── Project (one-to-many)
|
||||
│ │ └── TimeEntry (one-to-many, explicit user reference)
|
||||
│ └── ClientTarget (one-to-one per client)
|
||||
│ └── BalanceCorrection (one-to-many)
|
||||
│
|
||||
└── OngoingTimer (zero-or-one, explicit user reference)
|
||||
├── OngoingTimer (zero-or-one, explicit user reference)
|
||||
└── ApiKey (one-to-many)
|
||||
```
|
||||
|
||||
**Important**: Both `TimeEntry` and `OngoingTimer` have explicit references to the user who created them. This is distinct from the project's ownership and is required for future extensibility (see Future Extensibility section).
|
||||
@@ -127,10 +133,72 @@ User
|
||||
- Start time
|
||||
- End time
|
||||
- Project
|
||||
- Optional fields:
|
||||
- Break minutes (deducted from total duration)
|
||||
- Description (notes about the work)
|
||||
- The entry is validated against overlap rules before saving
|
||||
|
||||
---
|
||||
|
||||
### 6. Statistics
|
||||
|
||||
- User can view aggregated time tracking statistics
|
||||
- Filters available:
|
||||
- Date range (start/end)
|
||||
- Client
|
||||
- Project
|
||||
- Statistics display:
|
||||
- Total working time
|
||||
- Entry count
|
||||
- Breakdown by project (with color indicators)
|
||||
- Breakdown by client
|
||||
|
||||
---
|
||||
|
||||
### 7. Client Targets
|
||||
|
||||
- User can set hourly targets per client
|
||||
- Target configuration:
|
||||
- Target hours per period
|
||||
- Period type (weekly or monthly)
|
||||
- Working days (e.g., MON-FRI)
|
||||
- Start date
|
||||
- Balance tracking:
|
||||
- Shows current balance vs target
|
||||
- Supports manual corrections (e.g., holidays, overtime carry-over)
|
||||
- Only one target per client allowed
|
||||
|
||||
---
|
||||
|
||||
### 8. API Keys
|
||||
|
||||
- User can generate API keys for external tool access
|
||||
- API key properties:
|
||||
- Name (for identification)
|
||||
- Prefix (first characters shown for identification)
|
||||
- Last used timestamp
|
||||
- Security:
|
||||
- Raw key shown only once at creation
|
||||
- Key is hashed (SHA-256) before storage
|
||||
- Keys can be revoked (deleted)
|
||||
|
||||
---
|
||||
|
||||
### 9. MCP Integration
|
||||
|
||||
- Model Context Protocol endpoint for AI agent access
|
||||
- Stateless operation (no session persistence)
|
||||
- Tools exposed:
|
||||
- Client CRUD operations
|
||||
- Project CRUD operations
|
||||
- Time entry CRUD operations
|
||||
- Timer start/stop/cancel
|
||||
- Client target management
|
||||
- Statistics queries
|
||||
- Authentication via API keys
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints (Suggested)
|
||||
|
||||
### Authentication
|
||||
@@ -165,8 +233,29 @@ User
|
||||
- `POST /api/timer/start` — Start timer (creates OngoingTimer)
|
||||
- `PUT /api/timer` — Update ongoing timer (e.g., set project)
|
||||
- `POST /api/timer/stop` — Stop timer (converts to TimeEntry)
|
||||
- `POST /api/timer/cancel` — Cancel timer without saving
|
||||
- `GET /api/timer` — Get current ongoing timer (if any)
|
||||
|
||||
### Client Targets
|
||||
|
||||
- `GET /api/client-targets` — List targets with computed balance
|
||||
- `POST /api/client-targets` — Create a target
|
||||
- `PUT /api/client-targets/{id}` — Update a target
|
||||
- `DELETE /api/client-targets/{id}` — Delete a target
|
||||
- `POST /api/client-targets/{id}/corrections` — Add a correction
|
||||
- `DELETE /api/client-targets/{id}/corrections/{correctionId}` — Delete a correction
|
||||
|
||||
### API Keys
|
||||
|
||||
- `GET /api/api-keys` — List user's API keys
|
||||
- `POST /api/api-keys` — Create a new API key
|
||||
- `DELETE /api/api-keys/{id}` — Revoke an API key
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
- `GET /mcp` — SSE stream for server-initiated messages
|
||||
- `POST /mcp` — JSON-RPC requests (tool invocations)
|
||||
|
||||
---
|
||||
|
||||
## UI Requirements
|
||||
@@ -183,6 +272,9 @@ User
|
||||
- **Dashboard**: Overview with active timer widget and recent entries
|
||||
- **Time Entries**: List/calendar view of all entries with filters (date range, client, project)
|
||||
- **Clients & Projects**: Management interface for clients and projects
|
||||
- **Statistics**: Aggregated time data with filters and breakdowns
|
||||
- **API Keys**: Create and manage API keys for external access
|
||||
- **Client Targets**: Set and monitor hourly targets per client
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="git.simon-franken.de"
|
||||
CHART_DIR="timetracker-chart"
|
||||
CHART_DIR="helm"
|
||||
|
||||
# Load .env file if present (values do not override existing env variables)
|
||||
if [[ -f ".env" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user