Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
id: task-041
title: Add Semantic Cluster View to Frontend
type: task
status: in_progress
status: pull_requested
priority: P1
owner: Eric Lott
claim_owner: Eric Lott
Expand Down
15 changes: 14 additions & 1 deletion backend/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const Decision = sequelize.define('Decision', {
clusterY: { type: DataTypes.FLOAT, allowNull: true },
clusterLabel: { type: DataTypes.STRING, allowNull: true },
icon: { type: DataTypes.STRING, allowNull: true },
acceptanceCriteria: { type: DataTypes.JSON, defaultValue: [] },
technicalContext: { type: DataTypes.TEXT, allowNull: true },
dependencies: { type: DataTypes.JSON, defaultValue: [] },
priority: { type: DataTypes.STRING, allowNull: true },
options: { type: DataTypes.JSON, allowNull: true },
rawData: { type: DataTypes.JSON, defaultValue: {} },
PillarId: { type: DataTypes.INTEGER }
});

Expand All @@ -46,6 +52,12 @@ const AuditLog = sequelize.define('AuditLog', {
isAgent: { type: DataTypes.BOOLEAN, defaultValue: false }
});

const AppSettings = sequelize.define('AppSettings', {
singletonKey: { type: DataTypes.STRING, unique: true, allowNull: false, defaultValue: 'global' },
provider: { type: DataTypes.STRING, allowNull: false, defaultValue: 'mock' },
keys: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }
});

// Associations
Project.hasMany(Pillar, { onDelete: 'CASCADE' });
Pillar.belongsTo(Project);
Expand Down Expand Up @@ -76,5 +88,6 @@ module.exports = {
Pillar,
Decision,
DecisionRelationship,
AuditLog
AuditLog,
AppSettings
};
34 changes: 33 additions & 1 deletion backend/routes/projectRoutes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { Project } = require('../models');
const { Project, AppSettings } = require('../models');
const {
getProjectTree,
saveProjectState,
Expand Down Expand Up @@ -90,6 +90,38 @@ router.post('/save-state', async (req, res) => {
}
});

// App settings (persisted server-side, no local-only storage)
router.get('/settings', async (req, res) => {
try {
const [settings] = await AppSettings.findOrCreate({
where: { singletonKey: 'global' },
defaults: {
singletonKey: 'global',
provider: 'mock',
keys: { openai: '', anthropic: '', gemini: '' }
}
});
res.json({ provider: settings.provider, keys: settings.keys || {} });
} catch (err) {
res.status(500).json({ error: err.message });
}
});

router.put('/settings', async (req, res) => {
try {
const provider = typeof req.body.provider === 'string' ? req.body.provider : 'mock';
const keys = req.body.keys && typeof req.body.keys === 'object' ? req.body.keys : {};
const [settings] = await AppSettings.findOrCreate({
where: { singletonKey: 'global' },
defaults: { singletonKey: 'global', provider, keys }
});
await settings.update({ provider, keys });
res.json({ success: true, provider: settings.provider, keys: settings.keys || {} });
} catch (err) {
res.status(500).json({ error: err.message });
}
});

