Build Rank feature prioritization tool
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
rank.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3045
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Rank
|
||||||
|
|
||||||
|
Interactive feature prioritization tool for `rank.friborg.uk`.
|
||||||
|
|
||||||
|
## Product definition
|
||||||
|
|
||||||
|
Rank is a fast intake and prioritization board for product ideas from Jimmi and agents.
|
||||||
|
|
||||||
|
Core loop:
|
||||||
|
|
||||||
|
`Capture idea → score impact/effort/confidence/urgency → drag into milestone → revisit top-ranked work`
|
||||||
|
|
||||||
|
Chosen subdomain: `rank.friborg.uk` — short, memorable, and honest about the job.
|
||||||
|
|
||||||
|
## UX principles
|
||||||
|
|
||||||
|
- One-screen capture, no modal ceremony.
|
||||||
|
- Keyboard-first: `/` focuses capture, Enter saves.
|
||||||
|
- Plain sharp visual system: zero rounded corners, dark space/glass, high contrast.
|
||||||
|
- Milestones are customizable lanes, not a rigid roadmap prison.
|
||||||
|
- Agents can post ideas through the same API endpoint as the UI.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Node/Express app on port `3045`
|
||||||
|
- Static SPA in `public/`
|
||||||
|
- Appwrite TablesDB persistence
|
||||||
|
- Docker deploy on Unraid
|
||||||
|
- Gitea remote repo
|
||||||
|
- Nginx Proxy Manager routes `rank.friborg.uk` → `192.168.30.100:3045`
|
||||||
|
|
||||||
|
## Appwrite schema
|
||||||
|
|
||||||
|
Database: `priority_rank`
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
|
||||||
|
- `ideas` — title, description, source, sourceName, milestoneId, impact, effort, confidence, urgency, score, rank, labels, notes, archived
|
||||||
|
- `milestones` — name, description, horizon, color, position, active
|
||||||
|
- `activity` — small append-only UX feed
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup:appwrite
|
||||||
|
npm run check
|
||||||
|
PORT=3045 node server.js
|
||||||
|
npm run smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent idea post:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://rank.friborg.uk/api/ideas \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "Authorization: Bearer $RANK_AGENT_TOKEN" \
|
||||||
|
-d '{"title":"Add public roadmap export","source":"agent","sourceName":"Rook","impact":8,"effort":3,"confidence":7,"urgency":5}'
|
||||||
|
```
|
||||||
Generated
+892
@@ -0,0 +1,892 @@
|
|||||||
|
{
|
||||||
|
"name": "rank.friborg.uk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "rank.friborg.uk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"node-appwrite": "^25.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/bignumber.js": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/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/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/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/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"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/json-bigint": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/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/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/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/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/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/node-appwrite": {
|
||||||
|
"version": "25.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-25.0.0.tgz",
|
||||||
|
"integrity": "sha512-KpZ/3Ed8euz6r5CwjquElX3wRkNuiRuRQqjROiHK+feZ2ZX8HjjcF5IwrjTJYSNaYrmIwsZoex4L0ezzWjYWFg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"json-bigint": "1.0.0",
|
||||||
|
"node-fetch-native-with-agent": "1.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch-native-with-agent": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"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/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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/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/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"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/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/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"content-type": "^2.0.0",
|
||||||
|
"media-typer": "^1.1.0",
|
||||||
|
"mime-types": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is/node_modules/content-type": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "rank.friborg.uk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node server.js",
|
||||||
|
"check": "node --check server.js && node --check scripts/setup-appwrite.mjs && node --check public/app.js",
|
||||||
|
"setup:appwrite": "node scripts/setup-appwrite.mjs",
|
||||||
|
"smoke": "node scripts/smoke.mjs"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"node-appwrite": "^25.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
const state = {
|
||||||
|
ideas: [],
|
||||||
|
milestones: [],
|
||||||
|
activity: [],
|
||||||
|
filter: 'all',
|
||||||
|
search: '',
|
||||||
|
selected: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||||
|
const board = $('#board');
|
||||||
|
const form = $('#ideaForm');
|
||||||
|
const detail = $('#detail');
|
||||||
|
const detailForm = $('#detailForm');
|
||||||
|
const milestoneSelect = $('#milestoneSelect');
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (!res.ok) throw new Error(data?.error || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreOf(idea) { return Number(idea.score || 0).toFixed(1); }
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch]));
|
||||||
|
}
|
||||||
|
function short(text, len = 126) {
|
||||||
|
const value = String(text || '').trim();
|
||||||
|
return value.length > len ? `${value.slice(0, len - 1)}…` : value;
|
||||||
|
}
|
||||||
|
function milestoneFor(id) { return state.milestones.find(m => m.id === id) || state.milestones[0]; }
|
||||||
|
function sourceKind(idea) { return /agent|rook|iris|eve|claude|gpt|bot/i.test(`${idea.source} ${idea.sourceName}`) ? 'agent' : 'human'; }
|
||||||
|
function toast(message) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast';
|
||||||
|
el.textContent = message;
|
||||||
|
document.body.append(el);
|
||||||
|
setTimeout(() => el.remove(), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredIdeas() {
|
||||||
|
const q = state.search.toLowerCase();
|
||||||
|
return state.ideas.filter(idea => {
|
||||||
|
if (state.filter === 'human' && sourceKind(idea) !== 'human') return false;
|
||||||
|
if (state.filter === 'agent' && sourceKind(idea) !== 'agent') return false;
|
||||||
|
if (state.filter === 'high' && Number(idea.score) < 4) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return [idea.title, idea.description, idea.sourceName, ...(idea.labels || [])].join(' ').toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
const ideas = state.ideas;
|
||||||
|
const now = ideas.filter(i => i.milestoneId === 'now').length;
|
||||||
|
const agent = ideas.filter(i => sourceKind(i) === 'agent').length;
|
||||||
|
const top = ideas.slice().sort((a, b) => b.score - a.score)[0];
|
||||||
|
$('#stats').innerHTML = [
|
||||||
|
['Ideas', ideas.length],
|
||||||
|
['Now', now],
|
||||||
|
['Agent drops', agent],
|
||||||
|
['Top score', top ? scoreOf(top) : '—'],
|
||||||
|
].map(([label, value]) => `<div class="stat"><strong>${value}</strong><span>${label}</span></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMilestoneOptions() {
|
||||||
|
milestoneSelect.innerHTML = state.milestones.map(m => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name)}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivity() {
|
||||||
|
$('#activity').innerHTML = state.activity.slice(0, 8).map(item => `<span class="activity-item">${escapeHtml(short(item.message, 52))}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
renderStats();
|
||||||
|
renderMilestoneOptions();
|
||||||
|
renderActivity();
|
||||||
|
const ideas = filteredIdeas();
|
||||||
|
board.innerHTML = state.milestones.map(milestone => {
|
||||||
|
const laneIdeas = ideas
|
||||||
|
.filter(idea => (idea.milestoneId || 'inbox') === milestone.id)
|
||||||
|
.sort((a, b) => (a.rank - b.rank) || (b.score - a.score));
|
||||||
|
const cards = laneIdeas.map(cardHtml).join('') || '<div class="empty">Drop something here</div>';
|
||||||
|
return `<article class="lane" data-milestone="${escapeHtml(milestone.id)}" style="--lane-color:${escapeHtml(milestone.color)}">
|
||||||
|
<div class="lane-head">
|
||||||
|
<div class="lane-title"><h2>${escapeHtml(milestone.name)}</h2><strong>${laneIdeas.length}</strong></div>
|
||||||
|
<p>${escapeHtml(milestone.description || milestone.horizon || '')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="cards">${cards}</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
bindDrag();
|
||||||
|
bindCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(idea) {
|
||||||
|
const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5);
|
||||||
|
return `<article class="card" draggable="true" data-id="${escapeHtml(idea.id)}">
|
||||||
|
<div class="card-top"><h3>${escapeHtml(idea.title)}</h3><div class="score">${scoreOf(idea)}</div></div>
|
||||||
|
${idea.description ? `<p>${escapeHtml(short(idea.description))}</p>` : ''}
|
||||||
|
<div class="meta">${tags.map(t => `<span class="pill">${escapeHtml(t)}</span>`).join('')}</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<span>I <b>${idea.impact}</b></span><span>E <b>${idea.effort}</b></span><span>C <b>${idea.confidence}</b></span><span>U <b>${idea.urgency}</b></span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCards() {
|
||||||
|
$$('.card').forEach(card => card.addEventListener('click', () => openDetail(card.dataset.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDrag() {
|
||||||
|
$$('.card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', event => {
|
||||||
|
card.classList.add('dragging');
|
||||||
|
event.dataTransfer.setData('text/plain', card.dataset.id);
|
||||||
|
});
|
||||||
|
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||||||
|
});
|
||||||
|
$$('.lane').forEach(lane => {
|
||||||
|
lane.addEventListener('dragover', event => { event.preventDefault(); lane.classList.add('drag-over'); });
|
||||||
|
lane.addEventListener('dragleave', () => lane.classList.remove('drag-over'));
|
||||||
|
lane.addEventListener('drop', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
lane.classList.remove('drag-over');
|
||||||
|
const id = event.dataTransfer.getData('text/plain');
|
||||||
|
const idea = state.ideas.find(i => i.id === id);
|
||||||
|
const milestoneId = lane.dataset.milestone;
|
||||||
|
if (!idea || idea.milestoneId === milestoneId) return;
|
||||||
|
idea.milestoneId = milestoneId;
|
||||||
|
idea.rank = Date.now() % 100000;
|
||||||
|
renderBoard();
|
||||||
|
try {
|
||||||
|
const updated = await api(`/api/ideas/${id}`, { method: 'PATCH', body: { milestoneId, rank: idea.rank } });
|
||||||
|
replaceIdea(updated);
|
||||||
|
toast(`Moved to ${milestoneFor(milestoneId).name}`);
|
||||||
|
} catch (error) { toast(error.message); await load(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceIdea(idea) {
|
||||||
|
const idx = state.ideas.findIndex(i => i.id === idea.id);
|
||||||
|
if (idx >= 0) state.ideas[idx] = idea;
|
||||||
|
else state.ideas.unshift(idea);
|
||||||
|
state.ideas.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(id) {
|
||||||
|
const idea = state.ideas.find(i => i.id === id);
|
||||||
|
if (!idea) return;
|
||||||
|
state.selected = idea.id;
|
||||||
|
detailForm.title.value = idea.title;
|
||||||
|
detailForm.description.value = idea.description || '';
|
||||||
|
detailForm.impact.value = idea.impact;
|
||||||
|
detailForm.effort.value = idea.effort;
|
||||||
|
detailForm.confidence.value = idea.confidence;
|
||||||
|
detailForm.urgency.value = idea.urgency;
|
||||||
|
detailForm.notes.value = idea.notes || '';
|
||||||
|
detail.classList.add('open');
|
||||||
|
detail.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
function closeDetail() {
|
||||||
|
detail.classList.remove('open');
|
||||||
|
detail.setAttribute('aria-hidden', 'true');
|
||||||
|
state.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const payload = Object.fromEntries(fd.entries());
|
||||||
|
payload.labels = String(payload.labels || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
payload.source = payload.sourceName ? sourceKind({ sourceName: payload.sourceName }) : 'human';
|
||||||
|
payload.status = payload.milestoneId === 'inbox' ? 'inbox' : 'planned';
|
||||||
|
try {
|
||||||
|
const idea = await api('/api/ideas', { method: 'POST', body: payload });
|
||||||
|
replaceIdea(idea);
|
||||||
|
form.reset();
|
||||||
|
form.impact.value = 7; form.effort.value = 4; form.confidence.value = 6; form.urgency.value = 5;
|
||||||
|
renderBoard();
|
||||||
|
toast('Captured');
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
detailForm.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!state.selected) return;
|
||||||
|
const payload = Object.fromEntries(new FormData(detailForm).entries());
|
||||||
|
try {
|
||||||
|
const idea = await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: payload });
|
||||||
|
replaceIdea(idea);
|
||||||
|
closeDetail();
|
||||||
|
renderBoard();
|
||||||
|
toast('Saved');
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#archiveIdea').addEventListener('click', async () => {
|
||||||
|
if (!state.selected) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: { archived: true } });
|
||||||
|
state.ideas = state.ideas.filter(i => i.id !== state.selected);
|
||||||
|
closeDetail();
|
||||||
|
renderBoard();
|
||||||
|
toast('Archived');
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#closeDetail').addEventListener('click', closeDetail);
|
||||||
|
$('#refresh').addEventListener('click', load);
|
||||||
|
$('#search').addEventListener('input', event => { state.search = event.target.value; renderBoard(); });
|
||||||
|
$('#filters').addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('button[data-filter]');
|
||||||
|
if (!button) return;
|
||||||
|
state.filter = button.dataset.filter;
|
||||||
|
$$('#filters button').forEach(b => b.classList.toggle('active', b === button));
|
||||||
|
renderBoard();
|
||||||
|
});
|
||||||
|
$('#addMilestone').addEventListener('click', async () => {
|
||||||
|
const name = prompt('Milestone name');
|
||||||
|
if (!name) return;
|
||||||
|
const horizon = prompt('Horizon / timing', 'Custom') || '';
|
||||||
|
const colors = ['#8cf7ff', '#f8ff73', '#a78bfa', '#6ee7b7', '#ff5e7a'];
|
||||||
|
try {
|
||||||
|
const milestone = await api('/api/milestones', { method: 'POST', body: { name, horizon, color: colors[state.milestones.length % colors.length], position: state.milestones.length * 10 } });
|
||||||
|
state.milestones.push(milestone);
|
||||||
|
renderBoard();
|
||||||
|
toast('Milestone added');
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
|
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
||||||
|
event.preventDefault();
|
||||||
|
$('#title').focus();
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') closeDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await api('/api/bootstrap');
|
||||||
|
state.ideas = data.ideas || [];
|
||||||
|
state.milestones = data.milestones || [];
|
||||||
|
state.activity = data.activity || [];
|
||||||
|
renderBoard();
|
||||||
|
} catch (error) {
|
||||||
|
board.innerHTML = `<div class="empty">Backend is grumpy: ${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#050712" />
|
||||||
|
<title>Rank — Feature Priorities</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=rank-1" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="noise"></div>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">rank.friborg.uk · feature triage</p>
|
||||||
|
<h1>Drop ideas. Score fast. Drag into reality.</h1>
|
||||||
|
<p class="subcopy">A sharp prioritization board for humans and agents. No ceremony, no rounded-corner startup soup.</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="capture-panel" aria-label="Quick capture">
|
||||||
|
<form id="ideaForm" autocomplete="off">
|
||||||
|
<div class="capture-main">
|
||||||
|
<label for="title">Idea</label>
|
||||||
|
<input id="title" name="title" maxlength="180" placeholder="Press / then type the feature that keeps nagging at you" required />
|
||||||
|
</div>
|
||||||
|
<div class="capture-grid">
|
||||||
|
<label>Description<textarea id="description" name="description" rows="2" placeholder="Optional: what it does, why it matters, ugly constraints…"></textarea></label>
|
||||||
|
<label>Labels<input id="labels" name="labels" placeholder="scattermind, revenue, ux" /></label>
|
||||||
|
<label>Source<input id="sourceName" name="sourceName" placeholder="Jimmi, Rook, Iris…" /></label>
|
||||||
|
<label>Milestone<select id="milestoneSelect" name="milestoneId"></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="score-row">
|
||||||
|
<label>Impact <input type="range" min="0" max="10" value="7" name="impact" /></label>
|
||||||
|
<label>Effort <input type="range" min="1" max="10" value="4" name="effort" /></label>
|
||||||
|
<label>Confidence <input type="range" min="0" max="10" value="6" name="confidence" /></label>
|
||||||
|
<label>Urgency <input type="range" min="0" max="10" value="5" name="urgency" /></label>
|
||||||
|
<button type="submit">Capture ↵</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toolbar">
|
||||||
|
<div class="tabs" id="filters">
|
||||||
|
<button data-filter="all" class="active">All</button>
|
||||||
|
<button data-filter="human">Human</button>
|
||||||
|
<button data-filter="agent">Agent</button>
|
||||||
|
<button data-filter="high">High score</button>
|
||||||
|
</div>
|
||||||
|
<div class="tools">
|
||||||
|
<input id="search" placeholder="Filter ideas" />
|
||||||
|
<button id="addMilestone">+ Milestone</button>
|
||||||
|
<button id="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="board" class="board" aria-label="Priority board"></section>
|
||||||
|
|
||||||
|
<aside class="detail" id="detail" aria-hidden="true">
|
||||||
|
<form id="detailForm">
|
||||||
|
<div class="detail-head">
|
||||||
|
<p class="eyebrow">selected idea</p>
|
||||||
|
<button type="button" id="closeDetail">×</button>
|
||||||
|
</div>
|
||||||
|
<input name="title" class="detail-title" />
|
||||||
|
<textarea name="description" rows="8" placeholder="Description"></textarea>
|
||||||
|
<div class="detail-sliders">
|
||||||
|
<label>Impact <input type="number" min="0" max="10" name="impact" /></label>
|
||||||
|
<label>Effort <input type="number" min="1" max="10" name="effort" /></label>
|
||||||
|
<label>Confidence <input type="number" min="0" max="10" name="confidence" /></label>
|
||||||
|
<label>Urgency <input type="number" min="0" max="10" name="urgency" /></label>
|
||||||
|
</div>
|
||||||
|
<label>Notes<textarea name="notes" rows="5" placeholder="Decision notes, objections, cut lines…"></textarea></label>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" id="archiveIdea">Archive</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div id="activity"></div>
|
||||||
|
<p>Keyboard: <kbd>/</kbd> capture · <kbd>Esc</kbd> close · drag cards between milestones.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script src="/app.js?v=rank-1" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #050712;
|
||||||
|
--panel: rgba(9, 14, 31, .74);
|
||||||
|
--panel-strong: rgba(13, 22, 45, .92);
|
||||||
|
--line: rgba(159, 231, 255, .22);
|
||||||
|
--line-hot: rgba(248, 255, 115, .55);
|
||||||
|
--text: #eff8ff;
|
||||||
|
--muted: #8fa4b8;
|
||||||
|
--cyan: #8cf7ff;
|
||||||
|
--yellow: #f8ff73;
|
||||||
|
--violet: #a78bfa;
|
||||||
|
--green: #6ee7b7;
|
||||||
|
--danger: #ff5e7a;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 8%, rgba(140, 247, 255, .22), transparent 31rem),
|
||||||
|
radial-gradient(circle at 76% 6%, rgba(167, 139, 250, .20), transparent 34rem),
|
||||||
|
linear-gradient(135deg, #050712 0%, #09111f 46%, #03040a 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
button, input, textarea, select { font: inherit; border-radius: 0; }
|
||||||
|
button { cursor: pointer; color: var(--text); background: #101a31; border: 1px solid var(--line); text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; font-weight: 800; transition: .16s ease; }
|
||||||
|
button:hover { border-color: var(--yellow); box-shadow: 0 0 28px rgba(248,255,115,.16); transform: translateY(-1px); }
|
||||||
|
input, textarea, select { width: 100%; background: rgba(1, 4, 11, .72); border: 1px solid var(--line); color: var(--text); padding: .8rem .9rem; outline: none; }
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--cyan); box-shadow: 0 0 0 1px rgba(140, 247, 255, .16), 0 0 30px rgba(140, 247, 255, .09); }
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
label { display: grid; gap: .42rem; color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 800; }
|
||||||
|
kbd { border: 1px solid var(--line); padding: .08rem .32rem; background: rgba(255,255,255,.05); }
|
||||||
|
.noise { pointer-events: none; position: fixed; inset: 0; opacity: .12; mix-blend-mode: screen; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px); background-size: 32px 32px; mask-image: radial-gradient(circle at 50% 0%, black, transparent 80%); }
|
||||||
|
.shell { width: min(1720px, calc(100vw - 32px)); margin: 0 auto; padding: 34px 0 60px; position: relative; }
|
||||||
|
.hero { display: grid; grid-template-columns: 1fr auto; gap: 2rem; align-items: end; margin-bottom: 24px; }
|
||||||
|
.eyebrow { color: var(--cyan); text-transform: uppercase; letter-spacing: .22em; font-size: .72rem; font-weight: 900; margin: 0 0 .7rem; }
|
||||||
|
h1 { font-size: clamp(2.6rem, 5vw, 6.8rem); line-height: .86; letter-spacing: -.07em; margin: 0; max-width: 1050px; text-transform: uppercase; }
|
||||||
|
.subcopy { max-width: 760px; color: var(--muted); font-size: 1.02rem; line-height: 1.55; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(22px); min-width: 320px; }
|
||||||
|
.stat { padding: 1rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||||
|
.stat:nth-child(2n) { border-right: 0; }
|
||||||
|
.stat strong { display: block; font-size: 2.1rem; letter-spacing: -.05em; }
|
||||||
|
.stat span { color: var(--muted); text-transform: uppercase; font-size: .68rem; letter-spacing: .12em; }
|
||||||
|
.capture-panel, .toolbar, .lane, .detail, footer { border: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(24px); box-shadow: 0 28px 120px rgba(0,0,0,.22); }
|
||||||
|
.capture-panel { padding: 16px; margin-bottom: 16px; position: sticky; top: 8px; z-index: 5; }
|
||||||
|
.capture-main { display: grid; grid-template-columns: 72px 1fr; align-items: center; gap: 12px; }
|
||||||
|
.capture-main input { font-size: 1.25rem; font-weight: 850; letter-spacing: -.02em; padding: 1rem; }
|
||||||
|
.capture-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 12px; margin-top: 12px; }
|
||||||
|
.score-row { display: grid; grid-template-columns: repeat(4, 1fr) 160px; gap: 12px; align-items: end; margin-top: 12px; }
|
||||||
|
.score-row button { height: 49px; background: linear-gradient(90deg, rgba(140,247,255,.18), rgba(248,255,115,.2)); border-color: var(--line-hot); }
|
||||||
|
input[type="range"] { accent-color: var(--yellow); padding: 0; height: 32px; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 10px; margin-bottom: 16px; }
|
||||||
|
.tabs, .tools { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.tabs button.active { background: var(--yellow); color: #050712; border-color: var(--yellow); }
|
||||||
|
.tools input { min-width: 260px; }
|
||||||
|
.board { display: grid; grid-template-columns: repeat(4, minmax(280px, 1fr)); gap: 16px; align-items: start; }
|
||||||
|
.lane { min-height: 520px; position: relative; overflow: hidden; }
|
||||||
|
.lane::before { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: var(--lane-color, var(--cyan)); box-shadow: 0 0 32px var(--lane-color, var(--cyan)); }
|
||||||
|
.lane.drag-over { border-color: var(--yellow); box-shadow: 0 0 0 1px rgba(248,255,115,.3), 0 30px 120px rgba(248,255,115,.10); }
|
||||||
|
.lane-head { padding: 16px; border-bottom: 1px solid var(--line); display: grid; gap: .45rem; }
|
||||||
|
.lane-title { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
||||||
|
.lane-title h2 { margin: 0; text-transform: uppercase; letter-spacing: -.04em; font-size: 1.55rem; }
|
||||||
|
.lane-title strong { color: var(--lane-color, var(--cyan)); font-size: 1.8rem; }
|
||||||
|
.lane-head p { margin: 0; color: var(--muted); font-size: .85rem; min-height: 2.4em; }
|
||||||
|
.cards { display: grid; gap: 10px; padding: 12px; }
|
||||||
|
.card { border: 1px solid rgba(255,255,255,.12); background: linear-gradient(145deg, rgba(255,255,255,.07), rgba(255,255,255,.025)); padding: 12px; display: grid; gap: 10px; cursor: grab; position: relative; }
|
||||||
|
.card:hover { border-color: var(--cyan); background: linear-gradient(145deg, rgba(140,247,255,.11), rgba(255,255,255,.03)); }
|
||||||
|
.card:active { cursor: grabbing; }
|
||||||
|
.card.dragging { opacity: .35; }
|
||||||
|
.card-top { display: flex; justify-content: space-between; gap: 10px; align-items: start; }
|
||||||
|
.card h3 { margin: 0; font-size: 1rem; line-height: 1.15; letter-spacing: -.02em; }
|
||||||
|
.score { display: grid; place-items: center; min-width: 48px; height: 38px; border: 1px solid var(--line-hot); color: var(--yellow); font-weight: 950; background: rgba(248,255,115,.06); }
|
||||||
|
.card p { margin: 0; color: var(--muted); font-size: .84rem; line-height: 1.35; }
|
||||||
|
.meta { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pill { border: 1px solid rgba(255,255,255,.13); padding: .2rem .38rem; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid rgba(255,255,255,.1); padding-top: 8px; gap: 6px; }
|
||||||
|
.metrics span { color: var(--muted); font-size: .64rem; text-transform: uppercase; }
|
||||||
|
.metrics b { display: block; color: var(--text); font-size: .9rem; }
|
||||||
|
.detail { position: fixed; z-index: 20; top: 0; right: 0; height: 100vh; width: min(520px, 100vw); padding: 18px; transform: translateX(105%); transition: transform .18s ease; }
|
||||||
|
.detail.open { transform: translateX(0); }
|
||||||
|
.detail-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.detail-head button { width: 40px; height: 40px; font-size: 1.4rem; }
|
||||||
|
.detail form { display: grid; gap: 12px; }
|
||||||
|
.detail-title { font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; }
|
||||||
|
.detail-sliders { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.detail-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
#archiveIdea { border-color: rgba(255,94,122,.5); color: #ffd5dd; }
|
||||||
|
footer { margin-top: 16px; padding: 14px; display: grid; grid-template-columns: 1fr auto; gap: 1rem; color: var(--muted); font-size: .82rem; }
|
||||||
|
#activity { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.activity-item { border: 1px solid rgba(255,255,255,.1); padding: .32rem .48rem; }
|
||||||
|
.empty { border: 1px dashed rgba(255,255,255,.14); color: var(--muted); padding: 1.2rem; text-align: center; }
|
||||||
|
.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 50; background: #f8ff73; color: #050712; padding: .8rem 1rem; border: 1px solid #fff; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.hero { grid-template-columns: 1fr; }
|
||||||
|
.stats { min-width: 0; }
|
||||||
|
.board { grid-template-columns: repeat(2, minmax(280px, 1fr)); }
|
||||||
|
.capture-grid, .score-row { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.shell { width: min(100vw - 18px, 100%); padding-top: 16px; }
|
||||||
|
.board, .capture-grid, .score-row, .capture-main, footer { grid-template-columns: 1fr; }
|
||||||
|
.toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.tabs, .tools { width: 100%; overflow-x: auto; }
|
||||||
|
.tools input { min-width: 180px; }
|
||||||
|
.capture-panel { position: relative; top: auto; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Client, TablesDB, ID, Query, TablesDBIndexType } from 'node-appwrite';
|
||||||
|
|
||||||
|
const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL || process.env.APPWRITE_LOCAL_ENDPOINT;
|
||||||
|
const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID;
|
||||||
|
const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY || process.env.APPWRITE_LOCAL_API_KEY;
|
||||||
|
const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank';
|
||||||
|
const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas';
|
||||||
|
const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones';
|
||||||
|
const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity';
|
||||||
|
|
||||||
|
if (!endpoint || !projectId || !apiKey) throw new Error('Missing Appwrite endpoint/project/key env');
|
||||||
|
|
||||||
|
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
|
||||||
|
const tables = new TablesDB(client);
|
||||||
|
|
||||||
|
async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
||||||
|
function isNotFound(error) { return error?.code === 404 || /not found/i.test(error?.message || ''); }
|
||||||
|
function isConflict(error) { return error?.code === 409 || /already exists|conflict/i.test(error?.message || ''); }
|
||||||
|
|
||||||
|
async function ensureDatabase() {
|
||||||
|
try {
|
||||||
|
await tables.get({ databaseId });
|
||||||
|
console.log(`database exists: ${databaseId}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFound(error)) throw error;
|
||||||
|
await tables.create({ databaseId, name: 'Priority Rank', enabled: true });
|
||||||
|
console.log(`database created: ${databaseId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTable(tableId, name) {
|
||||||
|
try {
|
||||||
|
await tables.getTable({ databaseId, tableId });
|
||||||
|
console.log(`table exists: ${tableId}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFound(error)) throw error;
|
||||||
|
await tables.createTable({ databaseId, tableId, name, rowSecurity: false, enabled: true });
|
||||||
|
console.log(`table created: ${tableId}`);
|
||||||
|
await waitForTable(tableId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTable(tableId) {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const table = await tables.getTable({ databaseId, tableId });
|
||||||
|
if (!table.status || table.status === 'available' || table.enabled) return table;
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForColumn(tableId, key) {
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
const column = await tables.getColumn({ databaseId, tableId, key });
|
||||||
|
if (!column.status || column.status === 'available') return column;
|
||||||
|
if (column.status === 'failed') throw new Error(`Column ${tableId}.${key} failed`);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureColumn(tableId, key, create) {
|
||||||
|
try {
|
||||||
|
await tables.getColumn({ databaseId, tableId, key });
|
||||||
|
return console.log(`column exists: ${tableId}.${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFound(error)) throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await create();
|
||||||
|
console.log(`column created: ${tableId}.${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isConflict(error)) throw error;
|
||||||
|
}
|
||||||
|
await waitForColumn(tableId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIndex(tableId, key, type, columns, orders = undefined) {
|
||||||
|
try {
|
||||||
|
await tables.getIndex({ databaseId, tableId, key });
|
||||||
|
return console.log(`index exists: ${tableId}.${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFound(error)) throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tables.createIndex({ databaseId, tableId, key, type, columns, orders });
|
||||||
|
console.log(`index created: ${tableId}.${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isConflict(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const varchar = (tableId, key, size, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createVarcharColumn({ databaseId, tableId, key, size, required, xdefault }));
|
||||||
|
const text = (tableId, key, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createTextColumn({ databaseId, tableId, key, required, xdefault }));
|
||||||
|
const integer = (tableId, key, required = false, min = undefined, max = undefined, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createIntegerColumn({ databaseId, tableId, key, required, min, max, xdefault }));
|
||||||
|
const floatCol = (tableId, key, required = false, min = undefined, max = undefined, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createFloatColumn({ databaseId, tableId, key, required, min, max, xdefault }));
|
||||||
|
const bool = (tableId, key, required = false, xdefault = undefined) => ensureColumn(tableId, key, () => tables.createBooleanColumn({ databaseId, tableId, key, required, xdefault }));
|
||||||
|
|
||||||
|
async function seedMilestones() {
|
||||||
|
const existing = await tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.limit(1)] });
|
||||||
|
const rows = existing.rows || existing.documents || [];
|
||||||
|
if (rows.length) return console.log('milestones already seeded');
|
||||||
|
const seed = [
|
||||||
|
{ name: 'Inbox', description: 'Raw captures waiting for judgement.', horizon: 'Unsorted', color: '#8cf7ff', position: 0, active: true },
|
||||||
|
{ name: 'Now', description: 'Highest leverage work. Do not let this lane become a landfill.', horizon: 'This sprint', color: '#f8ff73', position: 10, active: true },
|
||||||
|
{ name: 'Next', description: 'Strong ideas after the current push.', horizon: 'Soon', color: '#a78bfa', position: 20, active: true },
|
||||||
|
{ name: 'Later', description: 'Useful but not urgent.', horizon: 'Backlog', color: '#6ee7b7', position: 30, active: true },
|
||||||
|
];
|
||||||
|
for (const row of seed) await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: row.name.toLowerCase(), data: row });
|
||||||
|
console.log('seeded milestones');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDatabase();
|
||||||
|
await ensureTable(ideasTableId, 'Ideas');
|
||||||
|
await ensureTable(milestonesTableId, 'Milestones');
|
||||||
|
await ensureTable(activityTableId, 'Activity');
|
||||||
|
|
||||||
|
await varchar(ideasTableId, 'title', 180, true);
|
||||||
|
await text(ideasTableId, 'description', false);
|
||||||
|
await varchar(ideasTableId, 'source', 40, false, 'human');
|
||||||
|
await varchar(ideasTableId, 'sourceName', 80, false);
|
||||||
|
await varchar(ideasTableId, 'status', 40, false, 'inbox');
|
||||||
|
await varchar(ideasTableId, 'milestoneId', 64, false, 'inbox');
|
||||||
|
await integer(ideasTableId, 'impact', false, 0, 10, 5);
|
||||||
|
await integer(ideasTableId, 'effort', false, 1, 10, 5);
|
||||||
|
await integer(ideasTableId, 'confidence', false, 0, 10, 6);
|
||||||
|
await integer(ideasTableId, 'urgency', false, 0, 10, 5);
|
||||||
|
await floatCol(ideasTableId, 'score', false, 0, 999, 0);
|
||||||
|
await integer(ideasTableId, 'rank', false, -100000, 100000, 0);
|
||||||
|
await varchar(ideasTableId, 'labels', 768, false, '[]');
|
||||||
|
await text(ideasTableId, 'notes', false);
|
||||||
|
await bool(ideasTableId, 'archived', false, false);
|
||||||
|
|
||||||
|
await varchar(milestonesTableId, 'name', 80, true);
|
||||||
|
await text(milestonesTableId, 'description', false);
|
||||||
|
await varchar(milestonesTableId, 'horizon', 80, false);
|
||||||
|
await varchar(milestonesTableId, 'color', 24, false, '#8cf7ff');
|
||||||
|
await integer(milestonesTableId, 'position', false, -10000, 10000, 0);
|
||||||
|
await bool(milestonesTableId, 'active', false, true);
|
||||||
|
|
||||||
|
await varchar(activityTableId, 'type', 80, true);
|
||||||
|
await varchar(activityTableId, 'message', 300, true);
|
||||||
|
await varchar(activityTableId, 'ideaId', 64, false);
|
||||||
|
await varchar(activityTableId, 'meta', 800, false);
|
||||||
|
|
||||||
|
await ensureIndex(ideasTableId, 'score_rank', TablesDBIndexType.Key, ['score', 'rank'], ['DESC', 'ASC']).catch(e => console.warn('index score_rank skipped:', e.message));
|
||||||
|
await ensureIndex(ideasTableId, 'milestone_rank', TablesDBIndexType.Key, ['milestoneId', 'rank'], ['ASC', 'ASC']).catch(e => console.warn('index milestone_rank skipped:', e.message));
|
||||||
|
await ensureIndex(milestonesTableId, 'position', TablesDBIndexType.Key, ['position'], ['ASC']).catch(e => console.warn('index milestone position skipped:', e.message));
|
||||||
|
|
||||||
|
await seedMilestones();
|
||||||
|
console.log(JSON.stringify({ ok: true, endpoint, projectId, databaseId, ideasTableId, milestonesTableId, activityTableId }, null, 2));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const base = process.env.RANK_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3045}`;
|
||||||
|
const token = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || '';
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
async function req(path, options = {}) {
|
||||||
|
const res = await fetch(`${base}${path}`, { headers: { ...headers, ...(options.headers || {}) }, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body });
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${path}: ${data?.error || text}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await req('/api/health');
|
||||||
|
assert.equal(health.ok, true, 'health ok');
|
||||||
|
const created = await req('/api/ideas', { method: 'POST', body: { title: `Smoke test ${new Date().toISOString()}`, description: 'Automated persistence check. Safe to archive.', source: 'agent', sourceName: 'smoke', labels: ['smoke'], impact: 3, effort: 1, confidence: 8, urgency: 1 } });
|
||||||
|
assert.ok(created.id, 'created id');
|
||||||
|
const updated = await req(`/api/ideas/${created.id}`, { method: 'PATCH', body: { milestoneId: 'later', notes: 'Verified create + update path.' } });
|
||||||
|
assert.equal(updated.milestoneId, 'later');
|
||||||
|
await req(`/api/ideas/${created.id}`, { method: 'PATCH', body: { archived: true } });
|
||||||
|
const boot = await req('/api/bootstrap');
|
||||||
|
assert.ok(Array.isArray(boot.ideas), 'ideas list');
|
||||||
|
console.log(JSON.stringify({ ok: true, created: created.id, archived: true, count: boot.ideas.length }, null, 2));
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { Client, TablesDB, ID, Query } from 'node-appwrite';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = Number(process.env.PORT || 3045);
|
||||||
|
const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_LOCAL_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL;
|
||||||
|
const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID;
|
||||||
|
const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_LOCAL_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY;
|
||||||
|
const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank';
|
||||||
|
const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas';
|
||||||
|
const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones';
|
||||||
|
const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity';
|
||||||
|
const agentToken = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || '';
|
||||||
|
const appVersion = process.env.APP_VERSION || 'rank-local';
|
||||||
|
|
||||||
|
if (!endpoint || !projectId || !apiKey) {
|
||||||
|
console.warn('[rank] Missing Appwrite configuration; /api/health will report degraded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client();
|
||||||
|
if (endpoint) client.setEndpoint(endpoint);
|
||||||
|
if (projectId) client.setProject(projectId);
|
||||||
|
if (apiKey) client.setKey(apiKey);
|
||||||
|
const tables = new TablesDB(client);
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '256kb' }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
|
etag: true,
|
||||||
|
maxAge: process.env.NODE_ENV === 'production' ? '10m' : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function clampInt(value, fallback, min = 0, max = 10) {
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value, max = 1000) {
|
||||||
|
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanMultiline(value, max = 6000) {
|
||||||
|
return String(value ?? '').replace(/\r\n/g, '\n').trim().slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreIdea({ impact, effort, confidence, urgency }) {
|
||||||
|
const i = clampInt(impact, 5);
|
||||||
|
const e = clampInt(effort, 5, 1, 10);
|
||||||
|
const c = clampInt(confidence, 5);
|
||||||
|
const u = clampInt(urgency, 5);
|
||||||
|
return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicIdea(row) {
|
||||||
|
return {
|
||||||
|
id: row.$id,
|
||||||
|
createdAt: row.$createdAt,
|
||||||
|
updatedAt: row.$updatedAt,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description || '',
|
||||||
|
source: row.source || 'human',
|
||||||
|
sourceName: row.sourceName || '',
|
||||||
|
status: row.status || 'inbox',
|
||||||
|
milestoneId: row.milestoneId || 'inbox',
|
||||||
|
impact: row.impact ?? 5,
|
||||||
|
effort: row.effort ?? 5,
|
||||||
|
confidence: row.confidence ?? 5,
|
||||||
|
urgency: row.urgency ?? 5,
|
||||||
|
score: row.score ?? 0,
|
||||||
|
rank: row.rank ?? 0,
|
||||||
|
labels: parseList(row.labels),
|
||||||
|
notes: row.notes || '',
|
||||||
|
archived: Boolean(row.archived),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMilestone(row) {
|
||||||
|
return {
|
||||||
|
id: row.$id,
|
||||||
|
createdAt: row.$createdAt,
|
||||||
|
updatedAt: row.$updatedAt,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
horizon: row.horizon || '',
|
||||||
|
color: row.color || '#8cf7ff',
|
||||||
|
position: row.position ?? 0,
|
||||||
|
active: row.active !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String).slice(0, 12) : [];
|
||||||
|
} catch {
|
||||||
|
return String(value).split(',').map(s => s.trim()).filter(Boolean).slice(0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeList(value) {
|
||||||
|
if (Array.isArray(value)) return JSON.stringify(value.map(v => cleanText(v, 32)).filter(Boolean).slice(0, 12));
|
||||||
|
return JSON.stringify(parseList(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsFrom(result) {
|
||||||
|
const rows = result?.rows || result?.documents;
|
||||||
|
if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRow(row) {
|
||||||
|
if (!row?.$id) throw new Error('Appwrite returned an invalid row response');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAgent(req, res, next) {
|
||||||
|
if (!agentToken) return next();
|
||||||
|
const header = req.get('authorization') || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : req.get('x-rank-token');
|
||||||
|
const tokenBuffer = Buffer.from(token || '');
|
||||||
|
const expectedBuffer = Buffer.from(agentToken);
|
||||||
|
if (tokenBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) return next();
|
||||||
|
return res.status(401).json({ error: 'agent token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logActivity(type, message, ideaId = '', meta = '') {
|
||||||
|
try {
|
||||||
|
await tables.createRow({
|
||||||
|
databaseId,
|
||||||
|
tableId: activityTableId,
|
||||||
|
rowId: ID.unique(),
|
||||||
|
data: { type, message: cleanText(message, 300), ideaId, meta: cleanText(meta, 800) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[rank] activity log failed', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
const health = { ok: false, app: 'rank', version: appVersion, appwriteConfigured: Boolean(endpoint && projectId && apiKey), appwriteReachable: false, tableReachable: false };
|
||||||
|
try {
|
||||||
|
if (health.appwriteConfigured) {
|
||||||
|
const probe = await tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.limit(1)] });
|
||||||
|
rowsFrom(probe);
|
||||||
|
health.appwriteReachable = true;
|
||||||
|
health.tableReachable = true;
|
||||||
|
health.ok = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
health.error = error.message;
|
||||||
|
}
|
||||||
|
res.status(health.ok ? 200 : 503).json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bootstrap', async (_req, res) => {
|
||||||
|
const [ideas, milestones, activity] = await Promise.all([
|
||||||
|
tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.equal('archived', false), Query.orderDesc('score'), Query.orderAsc('rank'), Query.limit(100)] }),
|
||||||
|
tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.equal('active', true), Query.orderAsc('position'), Query.limit(50)] }),
|
||||||
|
tables.listRows({ databaseId, tableId: activityTableId, queries: [Query.orderDesc('$createdAt'), Query.limit(18)] }).catch(() => ({ rows: [], documents: [] })),
|
||||||
|
]);
|
||||||
|
res.json({
|
||||||
|
version: appVersion,
|
||||||
|
ideas: rowsFrom(ideas).map(publicIdea),
|
||||||
|
milestones: rowsFrom(milestones).map(publicMilestone),
|
||||||
|
activity: rowsFrom(activity).map(row => ({ id: row.$id, createdAt: row.$createdAt, type: row.type, message: row.message, ideaId: row.ideaId || '' })),
|
||||||
|
scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ideas', requireAgent, async (req, res) => {
|
||||||
|
const title = cleanText(req.body.title, 180);
|
||||||
|
if (!title) return res.status(400).json({ error: 'title is required' });
|
||||||
|
const data = {
|
||||||
|
title,
|
||||||
|
description: cleanMultiline(req.body.description, 5000),
|
||||||
|
source: cleanText(req.body.source || 'human', 40),
|
||||||
|
sourceName: cleanText(req.body.sourceName || req.body.agent || '', 80),
|
||||||
|
status: cleanText(req.body.status || 'inbox', 40),
|
||||||
|
milestoneId: cleanText(req.body.milestoneId || 'inbox', 64),
|
||||||
|
impact: clampInt(req.body.impact, 5),
|
||||||
|
effort: clampInt(req.body.effort, 5, 1, 10),
|
||||||
|
confidence: clampInt(req.body.confidence, 6),
|
||||||
|
urgency: clampInt(req.body.urgency, 5),
|
||||||
|
rank: clampInt(req.body.rank, 0, -100000, 100000),
|
||||||
|
labels: encodeList(req.body.labels),
|
||||||
|
notes: cleanMultiline(req.body.notes, 4000),
|
||||||
|
archived: false,
|
||||||
|
};
|
||||||
|
data.score = scoreIdea(data);
|
||||||
|
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
|
||||||
|
await logActivity('idea.created', `Captured “${title}”`, row.$id, data.source);
|
||||||
|
res.status(201).json(publicIdea(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/ideas/:id', requireAgent, async (req, res) => {
|
||||||
|
const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived'];
|
||||||
|
const data = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (!(key in req.body)) continue;
|
||||||
|
if (['impact', 'effort', 'confidence', 'urgency', 'rank'].includes(key)) data[key] = clampInt(req.body[key], key === 'effort' ? 5 : 0, key === 'effort' ? 1 : -100000, key === 'rank' ? 100000 : 10);
|
||||||
|
else if (key === 'description' || key === 'notes') data[key] = cleanMultiline(req.body[key], key === 'description' ? 5000 : 4000);
|
||||||
|
else if (key === 'labels') data[key] = encodeList(req.body[key]);
|
||||||
|
else if (key === 'archived') data[key] = Boolean(req.body[key]);
|
||||||
|
else data[key] = cleanText(req.body[key], key === 'title' ? 180 : 80);
|
||||||
|
}
|
||||||
|
if (['impact', 'effort', 'confidence', 'urgency'].some(k => k in data)) {
|
||||||
|
const current = assertRow(await tables.getRow({ databaseId, tableId: ideasTableId, rowId: req.params.id }));
|
||||||
|
data.score = scoreIdea({ ...current, ...data });
|
||||||
|
}
|
||||||
|
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: req.params.id, data }));
|
||||||
|
await logActivity('idea.updated', `Updated “${row.title}”`, row.$id, Object.keys(data).join(','));
|
||||||
|
res.json(publicIdea(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/milestones', requireAgent, async (req, res) => {
|
||||||
|
const name = cleanText(req.body.name, 80);
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
description: cleanMultiline(req.body.description, 1000),
|
||||||
|
horizon: cleanText(req.body.horizon || '', 80),
|
||||||
|
color: cleanText(req.body.color || '#8cf7ff', 24),
|
||||||
|
position: clampInt(req.body.position, 0, -10000, 10000),
|
||||||
|
active: req.body.active !== false,
|
||||||
|
};
|
||||||
|
const row = assertRow(await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: ID.unique(), data }));
|
||||||
|
await logActivity('milestone.created', `Added milestone “${name}”`, '', row.$id);
|
||||||
|
res.status(201).json(publicMilestone(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/milestones/:id', requireAgent, async (req, res) => {
|
||||||
|
const data = {};
|
||||||
|
for (const key of ['name', 'description', 'horizon', 'color', 'position', 'active']) {
|
||||||
|
if (!(key in req.body)) continue;
|
||||||
|
if (key === 'position') data[key] = clampInt(req.body[key], 0, -10000, 10000);
|
||||||
|
else if (key === 'active') data[key] = Boolean(req.body[key]);
|
||||||
|
else if (key === 'description') data[key] = cleanMultiline(req.body[key], 1000);
|
||||||
|
else data[key] = cleanText(req.body[key], key === 'name' ? 80 : 120);
|
||||||
|
}
|
||||||
|
const row = assertRow(await tables.updateRow({ databaseId, tableId: milestonesTableId, rowId: req.params.id, data }));
|
||||||
|
await logActivity('milestone.updated', `Updated milestone “${row.name}”`, '', row.$id);
|
||||||
|
res.json(publicMilestone(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/reorder', requireAgent, async (req, res) => {
|
||||||
|
const updates = Array.isArray(req.body.updates) ? req.body.updates.slice(0, 100) : [];
|
||||||
|
const changed = [];
|
||||||
|
for (const item of updates) {
|
||||||
|
if (!item?.id) continue;
|
||||||
|
const data = {};
|
||||||
|
if ('rank' in item) data.rank = clampInt(item.rank, 0, -100000, 100000);
|
||||||
|
if ('milestoneId' in item) data.milestoneId = cleanText(item.milestoneId, 64);
|
||||||
|
if ('status' in item) data.status = cleanText(item.status, 40);
|
||||||
|
if (Object.keys(data).length) {
|
||||||
|
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: item.id, data }));
|
||||||
|
changed.push(publicIdea(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.length) await logActivity('ideas.reordered', `Re-ranked ${changed.length} item${changed.length === 1 ? '' : 's'}`);
|
||||||
|
res.json({ changed });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||||
|
|
||||||
|
app.use((error, _req, res, _next) => {
|
||||||
|
console.error('[rank]', error);
|
||||||
|
res.status(error.code && error.code >= 400 && error.code < 600 ? error.code : 500).json({ error: error.message || 'Internal error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`[rank] ${appVersion} listening on :${PORT}`));
|
||||||
Reference in New Issue
Block a user