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 ideaDataFromInput(input = {}, defaults = {}) { const title = cleanText(input.title || input.name, 180); if (!title) throw Object.assign(new Error('Every feature needs a title.'), { code: 400 }); const data = { title, description: cleanMultiline(input.description || input.brief || '', 5000), source: cleanText(input.source || defaults.source || 'import', 40), sourceName: cleanText(input.sourceName || input.owner || defaults.sourceName || 'Prioritix feature set', 80), status: cleanText(input.status || defaults.status || 'inbox', 40), milestoneId: cleanText(input.milestoneId || input.milestone || defaults.milestoneId || 'inbox', 64), impact: clampInt(input.impact, defaults.impact ?? 5), effort: clampInt(input.effort, defaults.effort ?? 5, 1, 10), confidence: clampInt(input.confidence, defaults.confidence ?? 6), urgency: clampInt(input.urgency, defaults.urgency ?? 5), rank: clampInt(input.rank, defaults.rank ?? 0, -100000, 100000), labels: encodeList(input.labels || input.category || input.categories || []), notes: cleanMultiline(input.notes || '', 4000), archived: Boolean(input.archived ?? false), }; data.score = scoreIdea(data); return data; } 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 data = ideaDataFromInput(req.body, { source: 'human', status: 'inbox', milestoneId: 'inbox' }); const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); await logActivity('idea.created', `Captured “${data.title}”`, row.$id, data.source); res.status(201).json(publicIdea(row)); }); app.post('/api/feature-set/import', requireAgent, async (req, res) => { const features = Array.isArray(req.body.features) ? req.body.features.slice(0, 100) : []; if (!features.length) return res.status(400).json({ error: 'Feature set must include a non-empty features array.' }); const defaults = req.body.defaults && typeof req.body.defaults === 'object' ? req.body.defaults : {}; const created = []; const errors = []; for (const [index, feature] of features.entries()) { try { const data = ideaDataFromInput(feature, { source: 'import', sourceName: req.body.name || 'Prioritix feature set', ...defaults }); const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); created.push(publicIdea(row)); } catch (error) { errors.push({ index, title: cleanText(feature?.title || feature?.name || '', 180), error: error.message }); } } if (created.length) await logActivity('feature_set.imported', `Imported ${created.length} feature${created.length === 1 ? '' : 's'}`, '', req.body.name || 'feature-set-v1'); res.status(created.length ? 201 : 400).json({ ok: errors.length === 0, imported: created.length, created, errors }); }); 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}`));