All checks were successful
build-and-push / docker (push) Successful in 28s
- Changing a license policy applies to all models with that license, and opening Settings reconciles old imports to current rules (store.reconcileModelsToRules) - Editing a model's license re-resolves its sellability from the rule - Removed the per-model commercial-use override (set by license now) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
const db = require('./db');
|
|
|
|
// ---- shared SELECT that hydrates a model with tags, projects, files, designer ----
|
|
const MODEL_SELECT = `
|
|
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
|
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
|
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
|
COALESCE(t.tags, '[]'::json) AS tags,
|
|
COALESCE(p.projects, '[]'::json) AS projects,
|
|
COALESCE(f.files, '[]'::json) AS files
|
|
FROM models m
|
|
LEFT JOIN designers d ON d.id = m.designer_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(tg.name ORDER BY tg.name) AS tags
|
|
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
|
|
WHERE mt.model_id = m.id
|
|
) t ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object('id', pr.id, 'name', pr.name) ORDER BY pr.name) AS projects
|
|
FROM model_projects mp JOIN projects pr ON pr.id = mp.project_id
|
|
WHERE mp.model_id = m.id
|
|
) p ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT json_agg(json_build_object(
|
|
'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name,
|
|
'format', fl.format, 'size', fl.size, 'volume', fl.volume,
|
|
'bbox', fl.bbox, 'facets', fl.facets) ORDER BY fl.id) AS files
|
|
FROM files fl WHERE fl.model_id = m.id
|
|
) f ON true
|
|
`;
|
|
|
|
function shapeModel(row) {
|
|
if (!row) return null;
|
|
const { designer_id, designer_name, designer_url, ...rest } = row;
|
|
return {
|
|
...rest,
|
|
designer: designer_id ? { id: designer_id, name: designer_name, url: designer_url } : null,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- models
|
|
async function getAllModels() {
|
|
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
|
|
return rows.map(shapeModel);
|
|
}
|
|
|
|
async function getModel(id) {
|
|
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = $1`, [id]);
|
|
return shapeModel(rows[0]);
|
|
}
|
|
|
|
async function findModelBySource(site, sourceId) {
|
|
const { rows } = await db.query(
|
|
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
|
|
return shapeModel(rows[0]);
|
|
}
|
|
|
|
// data = { name, description, source_site, source_url, source_id, license,
|
|
// designer:{name,source_site,profile_url}, thumbnail_path, print_settings,
|
|
// tags:[string], projects:[id], files:[{...}] }
|
|
async function createModel(data) {
|
|
return db.transaction(async (client) => {
|
|
let designerId = null;
|
|
if (data.designer && data.designer.name) {
|
|
designerId = await upsertDesigner(client, data.designer);
|
|
}
|
|
const { rows } = await client.query(
|
|
`INSERT INTO models (name, description, source_site, source_url, source_id,
|
|
license, commercial_use, designer_id, thumbnail_path, print_settings)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
|
|
[data.name, data.description || '', data.source_site || null, data.source_url || null,
|
|
data.source_id || null, data.license || null, data.commercial_use ?? null, designerId,
|
|
data.thumbnail_path || null, data.print_settings || {}]
|
|
);
|
|
const modelId = rows[0].id;
|
|
await setTags(client, modelId, data.tags || []);
|
|
await setProjects(client, modelId, data.projects || []);
|
|
for (const file of data.files || []) {
|
|
await insertFile(client, modelId, file);
|
|
}
|
|
return modelId;
|
|
}).then(getModel);
|
|
}
|
|
|
|
async function updateModel(id, updates) {
|
|
await db.transaction(async (client) => {
|
|
const fields = [];
|
|
const values = [];
|
|
let i = 1;
|
|
for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
|
|
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
|
}
|
|
if (updates.print_settings !== undefined) {
|
|
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
|
|
}
|
|
if (fields.length) {
|
|
fields.push(`date_modified = now()`);
|
|
values.push(id);
|
|
await client.query(`UPDATE models SET ${fields.join(', ')} WHERE id = $${i}`, values);
|
|
}
|
|
if (updates.tags !== undefined) await setTags(client, id, updates.tags);
|
|
if (updates.projects !== undefined) await setProjects(client, id, updates.projects);
|
|
});
|
|
return getModel(id);
|
|
}
|
|
|
|
// Returns the on-disk file rows so the caller can unlink them.
|
|
async function deleteModel(id) {
|
|
const { rows } = await db.query('SELECT file_path, glb_path FROM files WHERE model_id = $1', [id]);
|
|
const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]);
|
|
const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]);
|
|
if (result.rowCount === 0) return null;
|
|
return {
|
|
filePaths: rows.map((r) => r.file_path),
|
|
glbPaths: rows.map((r) => r.glb_path).filter(Boolean),
|
|
thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path,
|
|
};
|
|
}
|
|
|
|
async function addFiles(modelId, files) {
|
|
await db.transaction(async (client) => {
|
|
for (const file of files) await insertFile(client, modelId, file);
|
|
});
|
|
return getModel(modelId);
|
|
}
|
|
|
|
// Record a 3MF's GLB conversion + computed geometry on the file row.
|
|
async function updateFileConversion(id, { glb_path, volume, bbox }) {
|
|
await db.query(
|
|
`UPDATE files SET glb_path = $2,
|
|
volume = COALESCE($3, volume),
|
|
bbox = COALESCE($4, bbox)
|
|
WHERE id = $1`,
|
|
[id, glb_path, volume ?? null, bbox ?? null]);
|
|
}
|
|
|
|
async function insertFile(client, modelId, file) {
|
|
await client.query(
|
|
`INSERT INTO files (model_id, file_name, original_name, file_path, format, size, volume, bbox, facets)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
[modelId, file.fileName, file.originalName, file.filePath, file.format,
|
|
file.size || 0, file.volume ?? null, file.bbox ?? null, file.facets ?? null]
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- tags
|
|
async function setTags(client, modelId, tagNames) {
|
|
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
|
|
for (const raw of tagNames) {
|
|
const name = String(raw).trim();
|
|
if (!name) continue;
|
|
const { rows } = await client.query(
|
|
`INSERT INTO tags (name) VALUES ($1)
|
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
|
|
await client.query(
|
|
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
|
[modelId, rows[0].id]);
|
|
}
|
|
}
|
|
|
|
async function getAllTags() {
|
|
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
|
return rows.map((r) => r.name);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- projects
|
|
async function setProjects(client, modelId, projectIds) {
|
|
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
|
|
for (const pid of projectIds) {
|
|
await client.query(
|
|
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
|
[modelId, pid]);
|
|
}
|
|
}
|
|
|
|
async function getAllProjects() {
|
|
const { rows } = await db.query(`
|
|
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
|
|
COUNT(mp.model_id)::int AS model_count
|
|
FROM projects p
|
|
LEFT JOIN model_projects mp ON mp.project_id = p.id
|
|
GROUP BY p.id ORDER BY p.name`);
|
|
return rows;
|
|
}
|
|
|
|
async function getProject(id) {
|
|
const { rows } = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function createProject({ name, description }) {
|
|
const { rows } = await db.query(
|
|
'INSERT INTO projects (name, description) VALUES ($1,$2) RETURNING *',
|
|
[name, description || '']);
|
|
return rows[0];
|
|
}
|
|
|
|
async function updateProject(id, { name, description }) {
|
|
const { rows } = await db.query(
|
|
`UPDATE projects SET name = COALESCE($2, name),
|
|
description = COALESCE($3, description), date_modified = now()
|
|
WHERE id = $1 RETURNING *`, [id, name ?? null, description ?? null]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function deleteProject(id) {
|
|
const { rowCount } = await db.query('DELETE FROM projects WHERE id = $1', [id]);
|
|
return rowCount > 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- license rules
|
|
async function getLicenseRule(name) {
|
|
const { rows } = await db.query('SELECT name, commercial FROM license_rules WHERE name = $1', [name]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function upsertLicenseRule(name, commercial) {
|
|
await db.query(
|
|
`INSERT INTO license_rules (name, commercial) VALUES ($1, $2)
|
|
ON CONFLICT (name) DO UPDATE SET commercial = EXCLUDED.commercial`,
|
|
[name, commercial ?? null]);
|
|
}
|
|
|
|
async function listLicenseRules() {
|
|
const { rows } = await db.query(`
|
|
SELECT lr.name, lr.commercial, COUNT(m.id)::int AS model_count
|
|
FROM license_rules lr
|
|
LEFT JOIN models m ON m.license = lr.name
|
|
GROUP BY lr.name, lr.commercial
|
|
ORDER BY lr.name`);
|
|
return rows;
|
|
}
|
|
|
|
async function distinctModelLicenses() {
|
|
const { rows } = await db.query("SELECT DISTINCT license FROM models WHERE license IS NOT NULL AND license <> ''");
|
|
return rows.map((r) => r.license);
|
|
}
|
|
|
|
// Push a rule's value onto every model that carries that license.
|
|
async function applyLicenseRuleToModels(name, commercial) {
|
|
await db.query('UPDATE models SET commercial_use = $2 WHERE license = $1', [name, commercial ?? null]);
|
|
}
|
|
|
|
// Align every model's commercial_use to its license rule (license = source of truth).
|
|
async function reconcileModelsToRules() {
|
|
await db.query(`
|
|
UPDATE models m SET commercial_use = lr.commercial
|
|
FROM license_rules lr
|
|
WHERE m.license = lr.name AND m.commercial_use IS DISTINCT FROM lr.commercial`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- users / auth
|
|
async function countUsers() {
|
|
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM users');
|
|
return rows[0].n;
|
|
}
|
|
|
|
async function getUserByEmail(email) {
|
|
const { rows } = await db.query('SELECT * FROM users WHERE lower(email) = lower($1)', [email]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function getUserById(id) {
|
|
const { rows } = await db.query('SELECT id, email, created_at FROM users WHERE id = $1', [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function createUser(email, passwordHash) {
|
|
const { rows } = await db.query(
|
|
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, created_at',
|
|
[email.trim(), passwordHash]);
|
|
return rows[0];
|
|
}
|
|
|
|
async function updateUserPassword(id, passwordHash) {
|
|
await db.query('UPDATE users SET password_hash = $2 WHERE id = $1', [id, passwordHash]);
|
|
}
|
|
|
|
async function createPasswordReset(userId, tokenHash, expiresAt) {
|
|
await db.query(
|
|
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1,$2,$3)',
|
|
[userId, tokenHash, expiresAt]);
|
|
}
|
|
|
|
async function getValidReset(tokenHash) {
|
|
const { rows } = await db.query(
|
|
`SELECT * FROM password_resets
|
|
WHERE token_hash = $1 AND used = false AND expires_at > now()`, [tokenHash]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function markResetUsed(id) {
|
|
await db.query('UPDATE password_resets SET used = true WHERE id = $1', [id]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- settings
|
|
async function getSetting(key) {
|
|
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
|
|
return rows.length ? rows[0].value : null;
|
|
}
|
|
|
|
// value of '' or null clears the setting.
|
|
async function setSetting(key, value) {
|
|
if (value === null || value === '') {
|
|
await db.query('DELETE FROM settings WHERE key = $1', [key]);
|
|
return;
|
|
}
|
|
await db.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
|
[key, value]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- designers
|
|
async function upsertDesigner(client, { name, source_site, profile_url }) {
|
|
const { rows } = await client.query(
|
|
`INSERT INTO designers (name, source_site, profile_url) VALUES ($1,$2,$3)
|
|
ON CONFLICT (name, source_site) DO UPDATE SET profile_url = EXCLUDED.profile_url
|
|
RETURNING id`, [name, source_site || null, profile_url || null]);
|
|
return rows[0].id;
|
|
}
|
|
|
|
module.exports = {
|
|
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
|
updateFileConversion,
|
|
getAllTags,
|
|
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
|
getSetting, setSetting,
|
|
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
|
createPasswordReset, getValidReset, markResetUsed,
|
|
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
|
reconcileModelsToRules,
|
|
};
|