feat: per-user feedback AI prompt template with placeholder keys
This commit is contained in:
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user