// Link decisions
router.post('/decisions/:id/link', async (req, res) => {
try {
Expand Down
2 changes: 1 addition & 1 deletion backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { sequelize } = db;
const projectRoutes = require('./routes/projectRoutes');
const agentRoutes = require('./routes/agentRoutes');

const models = { Project: db.Project, Pillar: db.Pillar, Decision: db.Decision, AuditLog: db.AuditLog };
const models = { Project: db.Project, Pillar: db.Pillar, Decision: db.Decision, AuditLog: db.AuditLog, AppSettings: db.AppSettings };

const app = express();
app.use(cors());
Expand Down
96 changes: 64 additions & 32 deletions backend/services/projectService.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,65 @@
const { Project, Pillar, Decision, DecisionRelationship, AuditLog, sequelize } = require('../models');

const normalizeArray = (value) => {
if (Array.isArray(value)) return value;
if (value === null || value === undefined) return [];
return [value];
};

const buildDecisionPersistenceShape = (decisionInput = {}) => {
const acceptanceCriteria = decisionInput.acceptance_criteria ?? decisionInput.acceptanceCriteria;
const technicalContext = decisionInput.technical_context ?? decisionInput.technicalContext;

return {
question: decisionInput.question,
icon: decisionInput.icon,
context: decisionInput.context,
answer: decisionInput.answer,
conflict: decisionInput.conflict,
rationale: decisionInput.rationale,
constraints: decisionInput.constraints,
tags: normalizeArray(decisionInput.tags),
acceptanceCriteria: normalizeArray(acceptanceCriteria),
technicalContext: technicalContext ?? null,
dependencies: normalizeArray(decisionInput.dependencies),
priority: decisionInput.priority ?? null,
options: Array.isArray(decisionInput.options) ? decisionInput.options : null,
rawData: decisionInput
};
};

const buildDecisionResponseShape = (decisionRecord) => {
const raw = decisionRecord.rawData && typeof decisionRecord.rawData === 'object' ? decisionRecord.rawData : {};
const linked = decisionRecord.linkedTo ? decisionRecord.linkedTo.map(lt => ({
id: lt.decisionId,
type: lt.DecisionRelationship.type,
strength: lt.DecisionRelationship.strength
})) : [];

return {
...raw,
id: decisionRecord.decisionId,
question: decisionRecord.question,
icon: decisionRecord.icon,
context: decisionRecord.context,
answer: decisionRecord.answer,
conflict: decisionRecord.conflict,
rationale: decisionRecord.rationale,
constraints: decisionRecord.constraints,
tags: Array.isArray(decisionRecord.tags) ? decisionRecord.tags : [],
acceptance_criteria: Array.isArray(decisionRecord.acceptanceCriteria)
? decisionRecord.acceptanceCriteria
: normalizeArray(raw.acceptance_criteria ?? raw.acceptanceCriteria),
technical_context: decisionRecord.technicalContext ?? raw.technical_context ?? raw.technicalContext ?? null,
dependencies: Array.isArray(decisionRecord.dependencies)
? decisionRecord.dependencies
: normalizeArray(raw.dependencies),
priority: decisionRecord.priority ?? raw.priority ?? null,
options: Array.isArray(decisionRecord.options) ? decisionRecord.options : (raw.options ?? null),
links: linked
};
};

const getProjectTree = async (projectId) => {
const project = await Project.findByPk(projectId);
if (!project) return null;
Expand All @@ -26,22 +86,7 @@ const getProjectTree = async (projectId) => {
title: p.title,
description: p.description,
icon: p.icon,
decisions: (p.Decisions || []).map(d => ({
id: d.decisionId,
question: d.question,
icon: d.icon,
context: d.context,
answer: d.answer,
conflict: d.conflict,
rationale: d.rationale,
constraints: d.constraints,
tags: d.tags,
links: d.linkedTo ? d.linkedTo.map(lt => ({
id: lt.decisionId,
type: lt.DecisionRelationship.type,
strength: lt.DecisionRelationship.strength
})) : []
})),
decisions: (p.Decisions || []).map(buildDecisionResponseShape),
subcategories: buildPillarTree(p.id)
}));
};
Expand Down Expand Up @@ -110,33 +155,20 @@ const saveProjectState = async (idea, pillars, projectId, isAgent = false) => {

if (p.decisions) {
for (const d of p.decisions) {
const persistedShape = buildDecisionPersistenceShape(d);
let decision = await Decision.findOne({
where: { decisionId: d.id, PillarId: pillar.id },
transaction: t
});

if (decision) {
await decision.update({
question: d.question,
icon: d.icon,
context: d.context,
answer: d.answer,
conflict: d.conflict,
rationale: d.rationale,
constraints: d.constraints,
tags: d.tags
...persistedShape
}, { transaction: t });
} else {
decision = await Decision.create({
decisionId: d.id,
question: d.question,
icon: d.icon,
context: d.context,
answer: d.answer,
conflict: d.conflict,
rationale: d.rationale,
constraints: d.constraints,
tags: d.tags,
...persistedShape,
PillarId: pillar.id
}, { transaction: t });
}
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/integration/api.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,22 @@ describe('Backend integration: persistence, rollback, and health', () => {
expect(response.body.database).toBe('CONNECTED');
expect(typeof response.body.timestamp).toBe('string');
});

test('persists app settings in backend database', async () => {
const saveSettings = await request(app)
.put('/api/settings')
.send({
provider: 'openai',
keys: { openai: 'sk-test', anthropic: '', gemini: '' }
});

expect(saveSettings.status).toBe(200);
expect(saveSettings.body.success).toBe(true);
expect(saveSettings.body.provider).toBe('openai');

const fetchSettings = await request(app).get('/api/settings');
expect(fetchSettings.status).toBe(200);
expect(fetchSettings.body.provider).toBe('openai');
expect(fetchSettings.body.keys.openai).toBe('sk-test');
});
});
12 changes: 11 additions & 1 deletion backend/tests/integration/task-035.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ describe('Task-035: Decision Relationships Integration', () => {
answer: 'Answer 1',
rationale: 'Because we need it.',
constraints: 'Must be fast.',
tags: ['critical', 'backend']
tags: ['critical', 'backend'],
acceptance_criteria: ['Criterion A', 'Criterion B'],
technical_context: 'Use provider SDK X.',
dependencies: ['feat_auth'],
priority: 'P0',
options: [{ id: 'opt-a', label: 'Option A', icon: null }]
}]
}]
};
Expand All @@ -45,6 +50,11 @@ describe('Task-035: Decision Relationships Integration', () => {
expect(decision.rationale).toBe('Because we need it.');
expect(decision.constraints).toBe('Must be fast.');
expect(decision.tags).toContain('critical');
expect(decision.acceptance_criteria).toEqual(['Criterion A', 'Criterion B']);
expect(decision.technical_context).toBe('Use provider SDK X.');
expect(decision.dependencies).toEqual(['feat_auth']);
expect(decision.priority).toBe('P0');
expect(decision.options).toEqual([{ id: 'opt-a', label: 'Option A', icon: null }]);
});

