feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s

- Auto-advance artefact to next working version on rejection/revision
- Add post_id field to artefact creation
- Add request timeout (20s) to NocoDB client
- Add POST /api/admin/test-email for diagnosing SMTP issues
- Fix FK column creation logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-05-11 12:27:23 +03:00
parent a67b2afb0d
commit 94ce012837
7 changed files with 391 additions and 276 deletions
+27 -12
View File
@@ -40,29 +40,44 @@ function buildWhere(conditions) {
.join('~and');
}
const REQUEST_TIMEOUT_MS = 20_000;
async function request(method, url, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = {
method,
headers: {
'xc-token': NOCODB_TOKEN,
'Content-Type': 'application/json',
},
signal: controller.signal,
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let details;
try { details = await res.json(); } catch {}
throw new NocoDBError(
`NocoDB ${method} ${url} failed: ${res.status}`,
res.status,
details
);
try {
const res = await fetch(url, opts);
clearTimeout(timer);
if (!res.ok) {
let details;
try { details = await res.json(); } catch {}
throw new NocoDBError(
`NocoDB ${method} ${url} failed: ${res.status}`,
res.status,
details
);
}
// DELETE returns empty or {msg}
const text = await res.text();
return text ? JSON.parse(text) : {};
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
throw new NocoDBError(`NocoDB ${method} ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, 408);
}
throw err;
}
// DELETE returns empty or {msg}
const text = await res.text();
return text ? JSON.parse(text) : {};
}
// ─── Link Resolution ─────────────────────────────────────────
+103 -5
View File
@@ -591,12 +591,17 @@ async function ensureFKColumns() {
for (const col of columns) {
if (!existingCols.has(col)) {
console.log(` Adding column ${table}.${col}...`);
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
console.log(` Adding FK column ${table}.${col}...`);
const colRes = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
method: 'POST',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: col, uidt: 'Number' }),
});
if (colRes.ok) {
console.log(` ✓ Created ${table}.${col}`);
} else {
console.warn(` ⚠ Failed to create ${table}.${col}: ${colRes.status}`);
}
}
}
} catch (err) {
@@ -668,6 +673,28 @@ app.get('/api/health', async (req, res) => {
});
});
// ─── EMAIL TEST ─────────────────────────────────────────────────
app.post('/api/admin/test-email', requireAuth, async (req, res) => {
if (req.session.userRole !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' });
const to = req.body.to || req.session.userEmail;
if (!to) return res.status(400).json({ error: 'No recipient — pass { "to": "email@example.com" }' });
const { sendMail, getSmtpConfig } = require('./mail');
const config = getSmtpConfig();
if (!config) return res.status(503).json({ error: 'SMTP not configured', env: { server: !!process.env.CLOUDRON_MAIL_SMTP_SERVER || !!process.env.MAIL_SMTP_SERVER } });
try {
const info = await sendMail({
to,
subject: 'Rawaj — Test Email',
html: '<p>If you received this, email delivery is working correctly.</p>',
text: 'If you received this, email delivery is working correctly.',
});
res.json({ success: true, to, messageId: info?.messageId, smtp: { host: config.host, port: config.port, from: config.from } });
} catch (err) {
res.status(500).json({ success: false, error: err.message, code: err.code, smtp: { host: config.host, port: config.port } });
}
});
// ─── SETUP ROUTES ───────────────────────────────────────────────
app.get('/api/setup/status', async (req, res) => {
@@ -4007,7 +4034,7 @@ app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
});
app.post('/api/artefacts', requireAuth, async (req, res) => {
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type } = req.body;
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type, post_id } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
try {
@@ -4018,6 +4045,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
status: 'draft',
content: content || null,
brand_id: brand_id ? Number(brand_id) : null,
post_id: post_id ? Number(post_id) : null,
project_id: project_id ? Number(project_id) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
@@ -4025,7 +4053,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
created_by_user_id: req.session.userId,
current_version: 1,
};
console.log('[POST /artefacts] Creating with:', JSON.stringify({ approver_ids: createData.approver_ids, project_id: createData.project_id, campaign_id: createData.campaign_id }));
console.log('[POST /artefacts] Creating with:', JSON.stringify({ post_id: createData.post_id, type: createData.type, copy_type: createData.copy_type, approver_ids: createData.approver_ids }));
const created = await nocodb.create('Artefacts', createData);
console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids }));
@@ -4039,7 +4067,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
});
const artefact = await nocodb.get('Artefacts', created.Id);
console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, approver_ids: artefact.approver_ids }));
console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, post_id: artefact.post_id, copy_type: artefact.copy_type, approver_ids: artefact.approver_ids }));
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];
for (const id of approverIdList) {
@@ -4267,6 +4295,45 @@ app.post('/api/artefacts/:id/link-post', requireAuth, async (req, res) => {
// ─── ARTEFACT VERSIONS ──────────────────────────────────────────
// Creates the next working version automatically after rejection/revision.
// For copy artefacts, texts are copied from the last version so the creator
// doesn't start with a blank slate.
async function autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS) {
const versions = await nocodb.list('ArtefactVersions', {
where: `(artefact_id,eq,${artefact.Id})`,
sort: '-version_number',
limit: 1,
});
const latest = versions[0];
if (!latest) return;
const nextNumber = latest.version_number + 1;
const created = await nocodb.create('ArtefactVersions', {
artefact_id: artefact.Id,
version_number: nextNumber,
created_at: new Date().toISOString(),
notes: `Round ${nextNumber}`,
});
if (artefact.type === 'copy') {
const prevTexts = await nocodb.list('ArtefactVersionTexts', {
where: `(version_id,eq,${latest.Id})`,
limit: QUERY_LIMITS.large,
});
for (const text of prevTexts) {
await nocodb.create('ArtefactVersionTexts', {
version_id: created.Id,
language_code: text.language_code,
language_label: text.language_label,
content: text.content,
});
}
}
await nocodb.update('Artefacts', artefact.Id, { current_version: nextNumber });
}
// List all versions for an artefact
app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
try {
@@ -4466,6 +4533,31 @@ app.post('/api/artefacts/:id/versions/:versionId/texts', requireAuth, async (req
}
});
// Update language entry content
app.patch('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
try {
const text = await nocodb.get('ArtefactVersionTexts', req.params.id);
if (!text) return res.status(404).json({ error: 'Text not found' });
const version = await nocodb.get('ArtefactVersions', text.version_id);
const artefact = await nocodb.get('Artefacts', version.artefact_id);
if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage texts for your own artefacts' });
}
const { content } = req.body;
if (content === undefined) return res.status(400).json({ error: 'content is required' });
await nocodb.update('ArtefactVersionTexts', req.params.id, { content });
const updated = await nocodb.get('ArtefactVersionTexts', req.params.id);
res.json(updated);
} catch (err) {
console.error('Update text error:', err);
res.status(500).json({ error: 'Failed to update text' });
}
});
// Delete language entry
app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
try {
@@ -4823,6 +4915,9 @@ app.post('/api/public/review/:token/reject', async (req, res) => {
feedback: feedback || '',
});
// Auto-advance to next working version
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
res.json({ success: true, message: 'Artefact rejected' });
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
} catch (err) {
@@ -4853,6 +4948,9 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
feedback: feedback || '',
});
// Auto-advance to next working version
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
res.json({ success: true, message: 'Revision requested' });
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
} catch (err) {