feat: per-user feedback AI prompt template with placeholder keys

This commit is contained in:
dev
2026-10-06 18:55:04 +03:00
parent 285ee84c98
commit a73b1b3f8c
8 changed files with 269 additions and 8 deletions
+71 -3
View File
@@ -1703,6 +1703,13 @@ async function ensureUserTables() {
branch_id INT NOT NULL REFERENCES branches(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, branch_id)
)`);
await pool.query(`CREATE TABLE IF NOT EXISTS user_settings (
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, key)
)`);
await pool.query(`CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -3574,6 +3581,9 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
if (key === 'lesson_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'lesson_ai_prompt длиннее 8000 символов' });
}
if (key === 'feedback_ai_prompt' && String(value).length > 8000) {
return res.status(400).json({ error: 'feedback_ai_prompt длиннее 8000 символов' });
}
if (key === 'timezone' && !validTimezone(String(value).trim())) {
return res.status(400).json({ error: 'timezone должен быть корректным часовым поясом IANA, например Europe/Moscow' });
}
@@ -3609,6 +3619,53 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
}
});
// --- User settings (персональные настройки пользователя) ---
const USER_SETTINGS_KEYS = new Set(['feedback_prompt', 'feedback_profile']);
const USER_SETTINGS_LIMITS = { feedback_prompt: 8000, feedback_profile: 100 };
async function userSettingsPayload(userId) {
const { rows } = await pool.query('SELECT key, value FROM user_settings WHERE user_id = $1', [userId]);
const settings = {};
rows.forEach(r => { if (USER_SETTINGS_KEYS.has(r.key)) settings[r.key] = r.value; });
const globalPrompt = await getSetting('feedback_ai_prompt', FEEDBACK_AI_DEFAULT_PROMPT);
return { settings, defaults: { feedback_prompt: globalPrompt || FEEDBACK_AI_DEFAULT_PROMPT } };
}
app.get('/api/user-settings', requireAuth, async (req, res) => {
res.json(await userSettingsPayload(req.user.id));
});
app.put('/api/user-settings', requireAuth, async (req, res) => {
const incoming = req.body && req.body.settings;
if (!incoming || typeof incoming !== 'object') return res.status(400).json({ error: 'settings required' });
const entries = [];
for (const [key, raw] of Object.entries(incoming)) {
if (!USER_SETTINGS_KEYS.has(key)) return res.status(400).json({ error: `Неизвестный ключ: ${key}` });
const value = raw == null ? '' : String(raw).trim();
if (value.length > USER_SETTINGS_LIMITS[key]) {
return res.status(400).json({ error: `${key} длиннее ${USER_SETTINGS_LIMITS[key]} символов` });
}
entries.push([key, value]);
}
try {
for (const [key, value] of entries) {
if (value === '') {
await pool.query('DELETE FROM user_settings WHERE user_id = $1 AND key = $2', [req.user.id, key]);
} else {
await pool.query(
`INSERT INTO user_settings (user_id, key, value) VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[req.user.id, key, value]
);
}
}
await logAudit(req, 'user_settings.update', { keys: entries.map(e => e[0]) });
res.json(await userSettingsPayload(req.user.id));
} catch (e) {
res.status(500).json({ error: 'Не удалось сохранить настройки' });
}
});
const logoUpload = upload.single('logo');
app.post('/api/settings/logo', requireAdmin, (req, res, next) => {
logoUpload(req, res, (err) => {
@@ -3923,7 +3980,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca, sm] = await Promise.all([
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr, lrv, al, nt, nr, bi, ct, cm, ca, sm, fb, uss] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -3948,6 +4005,8 @@ async function buildBackupArchive() {
pool.query('SELECT * FROM chat_messages ORDER BY id'),
pool.query('SELECT * FROM chat_attachments ORDER BY id'),
pool.query('SELECT * FROM short_messages ORDER BY id'),
pool.query('SELECT * FROM feedbacks ORDER BY id'),
pool.query('SELECT * FROM user_settings ORDER BY user_id, key'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
@@ -3963,9 +4022,9 @@ async function buildBackupArchive() {
lesson_report_versions: lrv.rowCount, audit_log: al.rowCount, notifications: nt.rowCount,
notification_reads: nr.rowCount, banned_ips: bi.rowCount,
chat_threads: ct.rowCount, chat_messages: cm.rowCount, chat_attachments: ca.rowCount,
short_messages: sm.rowCount,
short_messages: sm.rowCount, feedbacks: fb.rowCount, user_settings: uss.rowCount,
},
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, short_messages: sm.rows,
groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows, lesson_report_versions: lrv.rows, audit_log: al.rows, notifications: nt.rows, notification_reads: nr.rows, banned_ips: bi.rows, chat_threads: ct.rows, chat_messages: cm.rows, chat_attachments: ca.rows, short_messages: sm.rows, feedbacks: fb.rows, user_settings: uss.rows,
};
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
const files = await storage.downloadAll(path.join(staging, 'uploads'));
@@ -4145,6 +4204,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await client.query('DELETE FROM sessions');
await client.query('DELETE FROM api_keys');
await client.query('DELETE FROM audit_log');
await client.query('DELETE FROM user_settings');
await client.query('DELETE FROM users');
await client.query('DELETE FROM branches');
for (const x of ndata.branches) {
@@ -4159,6 +4219,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.username, x.password_hash, x.name, x.role, x.is_active, x.created_at]
);
}
for (const x of (ndata.user_settings || [])) {
const okUser = (await client.query('SELECT 1 FROM users WHERE id = $1', [x.user_id])).rowCount;
if (!okUser) continue;
await client.query(
'INSERT INTO user_settings (user_id, key, value, updated_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at',
[x.user_id, x.key, x.value, x.updated_at || null]
);
}
for (const x of ndata.user_branches) {
await client.query(
'INSERT INTO user_branches (user_id, branch_id) VALUES ($1,$2)',