test('should link two decisions and retrieve their relationship', async () => {
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@ function App() {
setIsSettingsOpen,
isNotificationsOpen,
setIsNotificationsOpen,
setLlmConfig,
llmConfig,
handleNewProject,
handleSelectProject,
handleSendMessage,
handleUpdateDecision,
handleAddFeature,
handleDeleteFeature,
handleEditFeature,
handleExport
handleExport,
handleSaveLlmConfig
} = useAppLogic();

return (
Expand All @@ -54,8 +55,9 @@ function App() {

{isSettingsOpen && (
<SettingsModal
currentConfig={llmConfig}
onClose={() => setIsSettingsOpen(false)}
onSave={config => setLlmConfig(config)}
onSave={handleSaveLlmConfig}
/>
)}

Expand Down
34 changes: 17 additions & 17 deletions frontend/src/components/SettingsModal.jsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
import React, { useState } from 'react';

export default function SettingsModal({ onClose, onSave }) {
const [keys, setKeys] = useState(() => {
const saved = localStorage.getItem('cartograph_keys');
return saved ? JSON.parse(saved) : { openai: '', anthropic: '', gemini: '' };
});
const [provider, setProvider] = useState(() => {
const savedProvider = localStorage.getItem('cartograph_provider');
return savedProvider || 'mock';
});
export default function SettingsModal({ onClose, onSave, currentConfig }) {
const [keys, setKeys] = useState(currentConfig?.keys || { openai: '', anthropic: '', gemini: '' });
const [provider, setProvider] = useState(currentConfig?.provider || 'mock');
const [isSaving, setIsSaving] = useState(false);

const handleSave = () => {
localStorage.setItem('cartograph_keys', JSON.stringify(keys));
localStorage.setItem('cartograph_provider', provider);
onSave({ keys, provider });
onClose();
const handleSave = async () => {
setIsSaving(true);
try {
await onSave({ keys, provider });
onClose();
} finally {
setIsSaving(false);
}
};

return (
<div className="modal-overlay">
<div className="modal-content glass-panel">
<h2>LLM Settings (BYOK)</h2>
<p className="modal-desc">Provide your APIs keys to empower Cartograph with real AI. Keys are stored locally in your browser.</p>
<p className="modal-desc">Provide your APIs keys to empower Cartograph with real AI. Settings are persisted in the backend database.</p>

<div className="form-group">
<label>Active Provider</label>
Expand All @@ -46,8 +44,10 @@ export default function SettingsModal({ onClose, onSave }) {
)}

<div className="modal-actions">
<button className="btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn-primary" onClick={handleSave}>Save Settings</button>
<button className="btn-secondary" onClick={onClose} disabled={isSaving}>Cancel</button>
<button className="btn-primary" onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
Expand Down
Loading