Compare commits
27 Commits
0d116c8c26
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
| ca521000bf | |||
|
|
a7ab55932f | ||
|
|
64211e6a49 | ||
| cd03d8751e | |||
| 1964f76f74 | |||
| 1f4e12298e | |||
|
|
1049410fee | ||
| c9bd0abf18 | |||
| 7ec76e3e8e | |||
| 784e71e187 | |||
| 7677fdd73d | |||
| 924b83eb4d | |||
| 91d13b19db | |||
| 2a5e6d4a22 | |||
| b7bd875462 | |||
| a58dfcfa4a | |||
| 7101f38bc8 | |||
| 3850e2db06 | |||
| 5b7b8e47cb | |||
| 7dd3873148 | |||
| 850f12e09d | |||
| 74999ce265 | |||
| 0c0fbf42ef | |||
|
|
ddb0926dba | ||
|
|
1b0f5866a1 | ||
|
|
159022ef38 | ||
|
|
1a7d13d5b9 |
648
backend/package-lock.json
generated
648
backend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "timetracker-backend",
|
"name": "timetracker-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@quixo3/prisma-session-store": "^3.1.19",
|
"@quixo3/prisma-session-store": "^3.1.19",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -471,6 +472,367 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
@@ -771,6 +1133,39 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -983,6 +1378,20 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1193,6 +1602,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
@@ -1239,6 +1669,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-session": {
|
||||||
"version": "1.19.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
@@ -1292,6 +1740,28 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -1456,6 +1926,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -1494,6 +1973,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1503,6 +1991,18 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -1522,6 +2022,18 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
@@ -1808,6 +2320,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@@ -1832,6 +2353,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
@@ -1852,6 +2382,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
@@ -1993,6 +2532,15 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2092,6 +2689,27 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -2321,6 +2939,27 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
@@ -2335,6 +2974,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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"
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@quixo3/prisma-session-store": "^3.1.19",
|
"@quixo3/prisma-session-store": "^3.1.19",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable: add deleted_at column to clients
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to projects
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to time_entries
|
||||||
|
ALTER TABLE "time_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: add deleted_at column to client_targets
|
||||||
|
ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to balance_corrections
|
||||||
|
ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PeriodType" AS ENUM ('WEEKLY', 'MONTHLY');
|
||||||
|
|
||||||
|
-- AlterTable: rename weekly_hours -> target_hours, add period_type, add working_days
|
||||||
|
ALTER TABLE "client_targets"
|
||||||
|
RENAME COLUMN "weekly_hours" TO "target_hours";
|
||||||
|
|
||||||
|
ALTER TABLE "client_targets"
|
||||||
|
ADD COLUMN "period_type" "PeriodType" NOT NULL DEFAULT 'WEEKLY',
|
||||||
|
ADD COLUMN "working_days" TEXT[] NOT NULL DEFAULT ARRAY['MON','TUE','WED','THU','FRI']::TEXT[];
|
||||||
@@ -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[]
|
timeEntries TimeEntry[]
|
||||||
ongoingTimer OngoingTimer?
|
ongoingTimer OngoingTimer?
|
||||||
clientTargets ClientTarget[]
|
clientTargets ClientTarget[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ model Client {
|
|||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -47,6 +49,7 @@ model Project {
|
|||||||
color String? @db.VarChar(7) // Hex color code
|
color String? @db.VarChar(7) // Hex color code
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -69,6 +72,7 @@ model TimeEntry {
|
|||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -96,12 +100,20 @@ model OngoingTimer {
|
|||||||
@@map("ongoing_timers")
|
@@map("ongoing_timers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PeriodType {
|
||||||
|
WEEKLY
|
||||||
|
MONTHLY
|
||||||
|
}
|
||||||
|
|
||||||
model ClientTarget {
|
model ClientTarget {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
weeklyHours Float @map("weekly_hours")
|
targetHours Float @map("target_hours")
|
||||||
startDate DateTime @map("start_date") @db.Date // Always a Monday
|
periodType PeriodType @default(WEEKLY) @map("period_type")
|
||||||
|
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
|
||||||
|
startDate DateTime @map("start_date") @db.Date
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -123,6 +135,7 @@ model BalanceCorrection {
|
|||||||
description String? @db.VarChar(255)
|
description String? @db.VarChar(255)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
clientTargetId String @map("client_target_id")
|
clientTargetId String @map("client_target_id")
|
||||||
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
||||||
@@ -139,3 +152,18 @@ model Session {
|
|||||||
|
|
||||||
@@map("sessions")
|
@@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 timeEntryRoutes from "./routes/timeEntry.routes";
|
||||||
import timerRoutes from "./routes/timer.routes";
|
import timerRoutes from "./routes/timer.routes";
|
||||||
import clientTargetRoutes from "./routes/clientTarget.routes";
|
import clientTargetRoutes from "./routes/clientTarget.routes";
|
||||||
|
import apiKeyRoutes from "./routes/apiKey.routes";
|
||||||
|
import mcpRoutes from "./routes/mcp.routes";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -70,6 +72,8 @@ async function main() {
|
|||||||
app.use("/time-entries", timeEntryRoutes);
|
app.use("/time-entries", timeEntryRoutes);
|
||||||
app.use("/timer", timerRoutes);
|
app.use("/timer", timerRoutes);
|
||||||
app.use("/client-targets", clientTargetRoutes);
|
app.use("/client-targets", clientTargetRoutes);
|
||||||
|
app.use("/api-keys", apiKeyRoutes);
|
||||||
|
app.use("/mcp", mcpRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import { prisma } from '../prisma/client';
|
import { prisma } from '../prisma/client';
|
||||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||||
import { verifyBackendJwt } from '../auth/jwt';
|
import { verifyBackendJwt } from '../auth/jwt';
|
||||||
|
import { ApiKeyService } from '../services/apiKey.service';
|
||||||
|
|
||||||
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
export async function requireAuth(
|
export async function requireAuth(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
@@ -17,11 +20,33 @@ export async function requireAuth(
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Bearer JWT auth (iOS / native clients)
|
// 2. Bearer token auth (JWT or API key)
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
|
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 {
|
try {
|
||||||
req.user = verifyBackendJwt(token);
|
req.user = verifyBackendJwt(token);
|
||||||
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);
|
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;
|
||||||
@@ -73,14 +73,20 @@ export const StopTimerSchema = z.object({
|
|||||||
projectId: z.string().uuid().optional(),
|
projectId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']);
|
||||||
|
|
||||||
export const CreateClientTargetSchema = z.object({
|
export const CreateClientTargetSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
weeklyHours: z.number().positive().max(168),
|
targetHours: z.number().positive().max(168),
|
||||||
|
periodType: z.enum(['weekly', 'monthly']),
|
||||||
|
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required'),
|
||||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'),
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateClientTargetSchema = z.object({
|
export const UpdateClientTargetSchema = z.object({
|
||||||
weeklyHours: z.number().positive().max(168).optional(),
|
targetHours: z.number().positive().max(168).optional(),
|
||||||
|
periodType: z.enum(['weekly', 'monthly']).optional(),
|
||||||
|
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required').optional(),
|
||||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,3 +95,7 @@ export const CreateCorrectionSchema = z.object({
|
|||||||
hours: z.number().min(-1000).max(1000),
|
hours: z.number().min(-1000).max(1000),
|
||||||
description: z.string().max(255).optional(),
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,14 @@ import type { CreateClientInput, UpdateClientInput } from "../types";
|
|||||||
export class ClientService {
|
export class ClientService {
|
||||||
async findAll(userId: string) {
|
async findAll(userId: string) {
|
||||||
return prisma.client.findMany({
|
return prisma.client.findMany({
|
||||||
where: { userId },
|
where: { userId, deletedAt: null },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string, userId: string) {
|
async findById(id: string, userId: string) {
|
||||||
return prisma.client.findFirst({
|
return prisma.client.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId, deletedAt: null },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +43,9 @@ export class ClientService {
|
|||||||
throw new NotFoundError("Client not found");
|
throw new NotFoundError("Client not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.client.delete({
|
await prisma.client.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
|
|||||||
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
|
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
// Returns the Monday of the week containing the given date
|
// ---------------------------------------------------------------------------
|
||||||
function getMondayOfWeek(date: Date): Date {
|
// Day-of-week helpers
|
||||||
const d = new Date(date);
|
// ---------------------------------------------------------------------------
|
||||||
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
|
|
||||||
|
const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const;
|
||||||
|
|
||||||
|
/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */
|
||||||
|
function dayIndex(dateStr: string): number {
|
||||||
|
return new Date(dateStr + 'T00:00:00Z').getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */
|
||||||
|
function isWorkingDay(dateStr: string, workingDays: string[]): boolean {
|
||||||
|
return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */
|
||||||
|
function addDays(dateStr: string, n: number): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the Monday of the ISO week that contains the given date string. */
|
||||||
|
function getMondayOfWeek(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
const day = d.getUTCDay(); // 0=Sun
|
||||||
const diff = day === 0 ? -6 : 1 - day;
|
const diff = day === 0 ? -6 : 1 - day;
|
||||||
d.setUTCDate(d.getUTCDate() + diff);
|
d.setUTCDate(d.getUTCDate() + diff);
|
||||||
d.setUTCHours(0, 0, 0, 0);
|
return d.toISOString().split('T')[0];
|
||||||
return d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the Sunday (end of week) for a given Monday
|
/** Returns the Sunday of the ISO week given its Monday date string. */
|
||||||
function getSundayOfWeek(monday: Date): Date {
|
function getSundayOfWeek(monday: string): string {
|
||||||
const d = new Date(monday);
|
return addDays(monday, 6);
|
||||||
d.setUTCDate(d.getUTCDate() + 6);
|
|
||||||
d.setUTCHours(23, 59, 59, 999);
|
|
||||||
return d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all Mondays from startDate up to and including the current week's Monday
|
/** Returns the first day of the month for a given date string. */
|
||||||
function getWeekMondays(startDate: Date): Date[] {
|
function getMonthStart(dateStr: string): string {
|
||||||
const mondays: Date[] = [];
|
return dateStr.slice(0, 7) + '-01';
|
||||||
const currentMonday = getMondayOfWeek(new Date());
|
|
||||||
let cursor = new Date(startDate);
|
|
||||||
cursor.setUTCHours(0, 0, 0, 0);
|
|
||||||
while (cursor <= currentMonday) {
|
|
||||||
mondays.push(new Date(cursor));
|
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
|
||||||
}
|
|
||||||
return mondays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeekBalance {
|
/** Returns the last day of the month for a given date string. */
|
||||||
weekStart: string; // ISO date string (Monday)
|
function getMonthEnd(dateStr: string): string {
|
||||||
weekEnd: string; // ISO date string (Sunday)
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
// Set to first day of next month then subtract 1 day
|
||||||
|
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
|
||||||
|
return last.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total calendar days in the month containing dateStr. */
|
||||||
|
function daysInMonth(dateStr: string): number {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */
|
||||||
|
function cmpDate(a: string, b: string): number {
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Period enumeration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Period {
|
||||||
|
start: string; // YYYY-MM-DD
|
||||||
|
end: string; // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the period (start + end) that contains the given date.
|
||||||
|
* For weekly: Mon–Sun.
|
||||||
|
* For monthly: 1st–last day of month.
|
||||||
|
*/
|
||||||
|
function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period {
|
||||||
|
if (periodType === 'weekly') {
|
||||||
|
const monday = getMondayOfWeek(dateStr);
|
||||||
|
return { start: monday, end: getSundayOfWeek(monday) };
|
||||||
|
} else {
|
||||||
|
return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the start of the NEXT period after `currentPeriodEnd`.
|
||||||
|
*/
|
||||||
|
function nextPeriodStart(currentPeriodEnd: string, periodType: 'weekly' | 'monthly'): string {
|
||||||
|
if (periodType === 'weekly') {
|
||||||
|
return addDays(currentPeriodEnd, 1); // Monday of next week
|
||||||
|
} else {
|
||||||
|
// First day of next month
|
||||||
|
const d = new Date(currentPeriodEnd + 'T00:00:00Z');
|
||||||
|
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
|
||||||
|
return next.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerates all periods from startDate's period through today's period (inclusive).
|
||||||
|
*/
|
||||||
|
function enumeratePeriods(startDate: string, periodType: 'weekly' | 'monthly'): Period[] {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const periods: Period[] = [];
|
||||||
|
|
||||||
|
const firstPeriod = getPeriodForDate(startDate, periodType);
|
||||||
|
let cursor = firstPeriod;
|
||||||
|
|
||||||
|
while (cmpDate(cursor.start, today) <= 0) {
|
||||||
|
periods.push(cursor);
|
||||||
|
const ns = nextPeriodStart(cursor.end, periodType);
|
||||||
|
cursor = getPeriodForDate(ns, periodType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Working-day counting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts working days in [from, to] (both inclusive) matching the given pattern.
|
||||||
|
*/
|
||||||
|
function countWorkingDays(from: string, to: string, workingDays: string[]): number {
|
||||||
|
if (cmpDate(from, to) > 0) return 0;
|
||||||
|
let count = 0;
|
||||||
|
let cur = from;
|
||||||
|
while (cmpDate(cur, to) <= 0) {
|
||||||
|
if (isWorkingDay(cur, workingDays)) count++;
|
||||||
|
cur = addDays(cur, 1);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pro-ration helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pro-rated target hours for the first period, applying §5 of the spec.
|
||||||
|
* If startDate falls on the natural first day of the period, no pro-ration occurs.
|
||||||
|
*/
|
||||||
|
function computePeriodTargetHours(
|
||||||
|
period: Period,
|
||||||
|
startDate: string,
|
||||||
|
targetHours: number,
|
||||||
|
periodType: 'weekly' | 'monthly',
|
||||||
|
): number {
|
||||||
|
const naturalStart = period.start;
|
||||||
|
if (cmpDate(startDate, naturalStart) <= 0) {
|
||||||
|
// startDate is at or before the natural period start — no pro-ration needed
|
||||||
|
return targetHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDate is inside the period → pro-rate by calendar days
|
||||||
|
const fullDays = periodType === 'weekly' ? 7 : daysInMonth(period.start);
|
||||||
|
const remainingDays = daysBetween(startDate, period.end); // inclusive both ends
|
||||||
|
return (remainingDays / fullDays) * targetHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar days between two dates (both inclusive). */
|
||||||
|
function daysBetween(from: string, to: string): number {
|
||||||
|
const a = new Date(from + 'T00:00:00Z').getTime();
|
||||||
|
const b = new Date(to + 'T00:00:00Z').getTime();
|
||||||
|
return Math.round((b - a) / 86400000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PeriodBalance {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
targetHours: number;
|
||||||
trackedSeconds: number;
|
trackedSeconds: number;
|
||||||
targetSeconds: number;
|
|
||||||
correctionHours: number;
|
correctionHours: number;
|
||||||
balanceSeconds: number; // positive = overtime, negative = undertime
|
balanceSeconds: number;
|
||||||
|
isOngoing: boolean;
|
||||||
|
// only when isOngoing = true
|
||||||
|
dailyRateHours?: number;
|
||||||
|
workingDaysInPeriod?: number;
|
||||||
|
elapsedWorkingDays?: number;
|
||||||
|
expectedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientTargetWithBalance {
|
export interface ClientTargetWithBalance {
|
||||||
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
periodType: 'weekly' | 'monthly';
|
||||||
|
targetHours: number;
|
||||||
|
workingDays: string[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -59,53 +208,91 @@ export interface ClientTargetWithBalance {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}>;
|
}>;
|
||||||
totalBalanceSeconds: number; // running total across all weeks
|
totalBalanceSeconds: number;
|
||||||
currentWeekTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentWeekTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
weeks: WeekBalance[];
|
periods: PeriodBalance[];
|
||||||
|
/** True when an active timer is running for a project belonging to this client. */
|
||||||
|
hasOngoingTimer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prisma record shape accepted by computeBalance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TargetRecord = {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
targetHours: number;
|
||||||
|
periodType: 'WEEKLY' | 'MONTHLY';
|
||||||
|
workingDays: string[];
|
||||||
|
startDate: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
client: { id: string; name: string };
|
||||||
|
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class ClientTargetService {
|
export class ClientTargetService {
|
||||||
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
||||||
const targets = await prisma.clientTarget.findMany({
|
const targets = await prisma.clientTarget.findMany({
|
||||||
where: { userId },
|
where: { userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
orderBy: { client: { name: 'asc' } },
|
orderBy: { client: { name: 'asc' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(targets.map(t => this.computeBalance(t)));
|
return Promise.all(targets.map(t => this.computeBalance(t as unknown as TargetRecord)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string, userId: string) {
|
async findById(id: string, userId: string) {
|
||||||
return prisma.clientTarget.findFirst({
|
return prisma.clientTarget.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
|
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||||
// Validate startDate is a Monday
|
// Ensure the client belongs to this user and is not soft-deleted
|
||||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
|
||||||
const dayOfWeek = startDate.getUTCDay();
|
|
||||||
if (dayOfWeek !== 1) {
|
|
||||||
throw new BadRequestError('startDate must be a Monday');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the client belongs to this user
|
|
||||||
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } });
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new NotFoundError('Client not found');
|
throw new NotFoundError('Client not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||||
|
const periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
|
||||||
|
|
||||||
// Check for existing target (unique per user+client)
|
// Check for existing target (unique per user+client)
|
||||||
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.deletedAt !== null) {
|
||||||
|
// Reactivate the soft-deleted target with the new settings
|
||||||
|
const reactivated = await prisma.clientTarget.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
deletedAt: null,
|
||||||
|
targetHours: data.targetHours,
|
||||||
|
periodType,
|
||||||
|
workingDays: data.workingDays,
|
||||||
|
startDate,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: { select: { id: true, name: true } },
|
||||||
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.computeBalance(reactivated as unknown as TargetRecord);
|
||||||
|
}
|
||||||
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,52 +300,55 @@ export class ClientTargetService {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
weeklyHours: data.weeklyHours,
|
targetHours: data.targetHours,
|
||||||
|
periodType,
|
||||||
|
workingDays: data.workingDays,
|
||||||
startDate,
|
startDate,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.computeBalance(target);
|
return this.computeBalance(target as unknown as TargetRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
|
async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||||
const existing = await this.findById(id, userId);
|
const existing = await this.findById(id, userId);
|
||||||
if (!existing) throw new NotFoundError('Client target not found');
|
if (!existing) throw new NotFoundError('Client target not found');
|
||||||
|
|
||||||
const updateData: { weeklyHours?: number; startDate?: Date } = {};
|
const updateData: {
|
||||||
|
targetHours?: number;
|
||||||
|
periodType?: 'WEEKLY' | 'MONTHLY';
|
||||||
|
workingDays?: string[];
|
||||||
|
startDate?: Date;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (data.weeklyHours !== undefined) {
|
if (data.targetHours !== undefined) updateData.targetHours = data.targetHours;
|
||||||
updateData.weeklyHours = data.weeklyHours;
|
if (data.periodType !== undefined) updateData.periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
|
||||||
}
|
if (data.workingDays !== undefined) updateData.workingDays = data.workingDays;
|
||||||
|
if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||||
if (data.startDate !== undefined) {
|
|
||||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
|
||||||
if (startDate.getUTCDay() !== 1) {
|
|
||||||
throw new BadRequestError('startDate must be a Monday');
|
|
||||||
}
|
|
||||||
updateData.startDate = startDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.clientTarget.update({
|
const updated = await prisma.clientTarget.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true } },
|
client: { select: { id: true, name: true } },
|
||||||
corrections: { orderBy: { date: 'asc' } },
|
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.computeBalance(updated);
|
return this.computeBalance(updated as unknown as TargetRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string, userId: string): Promise<void> {
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
const existing = await this.findById(id, userId);
|
const existing = await this.findById(id, userId);
|
||||||
if (!existing) throw new NotFoundError('Client target not found');
|
if (!existing) throw new NotFoundError('Client target not found');
|
||||||
await prisma.clientTarget.delete({ where: { id } });
|
await prisma.clientTarget.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
||||||
@@ -188,99 +378,221 @@ export class ClientTargetService {
|
|||||||
if (!target) throw new NotFoundError('Client target not found');
|
if (!target) throw new NotFoundError('Client target not found');
|
||||||
|
|
||||||
const correction = await prisma.balanceCorrection.findFirst({
|
const correction = await prisma.balanceCorrection.findFirst({
|
||||||
where: { id: correctionId, clientTargetId: targetId },
|
where: { id: correctionId, clientTargetId: targetId, deletedAt: null },
|
||||||
});
|
});
|
||||||
if (!correction) throw new NotFoundError('Correction not found');
|
if (!correction) throw new NotFoundError('Correction not found');
|
||||||
|
|
||||||
await prisma.balanceCorrection.delete({ where: { id: correctionId } });
|
await prisma.balanceCorrection.update({
|
||||||
|
where: { id: correctionId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeBalance(target: {
|
// ---------------------------------------------------------------------------
|
||||||
id: string;
|
// Balance computation
|
||||||
clientId: string;
|
// ---------------------------------------------------------------------------
|
||||||
userId: string;
|
|
||||||
weeklyHours: number;
|
|
||||||
startDate: Date;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
client: { id: string; name: string };
|
|
||||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
|
||||||
}): Promise<ClientTargetWithBalance> {
|
|
||||||
const mondays = getWeekMondays(target.startDate);
|
|
||||||
|
|
||||||
if (mondays.length === 0) {
|
private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
|
||||||
return this.emptyBalance(target);
|
const startDateStr = target.startDate.toISOString().split('T')[0];
|
||||||
|
const periodType = target.periodType.toLowerCase() as 'weekly' | 'monthly';
|
||||||
|
const workingDays = target.workingDays;
|
||||||
|
|
||||||
|
const periods = enumeratePeriods(startDateStr, periodType);
|
||||||
|
|
||||||
|
if (periods.length === 0) {
|
||||||
|
return this.emptyBalance(target, periodType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all tracked time for this user on this client's projects in one query
|
const overallStart = periods[0].start;
|
||||||
// covering startDate to end of current week
|
const overallEnd = periods[periods.length - 1].end;
|
||||||
const periodStart = mondays[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
|
|
||||||
|
|
||||||
type TrackedRow = { week_start: Date; tracked_seconds: bigint };
|
// Fetch active timer for this user (if any) and check if it belongs to this client
|
||||||
|
const ongoingTimer = await prisma.ongoingTimer.findUnique({
|
||||||
|
where: { userId: target.userId },
|
||||||
|
include: { project: { select: { clientId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
// Elapsed seconds from the active timer attributed to this client target.
|
||||||
|
// We only count it if the timer has a project assigned and that project
|
||||||
|
// belongs to the same client as this target.
|
||||||
|
let ongoingTimerSeconds = 0;
|
||||||
|
let ongoingTimerPeriodStart: string | null = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ongoingTimer &&
|
||||||
|
ongoingTimer.projectId !== null &&
|
||||||
|
ongoingTimer.project?.clientId === target.clientId
|
||||||
|
) {
|
||||||
|
ongoingTimerSeconds = Math.floor(
|
||||||
|
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
|
||||||
|
);
|
||||||
|
// Determine which period the timer's start time falls into
|
||||||
|
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
|
||||||
|
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
|
||||||
|
ongoingTimerPeriodStart = timerPeriod.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all time tracked for this client across the full range in one query
|
||||||
|
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
||||||
|
|
||||||
|
let trackedRows: TrackedRow[];
|
||||||
|
if (periodType === 'weekly') {
|
||||||
|
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
|
TO_CHAR(
|
||||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds
|
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
) AS period_start,
|
||||||
|
COALESCE(
|
||||||
|
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
|
||||||
|
0
|
||||||
|
)::bigint AS tracked_seconds
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
WHERE te.user_id = ${target.userId}
|
WHERE te.user_id = ${target.userId}
|
||||||
AND p.client_id = ${target.clientId}
|
AND p.client_id = ${target.clientId}
|
||||||
AND te.start_time >= ${periodStart}
|
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||||
AND te.start_time <= ${periodEnd}
|
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
||||||
`);
|
`);
|
||||||
|
} else {
|
||||||
// Index tracked seconds by week start (ISO Monday string)
|
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
const trackedByWeek = new Map<string, number>();
|
SELECT
|
||||||
for (const row of rows) {
|
TO_CHAR(
|
||||||
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
|
DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
|
||||||
const monday = getMondayOfWeek(new Date(row.week_start));
|
'YYYY-MM-DD'
|
||||||
const key = monday.toISOString().split('T')[0];
|
) AS period_start,
|
||||||
trackedByWeek.set(key, Number(row.tracked_seconds));
|
COALESCE(
|
||||||
|
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
|
||||||
|
0
|
||||||
|
)::bigint AS tracked_seconds
|
||||||
|
FROM time_entries te
|
||||||
|
JOIN projects p ON p.id = te.project_id
|
||||||
|
WHERE te.user_id = ${target.userId}
|
||||||
|
AND p.client_id = ${target.clientId}
|
||||||
|
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||||
|
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC')
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index corrections by week
|
// Map tracked seconds by period start date string
|
||||||
const correctionsByWeek = new Map<string, number>();
|
const trackedByPeriod = new Map<string, number>();
|
||||||
|
for (const row of trackedRows) {
|
||||||
|
// Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday
|
||||||
|
const key = typeof row.period_start === 'string'
|
||||||
|
? row.period_start
|
||||||
|
: (row.period_start as Date).toISOString().split('T')[0];
|
||||||
|
trackedByPeriod.set(key, Number(row.tracked_seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index corrections by period start date
|
||||||
|
const correctionsByPeriod = new Map<string, number>();
|
||||||
for (const c of target.corrections) {
|
for (const c of target.corrections) {
|
||||||
const monday = getMondayOfWeek(new Date(c.date));
|
const corrDateStr = c.date.toISOString().split('T')[0];
|
||||||
const key = monday.toISOString().split('T')[0];
|
const period = getPeriodForDate(corrDateStr, periodType);
|
||||||
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours);
|
const key = period.start;
|
||||||
|
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetSecondsPerWeek = target.weeklyHours * 3600;
|
const periodBalances: PeriodBalance[] = [];
|
||||||
const weeks: WeekBalance[] = [];
|
|
||||||
let totalBalanceSeconds = 0;
|
let totalBalanceSeconds = 0;
|
||||||
|
const isFirstPeriod = (i: number) => i === 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < periods.length; i++) {
|
||||||
|
const period = periods[i];
|
||||||
|
|
||||||
|
// Effective start for this period (clamped to startDate for first period)
|
||||||
|
const effectiveStart = isFirstPeriod(i) && cmpDate(startDateStr, period.start) > 0
|
||||||
|
? startDateStr
|
||||||
|
: period.start;
|
||||||
|
|
||||||
|
// Period target hours (with possible pro-ration on the first period)
|
||||||
|
const periodTargetHours = isFirstPeriod(i)
|
||||||
|
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
|
||||||
|
: target.targetHours;
|
||||||
|
|
||||||
|
// Add ongoing timer seconds to the period it started in (if it belongs to this client)
|
||||||
|
const timerContribution =
|
||||||
|
ongoingTimerPeriodStart !== null && period.start === ongoingTimerPeriodStart
|
||||||
|
? ongoingTimerSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const trackedSeconds = (trackedByPeriod.get(period.start) ?? 0) + timerContribution;
|
||||||
|
const correctionHours = correctionsByPeriod.get(period.start) ?? 0;
|
||||||
|
|
||||||
|
const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 0;
|
||||||
|
|
||||||
|
let balanceSeconds: number;
|
||||||
|
let extra: Partial<PeriodBalance> = {};
|
||||||
|
|
||||||
|
if (isOngoing) {
|
||||||
|
// §6: ongoing period — expected hours based on elapsed working days
|
||||||
|
const workingDaysInPeriod = countWorkingDays(effectiveStart, period.end, workingDays);
|
||||||
|
const dailyRateHours = workingDaysInPeriod > 0 ? periodTargetHours / workingDaysInPeriod : 0;
|
||||||
|
|
||||||
|
const elapsedEnd = today < period.end ? today : period.end;
|
||||||
|
const elapsedWorkingDays = countWorkingDays(effectiveStart, elapsedEnd, workingDays);
|
||||||
|
const expectedHours = elapsedWorkingDays * dailyRateHours;
|
||||||
|
|
||||||
|
// Only count corrections up to and including today — future corrections
|
||||||
|
// within the ongoing period must not be counted until those days have elapsed,
|
||||||
|
// otherwise a +8h correction for tomorrow inflates the balance immediately.
|
||||||
|
const correctionHoursToDate = target.corrections.reduce((sum, c) => {
|
||||||
|
const d = c.date.toISOString().split('T')[0];
|
||||||
|
if (cmpDate(d, effectiveStart) >= 0 && cmpDate(d, today) <= 0) {
|
||||||
|
return sum + c.hours;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
balanceSeconds = Math.round(
|
||||||
|
(trackedSeconds + correctionHoursToDate * 3600) - expectedHours * 3600,
|
||||||
|
);
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
dailyRateHours,
|
||||||
|
workingDaysInPeriod,
|
||||||
|
elapsedWorkingDays,
|
||||||
|
expectedHours,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// §4: completed period — simple formula
|
||||||
|
balanceSeconds = Math.round(
|
||||||
|
(trackedSeconds + correctionHours * 3600) - periodTargetHours * 3600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const monday of mondays) {
|
|
||||||
const key = monday.toISOString().split('T')[0];
|
|
||||||
const sunday = getSundayOfWeek(monday);
|
|
||||||
const trackedSeconds = trackedByWeek.get(key) ?? 0;
|
|
||||||
const correctionHours = correctionsByWeek.get(key) ?? 0;
|
|
||||||
const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600;
|
|
||||||
const balanceSeconds = trackedSeconds - effectiveTargetSeconds;
|
|
||||||
totalBalanceSeconds += balanceSeconds;
|
totalBalanceSeconds += balanceSeconds;
|
||||||
|
|
||||||
weeks.push({
|
periodBalances.push({
|
||||||
weekStart: key,
|
periodStart: period.start,
|
||||||
weekEnd: sunday.toISOString().split('T')[0],
|
periodEnd: period.end,
|
||||||
|
targetHours: periodTargetHours,
|
||||||
trackedSeconds,
|
trackedSeconds,
|
||||||
targetSeconds: effectiveTargetSeconds,
|
|
||||||
correctionHours,
|
correctionHours,
|
||||||
balanceSeconds,
|
balanceSeconds,
|
||||||
|
isOngoing,
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWeek = weeks[weeks.length - 1];
|
const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: target.id,
|
id: target.id,
|
||||||
clientId: target.clientId,
|
clientId: target.clientId,
|
||||||
clientName: target.client.name,
|
clientName: target.client.name,
|
||||||
userId: target.userId,
|
userId: target.userId,
|
||||||
weeklyHours: target.weeklyHours,
|
periodType,
|
||||||
startDate: target.startDate.toISOString().split('T')[0],
|
targetHours: target.targetHours,
|
||||||
|
workingDays,
|
||||||
|
startDate: startDateStr,
|
||||||
createdAt: target.createdAt.toISOString(),
|
createdAt: target.createdAt.toISOString(),
|
||||||
updatedAt: target.updatedAt.toISOString(),
|
updatedAt: target.updatedAt.toISOString(),
|
||||||
corrections: target.corrections.map(c => ({
|
corrections: target.corrections.map(c => ({
|
||||||
@@ -291,37 +603,33 @@ export class ClientTargetService {
|
|||||||
createdAt: c.createdAt.toISOString(),
|
createdAt: c.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
totalBalanceSeconds,
|
totalBalanceSeconds,
|
||||||
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
|
currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
|
||||||
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
|
currentPeriodTargetSeconds: currentPeriod
|
||||||
weeks,
|
? Math.round(currentPeriod.targetHours * 3600)
|
||||||
|
: Math.round(target.targetHours * 3600),
|
||||||
|
periods: periodBalances,
|
||||||
|
hasOngoingTimer: ongoingTimerSeconds > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private emptyBalance(target: {
|
private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
|
||||||
id: string;
|
|
||||||
clientId: string;
|
|
||||||
userId: string;
|
|
||||||
weeklyHours: number;
|
|
||||||
startDate: Date;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
client: { id: string; name: string };
|
|
||||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
|
||||||
}): ClientTargetWithBalance {
|
|
||||||
return {
|
return {
|
||||||
id: target.id,
|
id: target.id,
|
||||||
clientId: target.clientId,
|
clientId: target.clientId,
|
||||||
clientName: target.client.name,
|
clientName: target.client.name,
|
||||||
userId: target.userId,
|
userId: target.userId,
|
||||||
weeklyHours: target.weeklyHours,
|
periodType,
|
||||||
|
targetHours: target.targetHours,
|
||||||
|
workingDays: target.workingDays,
|
||||||
startDate: target.startDate.toISOString().split('T')[0],
|
startDate: target.startDate.toISOString().split('T')[0],
|
||||||
createdAt: target.createdAt.toISOString(),
|
createdAt: target.createdAt.toISOString(),
|
||||||
updatedAt: target.updatedAt.toISOString(),
|
updatedAt: target.updatedAt.toISOString(),
|
||||||
corrections: [],
|
corrections: [],
|
||||||
totalBalanceSeconds: 0,
|
totalBalanceSeconds: 0,
|
||||||
currentWeekTrackedSeconds: 0,
|
currentPeriodTrackedSeconds: 0,
|
||||||
currentWeekTargetSeconds: target.weeklyHours * 3600,
|
currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
|
||||||
weeks: [],
|
periods: [],
|
||||||
|
hasOngoingTimer: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export class ProjectService {
|
|||||||
return prisma.project.findMany({
|
return prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
client: { deletedAt: null },
|
||||||
...(clientId && { clientId }),
|
...(clientId && { clientId }),
|
||||||
},
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
@@ -23,7 +25,12 @@ export class ProjectService {
|
|||||||
|
|
||||||
async findById(id: string, userId: string) {
|
async findById(id: string, userId: string) {
|
||||||
return prisma.project.findFirst({
|
return prisma.project.findFirst({
|
||||||
where: { id, userId },
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
client: { deletedAt: null },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
client: {
|
client: {
|
||||||
select: {
|
select: {
|
||||||
@@ -36,9 +43,9 @@ export class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: string, data: CreateProjectInput) {
|
async create(userId: string, data: CreateProjectInput) {
|
||||||
// Verify the client belongs to the user
|
// Verify the client belongs to the user and is not soft-deleted
|
||||||
const client = await prisma.client.findFirst({
|
const client = await prisma.client.findFirst({
|
||||||
where: { id: data.clientId, userId },
|
where: { id: data.clientId, userId, deletedAt: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -70,10 +77,10 @@ export class ProjectService {
|
|||||||
throw new NotFoundError("Project not found");
|
throw new NotFoundError("Project not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If clientId is being updated, verify it belongs to the user
|
// If clientId is being updated, verify it belongs to the user and is not soft-deleted
|
||||||
if (data.clientId) {
|
if (data.clientId) {
|
||||||
const client = await prisma.client.findFirst({
|
const client = await prisma.client.findFirst({
|
||||||
where: { id: data.clientId, userId },
|
where: { id: data.clientId, userId, deletedAt: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -108,8 +115,9 @@ export class ProjectService {
|
|||||||
throw new NotFoundError("Project not found");
|
throw new NotFoundError("Project not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.project.delete({
|
await prisma.project.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export class TimeEntryService {
|
|||||||
COUNT(te.id)::bigint AS entry_count
|
COUNT(te.id)::bigint AS entry_count
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
|
JOIN clients c ON c.id = p.client_id
|
||||||
WHERE te.user_id = ${userId}
|
WHERE te.user_id = ${userId}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
${filterClause}
|
${filterClause}
|
||||||
GROUP BY p.id, p.name, p.color
|
GROUP BY p.id, p.name, p.color
|
||||||
ORDER BY total_seconds DESC
|
ORDER BY total_seconds DESC
|
||||||
@@ -69,6 +73,9 @@ export class TimeEntryService {
|
|||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
JOIN clients c ON c.id = p.client_id
|
JOIN clients c ON c.id = p.client_id
|
||||||
WHERE te.user_id = ${userId}
|
WHERE te.user_id = ${userId}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
${filterClause}
|
${filterClause}
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.id, c.name
|
||||||
ORDER BY total_seconds DESC
|
ORDER BY total_seconds DESC
|
||||||
@@ -81,7 +88,11 @@ export class TimeEntryService {
|
|||||||
COUNT(te.id)::bigint AS entry_count
|
COUNT(te.id)::bigint AS entry_count
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
|
JOIN clients c ON c.id = p.client_id
|
||||||
WHERE te.user_id = ${userId}
|
WHERE te.user_id = ${userId}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
${filterClause}
|
${filterClause}
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
@@ -125,10 +136,11 @@ export class TimeEntryService {
|
|||||||
|
|
||||||
const where: {
|
const where: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
deletedAt: null;
|
||||||
startTime?: { gte?: Date; lte?: Date };
|
startTime?: { gte?: Date; lte?: Date };
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
project?: { clientId?: string };
|
project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } };
|
||||||
} = { userId };
|
} = { userId, deletedAt: null };
|
||||||
|
|
||||||
if (startDate || endDate) {
|
if (startDate || endDate) {
|
||||||
where.startTime = {};
|
where.startTime = {};
|
||||||
@@ -140,9 +152,13 @@ export class TimeEntryService {
|
|||||||
where.projectId = projectId;
|
where.projectId = projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientId) {
|
// Always filter out entries whose project or client is soft-deleted,
|
||||||
where.project = { clientId };
|
// merging the optional clientId filter into the project relation filter.
|
||||||
}
|
where.project = {
|
||||||
|
deletedAt: null,
|
||||||
|
client: { deletedAt: null },
|
||||||
|
...(clientId && { clientId }),
|
||||||
|
};
|
||||||
|
|
||||||
const [entries, total] = await Promise.all([
|
const [entries, total] = await Promise.all([
|
||||||
prisma.timeEntry.findMany({
|
prisma.timeEntry.findMany({
|
||||||
@@ -182,7 +198,12 @@ export class TimeEntryService {
|
|||||||
|
|
||||||
async findById(id: string, userId: string) {
|
async findById(id: string, userId: string) {
|
||||||
return prisma.timeEntry.findFirst({
|
return prisma.timeEntry.findFirst({
|
||||||
where: { id, userId },
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
project: { deletedAt: null, client: { deletedAt: null } },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {
|
select: {
|
||||||
@@ -217,9 +238,9 @@ export class TimeEntryService {
|
|||||||
throw new BadRequestError("Break time cannot exceed total duration");
|
throw new BadRequestError("Break time cannot exceed total duration");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the project belongs to the user
|
// Verify the project belongs to the user and is not soft-deleted (nor its client)
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id: data.projectId, userId },
|
where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -288,10 +309,10 @@ export class TimeEntryService {
|
|||||||
throw new BadRequestError("Break time cannot exceed total duration");
|
throw new BadRequestError("Break time cannot exceed total duration");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If project changed, verify it belongs to the user
|
// If project changed, verify it belongs to the user and is not soft-deleted
|
||||||
if (data.projectId && data.projectId !== entry.projectId) {
|
if (data.projectId && data.projectId !== entry.projectId) {
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id: data.projectId, userId },
|
where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -345,8 +366,9 @@ export class TimeEntryService {
|
|||||||
throw new NotFoundError("Time entry not found");
|
throw new NotFoundError("Time entry not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.timeEntry.delete({
|
await prisma.timeEntry.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface CreateProjectInput {
|
|||||||
export interface UpdateProjectInput {
|
export interface UpdateProjectInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string | null;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,13 +82,17 @@ export interface StopTimerInput {
|
|||||||
|
|
||||||
export interface CreateClientTargetInput {
|
export interface CreateClientTargetInput {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
weeklyHours: number;
|
targetHours: number;
|
||||||
startDate: string; // YYYY-MM-DD, always a Monday
|
periodType: 'weekly' | 'monthly';
|
||||||
|
workingDays: string[]; // e.g. ["MON","WED","FRI"]
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateClientTargetInput {
|
export interface UpdateClientTargetInput {
|
||||||
weeklyHours?: number;
|
targetHours?: number;
|
||||||
startDate?: string; // YYYY-MM-DD, always a Monday
|
periodType?: 'weekly' | 'monthly';
|
||||||
|
workingDays?: string[];
|
||||||
|
startDate?: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCorrectionInput {
|
export interface CreateCorrectionInput {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function hasOverlappingEntries(
|
|||||||
const count = await prisma.timeEntry.count({
|
const count = await prisma.timeEntry.count({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
// An entry overlaps when it starts before our end AND ends after our start.
|
// An entry overlaps when it starts before our end AND ends after our start.
|
||||||
startTime: { lt: endTime },
|
startTime: { lt: endTime },
|
||||||
|
|||||||
285
docs/client-targets-v2-requirements.md
Normal file
285
docs/client-targets-v2-requirements.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# 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*`
|
||||||
@@ -1,40 +1,23 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<defs>
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
|
||||||
<stop offset="0%" stop-color="#818CF8" />
|
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="100%" stop-color="#4F46E5" />
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
|
||||||
|
<g id="Group">
|
||||||
<!-- App Icon Background -->
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
|
||||||
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" />
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
|
||||||
<!-- Inner Icon Group -->
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
|
||||||
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
|
||||||
<!-- Stopwatch Top Button -->
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
|
||||||
<path d="M256 96 v44" stroke-width="28" />
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
|
||||||
<path d="M224 88 h64" stroke-width="24" />
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
|
||||||
<!-- Stopwatch Side Button -->
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
|
||||||
<path d="M352 176 l 24 -24" stroke-width="24" />
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
|
||||||
<!-- Cap for side button -->
|
|
||||||
<path d="M362 138 l 28 28" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Outer Ring -->
|
|
||||||
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
|
||||||
|
|
||||||
<!-- Clock Hands -->
|
|
||||||
<!-- Minute Hand -->
|
|
||||||
<path d="M256 184 v 88" stroke-width="24" />
|
|
||||||
<!-- Hour Hand -->
|
|
||||||
<path d="M256 272 l 48 32" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Dial Tick Marks -->
|
|
||||||
<g stroke-width="12" opacity="0.6">
|
|
||||||
<line x1="256" y1="172" x2="256" y2="188" />
|
|
||||||
<line x1="256" y1="356" x2="256" y2="372" />
|
|
||||||
<line x1="172" y1="272" x2="188" y2="272" />
|
|
||||||
<line x1="340" y1="272" x2="324" y2="272" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,43 +1,33 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<defs>
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<stop offset="0%" stop-color="#818CF8" />
|
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="100%" stop-color="#4F46E5" />
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" />
|
<feGaussianBlur stdDeviation="16"/>
|
||||||
|
<feOffset dx="0" dy="12" result="offsetblur"/>
|
||||||
|
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
|
||||||
|
<feComposite in2="offsetblur" operator="in"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
|
||||||
|
<g id="Group">
|
||||||
<!-- App Icon Background -->
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
|
||||||
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" />
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
|
||||||
<!-- Inner Icon Group -->
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
|
||||||
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
|
||||||
<!-- Stopwatch Top Button -->
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
|
||||||
<path d="M256 96 v44" stroke-width="28" />
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
|
||||||
<path d="M224 88 h64" stroke-width="24" />
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
|
||||||
<!-- Stopwatch Side Button -->
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
|
||||||
<path d="M352 176 l 24 -24" stroke-width="24" />
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
|
||||||
<!-- Cap for side button -->
|
|
||||||
<path d="M362 138 l 28 28" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Outer Ring -->
|
|
||||||
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
|
||||||
|
|
||||||
<!-- Clock Hands -->
|
|
||||||
<!-- Minute Hand -->
|
|
||||||
<path d="M256 184 v 88" stroke-width="24" />
|
|
||||||
<!-- Hour Hand -->
|
|
||||||
<path d="M256 272 l 48 32" stroke-width="24" />
|
|
||||||
|
|
||||||
<!-- Dial Tick Marks -->
|
|
||||||
<g stroke-width="12" opacity="0.6">
|
|
||||||
<line x1="256" y1="172" x2="256" y2="188" />
|
|
||||||
<line x1="256" y1="356" x2="256" y2="372" />
|
|
||||||
<line x1="172" y1="272" x2="188" y2="272" />
|
|
||||||
<line x1="340" y1="272" x2="324" y2="272" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
|
|||||||
import { ClientsPage } from "./pages/ClientsPage";
|
import { ClientsPage } from "./pages/ClientsPage";
|
||||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||||
import { StatisticsPage } from "./pages/StatisticsPage";
|
import { StatisticsPage } from "./pages/StatisticsPage";
|
||||||
|
import { ApiKeysPage } from "./pages/ApiKeysPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +34,7 @@ function App() {
|
|||||||
<Route path="clients" element={<ClientsPage />} />
|
<Route path="clients" element={<ClientsPage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="statistics" element={<StatisticsPage />} />
|
<Route path="statistics" element={<StatisticsPage />} />
|
||||||
|
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -4,9 +4,9 @@ import { TimerWidget } from './TimerWidget';
|
|||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="h-[100vh] w-[100vw] flex flex-col bg-gray-50">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="pt-4 pb-24">
|
<main className="pt-4 pb-8 grow overflow-auto">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
Settings,
|
||||||
|
Key,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
@@ -40,6 +41,7 @@ export function Navbar() {
|
|||||||
const managementItems = [
|
const managementItems = [
|
||||||
{ to: "/clients", label: "Clients", icon: Briefcase },
|
{ to: "/clients", label: "Clients", icon: Briefcase },
|
||||||
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
||||||
|
{ to: "/api-keys", label: "API Keys", icon: Key },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,20 +49,27 @@ export function Navbar() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<NavLink
|
||||||
<img src="/icon.svg" alt="TimeTracker Logo" className="h-8 w-8 drop-shadow-sm" />
|
className="flex-shrink-0 flex items-center"
|
||||||
|
to={"/dashboard"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/icon.svg"
|
||||||
|
alt="TimeTracker Logo"
|
||||||
|
className="h-8 w-8 drop-shadow-sm"
|
||||||
|
/>
|
||||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
<span className="ml-2 text-xl font-bold text-gray-900">
|
||||||
TimeTracker
|
TimeTracker
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</NavLink>
|
||||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
|
<div className="hidden sm:ml-8 sm:flex sm:space-x-4 items-center">
|
||||||
{/* Main Navigation Items */}
|
{/* Main Navigation Items */}
|
||||||
{mainNavItems.map((item) => (
|
{mainNavItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors h-min ${
|
||||||
isActive
|
isActive
|
||||||
? "text-primary-600 bg-primary-50"
|
? "text-primary-600 bg-primary-50"
|
||||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||||
|
|||||||
@@ -3,27 +3,37 @@ interface StatCardProps {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange';
|
color: 'blue' | 'green' | 'purple' | 'orange';
|
||||||
|
/** When true, renders a pulsing green dot to signal a live/active state. */
|
||||||
|
indicator?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorClasses: Record<StatCardProps['color'], string> = {
|
const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
|
||||||
blue: 'bg-blue-50 text-blue-600',
|
blue: 'bg-blue-50 text-blue-600',
|
||||||
green: 'bg-green-50 text-green-600',
|
green: 'bg-green-50 text-green-600',
|
||||||
purple: 'bg-purple-50 text-purple-600',
|
purple: 'bg-purple-50 text-purple-600',
|
||||||
orange: 'bg-orange-50 text-orange-600',
|
orange: 'bg-orange-50 text-orange-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
||||||
<Icon className="h-6 w-6" />
|
<Icon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4 flex-1">
|
||||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
<p className="text-sm font-medium text-gray-600">{label}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{indicator && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
|
||||||
|
title="Timer running"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function TimerWidget() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +156,7 @@ export function TimerWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg z-50">
|
<div className="bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
|
||||||
{ongoingTimer ? (
|
{ongoingTimer ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { clientTargetsApi } from '@/api/clientTargets';
|
import { clientTargetsApi } from '@/api/clientTargets';
|
||||||
|
import { useTimer } from '@/contexts/TimerContext';
|
||||||
import type {
|
import type {
|
||||||
CreateClientTargetInput,
|
CreateClientTargetInput,
|
||||||
UpdateClientTargetInput,
|
UpdateClientTargetInput,
|
||||||
@@ -8,10 +9,13 @@ import type {
|
|||||||
|
|
||||||
export function useClientTargets() {
|
export function useClientTargets() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { ongoingTimer } = useTimer();
|
||||||
|
|
||||||
const { data: targets, isLoading, error } = useQuery({
|
const { data: targets, isLoading, error } = useQuery({
|
||||||
queryKey: ['clientTargets'],
|
queryKey: ['clientTargets'],
|
||||||
queryFn: clientTargetsApi.getAll,
|
queryFn: clientTargetsApi.getAll,
|
||||||
|
// Poll every 30 s while a timer is running so the balance stays current
|
||||||
|
refetchInterval: ongoingTimer ? 30_000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTarget = useMutation({
|
const createTarget = useMutation({
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,33 +14,10 @@ import type {
|
|||||||
CreateCorrectionInput,
|
CreateCorrectionInput,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16"
|
const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const;
|
||||||
function weekInputToMonday(weekValue: string): string {
|
const DAY_LABELS: Record<string, string> = {
|
||||||
const [yearStr, weekStr] = weekValue.split('-W');
|
MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun',
|
||||||
const year = parseInt(yearStr, 10);
|
};
|
||||||
const week = parseInt(weekStr, 10);
|
|
||||||
// ISO week 1 is the week containing the first Thursday of January
|
|
||||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
|
||||||
const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7
|
|
||||||
const monday = new Date(jan4);
|
|
||||||
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
|
|
||||||
return monday.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input
|
|
||||||
function mondayToWeekInput(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr + 'T00:00:00Z');
|
|
||||||
// ISO week number calculation
|
|
||||||
const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
|
|
||||||
const jan4Day = jan4.getUTCDay() || 7;
|
|
||||||
const firstMonday = new Date(jan4);
|
|
||||||
firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1);
|
|
||||||
const diff = date.getTime() - firstMonday.getTime();
|
|
||||||
const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1;
|
|
||||||
// Handle year boundary: if week > 52 we might be in week 1 of next year
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
return `${year}-W${week.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function balanceLabel(seconds: number): { text: string; color: string } {
|
function balanceLabel(seconds: number): { text: string; color: string } {
|
||||||
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
|
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
|
||||||
@@ -58,7 +35,12 @@ function ClientTargetPanel({
|
|||||||
}: {
|
}: {
|
||||||
client: Client;
|
client: Client;
|
||||||
target: ClientTargetWithBalance | undefined;
|
target: ClientTargetWithBalance | undefined;
|
||||||
onCreated: (weeklyHours: number, startDate: string) => Promise<void>;
|
onCreated: (input: {
|
||||||
|
targetHours: number;
|
||||||
|
periodType: 'weekly' | 'monthly';
|
||||||
|
workingDays: string[];
|
||||||
|
startDate: string;
|
||||||
|
}) => Promise<void>;
|
||||||
onDeleted: () => Promise<void>;
|
onDeleted: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
|
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
|
||||||
@@ -69,7 +51,9 @@ function ClientTargetPanel({
|
|||||||
|
|
||||||
// Create/edit form state
|
// Create/edit form state
|
||||||
const [formHours, setFormHours] = useState('');
|
const [formHours, setFormHours] = useState('');
|
||||||
const [formWeek, setFormWeek] = useState('');
|
const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
|
||||||
|
const [formWorkingDays, setFormWorkingDays] = useState<string[]>(['MON', 'TUE', 'WED', 'THU', 'FRI']);
|
||||||
|
const [formStartDate, setFormStartDate] = useState('');
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
@@ -81,13 +65,13 @@ function ClientTargetPanel({
|
|||||||
const [corrError, setCorrError] = useState<string | null>(null);
|
const [corrError, setCorrError] = useState<string | null>(null);
|
||||||
const [corrSaving, setCorrSaving] = useState(false);
|
const [corrSaving, setCorrSaving] = useState(false);
|
||||||
|
|
||||||
|
const todayIso = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormHours('');
|
setFormHours('');
|
||||||
const today = new Date();
|
setFormPeriodType('weekly');
|
||||||
const day = today.getUTCDay() || 7;
|
setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']);
|
||||||
const monday = new Date(today);
|
setFormStartDate(todayIso);
|
||||||
monday.setUTCDate(today.getUTCDate() - day + 1);
|
|
||||||
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
|
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@@ -95,32 +79,56 @@ function ClientTargetPanel({
|
|||||||
|
|
||||||
const openEdit = () => {
|
const openEdit = () => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
setFormHours(String(target.weeklyHours));
|
setFormHours(String(target.targetHours));
|
||||||
setFormWeek(mondayToWeekInput(target.startDate));
|
setFormPeriodType(target.periodType);
|
||||||
|
setFormWorkingDays([...target.workingDays]);
|
||||||
|
setFormStartDate(target.startDate);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleDay = (day: string) => {
|
||||||
|
setFormWorkingDays(prev =>
|
||||||
|
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
const hours = parseFloat(formHours);
|
const hours = parseFloat(formHours);
|
||||||
if (isNaN(hours) || hours <= 0 || hours > 168) {
|
if (isNaN(hours) || hours <= 0 || hours > 168) {
|
||||||
setFormError('Weekly hours must be between 0 and 168');
|
setFormError(`${formPeriodType === 'weekly' ? 'Weekly' : 'Monthly'} hours must be between 0 and 168`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formWeek) {
|
if (formWorkingDays.length === 0) {
|
||||||
setFormError('Please select a start week');
|
setFormError('Select at least one working day');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formStartDate) {
|
||||||
|
setFormError('Please select a start date');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const startDate = weekInputToMonday(formWeek);
|
|
||||||
setFormSaving(true);
|
setFormSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing && target) {
|
if (editing && target) {
|
||||||
await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } });
|
await updateTarget.mutateAsync({
|
||||||
|
id: target.id,
|
||||||
|
input: {
|
||||||
|
targetHours: hours,
|
||||||
|
periodType: formPeriodType,
|
||||||
|
workingDays: formWorkingDays,
|
||||||
|
startDate: formStartDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await onCreated(hours, startDate);
|
await onCreated({
|
||||||
|
targetHours: hours,
|
||||||
|
periodType: formPeriodType,
|
||||||
|
workingDays: formWorkingDays,
|
||||||
|
startDate: formStartDate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -185,23 +193,46 @@ function ClientTargetPanel({
|
|||||||
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
|
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
|
||||||
>
|
>
|
||||||
<Target className="h-3.5 w-3.5" />
|
<Target className="h-3.5 w-3.5" />
|
||||||
Set weekly target
|
Set target
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
|
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||||
{editing ? 'Edit target' : 'Set weekly target'}
|
{editing ? 'Edit target' : 'Set target'}
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleFormSubmit} className="space-y-2">
|
<form onSubmit={handleFormSubmit} className="space-y-2">
|
||||||
{formError && <p className="text-xs text-red-600">{formError}</p>}
|
{formError && <p className="text-xs text-red-600">{formError}</p>}
|
||||||
|
|
||||||
|
{/* Period type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-0.5">Period</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['weekly', 'monthly'] as const).map(pt => (
|
||||||
|
<label key={pt} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="periodType"
|
||||||
|
value={pt}
|
||||||
|
checked={formPeriodType === pt}
|
||||||
|
onChange={() => setFormPeriodType(pt)}
|
||||||
|
className="accent-primary-600"
|
||||||
|
/>
|
||||||
|
{pt.charAt(0).toUpperCase() + pt.slice(1)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours + Start Date */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
|
<label className="block text-xs text-gray-500 mb-0.5">{hoursLabel}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={formHours}
|
value={formHours}
|
||||||
@@ -215,16 +246,41 @@ function ClientTargetPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
|
<label className="block text-xs text-gray-500 mb-0.5">Start date</label>
|
||||||
<input
|
<input
|
||||||
type="week"
|
type="date"
|
||||||
value={formWeek}
|
value={formStartDate}
|
||||||
onChange={e => setFormWeek(e.target.value)}
|
onChange={e => setFormStartDate(e.target.value)}
|
||||||
className="input text-sm py-1"
|
className="input text-sm py-1"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Working days */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-0.5">Working days</label>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{ALL_DAYS.map(day => {
|
||||||
|
const active = formWorkingDays.includes(day);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(day)}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded border font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-primary-600 border-primary-600 text-white'
|
||||||
|
: 'bg-white border-gray-300 text-gray-600 hover:border-primary-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DAY_LABELS[day]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -248,6 +304,7 @@ function ClientTargetPanel({
|
|||||||
|
|
||||||
// Target exists — show summary + expandable details
|
// Target exists — show summary + expandable details
|
||||||
const balance = balanceLabel(target!.totalBalanceSeconds);
|
const balance = balanceLabel(target!.totalBalanceSeconds);
|
||||||
|
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
@@ -256,9 +313,15 @@ function ClientTargetPanel({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||||
<span className="text-xs text-gray-600">
|
<span className="text-xs text-gray-600">
|
||||||
<span className="font-medium">{target!.weeklyHours}h</span>/week
|
<span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
||||||
|
{target!.hasOngoingTimer && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse"
|
||||||
|
title="Timer running — balance updates every 30 s"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -531,8 +594,14 @@ export function ClientsPage() {
|
|||||||
<ClientTargetPanel
|
<ClientTargetPanel
|
||||||
client={client}
|
client={client}
|
||||||
target={target}
|
target={target}
|
||||||
onCreated={async (weeklyHours, startDate) => {
|
onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
|
||||||
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
|
await createTarget.mutateAsync({
|
||||||
|
clientId: client.id,
|
||||||
|
targetHours,
|
||||||
|
periodType,
|
||||||
|
workingDays,
|
||||||
|
startDate,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onDeleted={async () => {
|
onDeleted={async () => {
|
||||||
if (target) await deleteTarget.mutateAsync(target.id);
|
if (target) await deleteTarget.mutateAsync(target.id);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
|
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
|
||||||
import { useTimeEntries } from "@/hooks/useTimeEntries";
|
import { useTimeEntries } from "@/hooks/useTimeEntries";
|
||||||
import { useClientTargets } from "@/hooks/useClientTargets";
|
import { useClientTargets } from "@/hooks/useClientTargets";
|
||||||
|
import { useTimer } from "@/contexts/TimerContext";
|
||||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
|
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
|
||||||
@@ -30,6 +31,7 @@ export function DashboardPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { targets } = useClientTargets();
|
const { targets } = useClientTargets();
|
||||||
|
const { ongoingTimer, elapsedSeconds } = useTimer();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||||||
@@ -54,12 +56,19 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalTodaySeconds =
|
const completedTodaySeconds =
|
||||||
todayEntries?.entries.reduce((total, entry) => {
|
todayEntries?.entries.reduce((total, entry) => {
|
||||||
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||||
}, 0) || 0;
|
}, 0) ?? 0;
|
||||||
|
|
||||||
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
// Only add the running timer if it started today (not a timer left running from yesterday)
|
||||||
|
const timerStartedToday =
|
||||||
|
ongoingTimer !== null &&
|
||||||
|
new Date(ongoingTimer.startTime) >= startOfDay(today);
|
||||||
|
|
||||||
|
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
|
||||||
|
|
||||||
|
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -78,6 +87,7 @@ export function DashboardPage() {
|
|||||||
label="Today"
|
label="Today"
|
||||||
value={formatDurationHoursMinutes(totalTodaySeconds)}
|
value={formatDurationHoursMinutes(totalTodaySeconds)}
|
||||||
color="blue"
|
color="blue"
|
||||||
|
indicator={timerStartedToday}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
@@ -108,7 +118,7 @@ export function DashboardPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Target className="h-5 w-5 text-primary-600" />
|
<Target className="h-5 w-5 text-primary-600" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Targets</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{targetsWithData.map(target => {
|
{targetsWithData.map(target => {
|
||||||
@@ -116,8 +126,9 @@ export function DashboardPage() {
|
|||||||
const absBalance = Math.abs(balance);
|
const absBalance = Math.abs(balance);
|
||||||
const isOver = balance > 0;
|
const isOver = balance > 0;
|
||||||
const isEven = balance === 0;
|
const isEven = balance === 0;
|
||||||
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
|
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
|
||||||
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
|
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
|
||||||
|
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -127,10 +138,17 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
This week: {currentWeekTracked} / {currentWeekTarget}
|
{periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{target.hasOngoingTimer && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||||
|
title="Timer running — balance updates every 30 s"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p
|
<p
|
||||||
className={`text-sm font-bold ${
|
className={`text-sm font-bold ${
|
||||||
isEven
|
isEven
|
||||||
@@ -144,6 +162,7 @@ export function DashboardPage() {
|
|||||||
? '±0'
|
? '±0'
|
||||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-gray-400">running balance</p>
|
<p className="text-xs text-gray-400">running balance</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Client {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
@@ -22,6 +23,7 @@ export interface Project {
|
|||||||
client: Pick<Client, 'id' | 'name'>;
|
client: Pick<Client, 'id' | 'name'>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeEntry {
|
export interface TimeEntry {
|
||||||
@@ -36,6 +38,7 @@ export interface TimeEntry {
|
|||||||
};
|
};
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OngoingTimer {
|
export interface OngoingTimer {
|
||||||
@@ -149,15 +152,22 @@ export interface BalanceCorrection {
|
|||||||
hours: number;
|
hours: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekBalance {
|
export interface PeriodBalance {
|
||||||
weekStart: string; // YYYY-MM-DD (Monday)
|
periodStart: string; // YYYY-MM-DD
|
||||||
weekEnd: string; // YYYY-MM-DD (Sunday)
|
periodEnd: string; // YYYY-MM-DD
|
||||||
|
targetHours: number; // pro-rated for first period
|
||||||
trackedSeconds: number;
|
trackedSeconds: number;
|
||||||
targetSeconds: number;
|
|
||||||
correctionHours: number;
|
correctionHours: number;
|
||||||
balanceSeconds: number;
|
balanceSeconds: number;
|
||||||
|
isOngoing: boolean;
|
||||||
|
// only present when isOngoing = true
|
||||||
|
dailyRateHours?: number;
|
||||||
|
workingDaysInPeriod?: number;
|
||||||
|
elapsedWorkingDays?: number;
|
||||||
|
expectedHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientTargetWithBalance {
|
export interface ClientTargetWithBalance {
|
||||||
@@ -165,26 +175,34 @@ export interface ClientTargetWithBalance {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
weeklyHours: number;
|
periodType: "weekly" | "monthly";
|
||||||
|
targetHours: number;
|
||||||
|
workingDays: string[]; // e.g. ["MON","WED"]
|
||||||
startDate: string; // YYYY-MM-DD
|
startDate: string; // YYYY-MM-DD
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
corrections: BalanceCorrection[];
|
corrections: BalanceCorrection[];
|
||||||
totalBalanceSeconds: number;
|
totalBalanceSeconds: number;
|
||||||
currentWeekTrackedSeconds: number;
|
currentPeriodTrackedSeconds: number;
|
||||||
currentWeekTargetSeconds: number;
|
currentPeriodTargetSeconds: number;
|
||||||
weeks: WeekBalance[];
|
periods: PeriodBalance[];
|
||||||
|
/** True when an active timer for a project belonging to this client is running. */
|
||||||
|
hasOngoingTimer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateClientTargetInput {
|
export interface CreateClientTargetInput {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
weeklyHours: number;
|
targetHours: number;
|
||||||
|
periodType: "weekly" | "monthly";
|
||||||
|
workingDays: string[]; // e.g. ["MON","WED","FRI"]
|
||||||
startDate: string; // YYYY-MM-DD
|
startDate: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateClientTargetInput {
|
export interface UpdateClientTargetInput {
|
||||||
weeklyHours?: number;
|
targetHours?: number;
|
||||||
startDate?: string;
|
periodType?: "weekly" | "monthly";
|
||||||
|
workingDays?: string[];
|
||||||
|
startDate?: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCorrectionInput {
|
export interface CreateCorrectionInput {
|
||||||
@@ -192,3 +210,19 @@ export interface CreateCorrectionInput {
|
|||||||
hours: number;
|
hours: number;
|
||||||
description?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "app_icon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 918 KiB |
Reference in New Issue
Block a user