'use strict';
const { createModelModule } = require('../dal/lib/model-handle');
const { proxy: ThingHandle, register: registerThingHandle } = createModelModule({
tableName: 'things'
});
module.exports = ThingHandle;
const { getPostgresDAL } = require('../db-postgres');
const type = require('../dal').type;
const mlString = require('../dal').mlString;
const debug = require('../util/debug');
const urlUtils = require('../util/url-utils');
const ReportedError = require('../util/reported-error');
const adapters = require('../adapters/adapters');
const isValidLanguage = require('../locales/languages').isValid;
const { initializeModel } = require('../dal/lib/model-initializer');
const ThingSlug = require('./thing-slug');
const File = require('./file');
const Review = require('./review');
const User = require('./user');
const isUUID = require('is-uuid');
const decodeHTML = require('entities').decodeHTML;
const search = require('../search');
let Thing = null;
/**
* Initialize the PostgreSQL Thing model
* @param {DataAccessLayer} dal - Optional DAL instance for testing
*/
async function initializeThingModel(dal = null) {
const activeDAL = dal || await getPostgresDAL();
if (!activeDAL) {
debug.db('PostgreSQL DAL not available, skipping Thing model initialization');
return null;
}
try {
// Create the schema with revision fields
const thingSchema = {
id: type.string().uuid(4),
// Array of URLs (first is primary)
urls: type.array(type.string().validator(_isValidURL)),
// JSONB multilingual fields
label: mlString.getSchema({ maxLength: 256 }),
aliases: mlString.getSchema({ maxLength: 256, array: true }),
// Grouped metadata in JSONB for extensibility
metadata: type.object().validator(_validateMetadata),
// Complex sync data in JSONB
sync: type.object(),
// CamelCase relational fields that map to snake_case database columns
originalLanguage: type.string().max(4).validator(isValidLanguage),
canonicalSlugName: type.string(),
createdOn: type.date().required(true),
createdBy: type.string().uuid(4).required(true),
// Virtual fields for compatibility
urlID: type.virtual().default(function() {
const slugName = this.getValue ? this.getValue('canonicalSlugName') : this.canonicalSlugName;
return slugName ? encodeURIComponent(slugName) : this.id;
}),
// Permission virtual fields
userCanDelete: type.virtual().default(false),
userCanEdit: type.virtual().default(false),
userCanUpload: type.virtual().default(false),
userIsCreator: type.virtual().default(false),
// Metrics virtual fields (populated asynchronously)
numberOfReviews: type.virtual().default(0),
averageStarRating: type.virtual().default(0),
// Virtual accessors for nested metadata JSONB fields
description: type.virtual().default(function() {
const metadata = this.getValue ? this.getValue('metadata') : this.metadata;
return metadata?.description;
}),
subtitle: type.virtual().default(function() {
const metadata = this.getValue ? this.getValue('metadata') : this.metadata;
return metadata?.subtitle;
}),
authors: type.virtual().default(function() {
const metadata = this.getValue ? this.getValue('metadata') : this.metadata;
return metadata?.authors;
})
};
const { model } = initializeModel({
dal: activeDAL,
baseTable: 'things',
schema: thingSchema,
camelToSnake: {
originalLanguage: 'original_language',
canonicalSlugName: 'canonical_slug_name',
createdOn: 'created_on',
createdBy: 'created_by'
},
withRevision: {
static: [
'createFirstRevision',
'getNotStaleOrDeleted',
'filterNotStaleOrDeleted',
'getMultipleNotStaleOrDeleted'
],
instance: ['deleteAllRevisions']
},
staticMethods: {
lookupByURL,
getWithData,
getLabel
},
instanceMethods: {
initializeFieldsFromAdapter,
populateUserInfo,
populateReviewMetrics,
setURLs,
updateActiveSyncs,
getSourceIDsOfActiveSyncs,
updateSlug,
getReviewsByUser,
getAverageStarRating,
getReviewCount,
addFile,
addFilesByIDsAndSave
},
relations: [
{
name: 'reviews',
targetTable: 'reviews',
sourceKey: 'id',
targetKey: 'thing_id',
hasRevisions: true,
cardinality: 'many'
},
{
name: 'files',
targetTable: 'files',
sourceKey: 'id',
targetKey: 'id',
hasRevisions: true,
through: {
table: 'thing_files',
sourceForeignKey: 'thing_id',
targetForeignKey: 'file_id'
},
cardinality: 'many'
}
]
});
Thing = model;
debug.db('PostgreSQL Thing model initialized with all methods');
return Thing;
} catch (error) {
debug.error('Failed to initialize PostgreSQL Thing model:', error);
return null;
}
}
// NOTE: STATIC METHODS --------------------------------------------------------
/**
* Find a Thing object using a URL. May return multiple matches (but ordinarily
* should not).
*
* @param {String} url - the URL to look up
* @param {String} [userID] - include any review(s) of this thing by the given user
* @returns {Promise<Thing[]>} array of matching things
*/
async function lookupByURL(url, userID) {
const query = `
SELECT * FROM ${Thing.tableName}
WHERE urls @> ARRAY[$1]
AND (_old_rev_of IS NULL)
AND (_rev_deleted IS NULL OR _rev_deleted = false)
`;
const result = await Thing.dal.query(query, [url]);
const things = result.rows.map(row => Thing._createInstance(row));
if (typeof userID === 'string') {
const lookupUserID = typeof userID.trim === 'function' ? userID.trim() : userID;
const thingIDs = things
.map(thing => thing.id)
.filter(id => typeof id === 'string' && id.length > 0);
// Default to empty arrays so API consumers can rely on the property
things.forEach(thing => {
thing.reviews = [];
});
if (thingIDs.length > 0) {
try {
const reviews = await Review
.filterNotStaleOrDeleted()
.whereIn('thing_id', thingIDs, { cast: 'uuid[]' })
.filter({ createdBy: lookupUserID })
.orderBy('created_on', 'DESC')
.limit(50)
.run();
const reviewsByThing = new Map();
for (const review of reviews) {
if (typeof review.populateUserInfo === 'function') {
review.populateUserInfo({ id: lookupUserID });
}
const reviewThingID = review.thingID;
if (!reviewThingID) {
continue;
}
if (!reviewsByThing.has(reviewThingID)) {
reviewsByThing.set(reviewThingID, []);
}
reviewsByThing.get(reviewThingID).push(review);
}
things.forEach(thing => {
const userReviews = reviewsByThing.get(thing.id);
if (Array.isArray(userReviews)) {
thing.reviews = userReviews;
}
});
} catch (error) {
debug.db('Failed to include user reviews in Thing.lookupByURL:', error.message);
}
}
}
return things;
}
/**
* Get a Thing object by ID, plus some of the data linked to it.
*
* @async
* @param {String} id - the unique ID of the Thing object
* @param {Object} [options] - which data to include
* @param {Boolean} options.withFiles=true - include metadata about file upload via join
* @param {Boolean} options.withReviewMetrics=true - obtain review metrics
* @returns {Thing} the Thing object
*/
async function getWithData(id, {
withFiles = true,
withReviewMetrics = true
} = {}) {
const joinOptions = {};
if (withFiles) {
joinOptions.files = {
_apply: seq => seq.filter({ completed: true })
};
}
const thing = Object.keys(joinOptions).length
? await Thing.getNotStaleOrDeleted(id, joinOptions)
: await Thing.getNotStaleOrDeleted(id);
if (withFiles) {
thing.files = Array.isArray(thing.files) ? thing.files : [];
await _attachUploadersToFiles(thing.files);
}
if (withReviewMetrics) {
await thing.populateReviewMetrics();
}
return thing;
}
/**
* Get label for a given thing in the provided language, or fall back to a
* prettified URL.
*
* @param {Thing} thing - the thing object to get a label for
* @param {String} language - the language code of the preferred language
* @returns {String} the best available label
*/
function getLabel(thing, language) {
if (!thing || !thing.id) {
return undefined;
}
let str;
if (thing.label) {
const resolved = mlString.resolve(language, thing.label);
str = resolved ? resolved.str : undefined;
}
if (str) {
return str;
}
// If we have no proper label, we can at least show the URL
if (thing.urls && thing.urls.length) {
return urlUtils.prettify(thing.urls[0]);
}
return undefined;
}
// NOTE: INSTANCE METHODS ------------------------------------------------------
/**
* Initialize field values from the lookup result of an adapter.
*
* @param {Object} adapterResult - the result from any backend adapter
* @param {Object} adapterResult.data - data for this result
* @param {String} adapterResult.sourceID - canonical source identifier
* @instance
* @memberof Thing
*/
function initializeFieldsFromAdapter(adapterResult) {
if (typeof adapterResult !== 'object') {
return;
}
const responsibleAdapter = adapters.getAdapterForSource(adapterResult.sourceID);
const supportedFields = responsibleAdapter.getSupportedFields();
for (const field in adapterResult.data) {
if (supportedFields.includes(field)) {
// Handle metadata grouping for PostgreSQL
if (['description', 'subtitle', 'authors'].includes(field)) {
if (!this.metadata) {
this.metadata = {};
}
this.metadata[field] = adapterResult.data[field];
} else {
this[field] = adapterResult.data[field];
}
if (typeof this.sync !== 'object') {
this.sync = {};
}
this.sync[field] = {
active: true,
source: adapterResult.sourceID,
updated: new Date()
};
}
}
}
/**
* Populate virtual permission fields in a Thing object with the rights of a
* given user.
*
* @param {User} user - the user whose permissions to check
* @memberof Thing
* @instance
*/
function populateUserInfo(user) {
if (!user) {
return; // Permissions will be at their default value (false)
}
this.userCanDelete = user.isSuperUser || user.isSiteModerator || false;
this.userCanEdit = user.isSuperUser || user.isTrusted || user.id === this.createdBy;
this.userCanUpload = user.isSuperUser || user.isTrusted;
this.userIsCreator = user.id === this.createdBy;
}
/**
* Set this Thing object's virtual data fields for review metrics.
*
* @returns {Thing} the modified thing object
* @memberof Thing
* @instance
*/
async function populateReviewMetrics() {
const [averageStarRating, numberOfReviews] = await Promise.all([
this.getAverageStarRating(),
this.getReviewCount()
]);
this.averageStarRating = averageStarRating;
this.numberOfReviews = numberOfReviews;
return this;
}
/**
* Update URLs and reset synchronization settings.
*
* @param {String[]} urls - the complete array of URLs to assign
* @instance
* @memberof Thing
*/
function setURLs(urls) {
// Turn off all synchronization
if (typeof this.sync === 'object') {
for (const field in this.sync) {
this.sync[field].active = false;
}
}
// Turn on synchronization for supported fields
urls.forEach(url => {
adapters.getAll().forEach(adapter => {
if (adapter.ask(url)) {
adapter.supportedFields.forEach(field => {
// Another adapter is already handling this field
if (this.sync && this.sync[field] && this.sync[field].active) {
return;
}
if (!this.sync) {
this.sync = {};
}
this.sync[field] = {
active: true,
source: adapter.sourceID
};
});
}
});
});
this.urls = urls;
}
/**
* Fetch new external data for all fields set to be synchronized.
*
* @param {String} userID - the user to associate with any slug changes
* @returns {Thing} the updated thing
* @memberof Thing
* @instance
*/
async function updateActiveSyncs(userID) {
const thing = this;
if (!thing.urls || !thing.urls.length) {
return thing;
}
if (typeof thing.sync !== 'object') {
thing.sync = {};
}
// Determine which external sources we need to contact
const sources = [];
for (const field in thing.sync) {
if (thing.sync[field].active && thing.sync[field].source &&
!sources.includes(thing.sync[field].source)) {
sources.push(thing.sync[field].source);
}
}
// Build array of lookup promises from currently used sources
const promises = [];
const allURLs = [];
sources.forEach(source => {
const adapter = adapters.getAdapterForSource(source);
const relevantURLs = thing.urls.filter(url => adapter.ask(url));
allURLs.push(...relevantURLs);
promises.push(...relevantURLs.map(url =>
adapter
.lookup(url)
.catch(error => {
debug.error(`Problem contacting adapter "${adapter.getSourceID()}" for URL ${url}.`);
debug.error({ error });
})
));
});
if (allURLs.length) {
debug.app(`Retrieving item metadata for ${thing.id} from the following URL(s):\n` +
allURLs.join(', '));
}
const results = await Promise.all(promises);
let needSlugUpdate = false;
// Organize data by source and update fields
const dataBySource = _organizeDataBySource(results);
sources.forEach((source) => {
for (const field in thing.sync) {
if (thing.sync[field].active && thing.sync[field].source === source &&
Array.isArray(dataBySource[source])) {
for (const data of dataBySource[source]) {
if (data[field] !== undefined) {
// Create a new sync object to ensure change detection works
const newSync = { ...thing.sync };
newSync[field] = { ...newSync[field], updated: new Date() };
thing.sync = newSync;
// Handle metadata grouping for PostgreSQL
if (['description', 'subtitle', 'authors'].includes(field)) {
if (!thing.metadata) {
thing.metadata = {};
}
thing.metadata[field] = data[field];
} else {
thing[field] = data[field];
}
if (field === 'label') {
needSlugUpdate = true;
}
}
}
}
}
});
if (needSlugUpdate) {
try {
await thing.updateSlug(userID, thing.originalLanguage || 'en');
} catch (error) {
debug.error('Failed to update slug after sync:', error);
}
}
await thing.save();
// Index update can keep running after we resolve this promise
search.indexThing(thing);
return thing;
/**
* Organize valid data from results array into an object with sourceID as key
* @param {Object[]} results - results from multiple adapters
* @returns {Object} key = source, value = array of data objects
*/
function _organizeDataBySource(results) {
const rv = {};
results.forEach(result => {
if (typeof result === 'object' && typeof result.sourceID === 'string' &&
typeof result.data === 'object') {
if (!Array.isArray(rv[result.sourceID])) {
rv[result.sourceID] = [];
}
rv[result.sourceID].unshift(result.data);
}
});
return rv;
}
}
/**
* Get the identifiers of all sources for which relevant information is being
* fetched for this review subject.
*
* @returns {String[]} array of the source IDs
*/
function getSourceIDsOfActiveSyncs() {
const sync = this.sync;
const rv = [];
for (const key in sync) {
if (sync[key] && sync[key].active && sync[key].source && !rv.includes(sync[key].source)) {
rv.push(sync[key].source);
}
}
return rv;
}
/**
* Fetch up to 50 reviews authored by the given user for this thing.
*
* Uses the PostgreSQL review feed to reuse join logic and populates
* permission flags on each returned review.
*
* @param {Object} user - Current user performing the lookup
* @param {string} user.id - User identifier used to filter reviews
* @returns {Promise<Object[]>} Reviews authored by the user (empty array if none)
* @instance
* @memberof Thing
*/
async function getReviewsByUser(user) {
if (!user || !user.id) {
return [];
}
try {
const feed = await Review.getFeed({
thingID: this.id,
createdBy: user.id,
withThing: false,
withTeams: true,
limit: 50
});
const reviews = Array.isArray(feed.feedItems) ? feed.feedItems : [];
reviews.forEach(review => {
if (typeof review.populateUserInfo === 'function') {
review.populateUserInfo(user);
}
});
return reviews;
} catch (error) {
debug.db('Failed to fetch user reviews in Thing.getReviewsByUser:', error.message);
return [];
}
}
/**
* Calculate the average review rating for this Thing object
*
* @returns {Number} average rating, not rounded
* @memberof Thing
* @instance
*/
async function getAverageStarRating() {
try {
const reviewTableName = Thing.dal.schemaNamespace ? `${Thing.dal.schemaNamespace}reviews` : 'reviews';
const query = `
SELECT AVG(star_rating) as avg_rating
FROM ${reviewTableName}
WHERE thing_id = $1
AND (_old_rev_of IS NULL)
AND (_rev_deleted IS NULL OR _rev_deleted = false)
`;
const result = await Thing.dal.query(query, [this.id]);
return parseFloat(result.rows[0]?.avg_rating || 0);
} catch (error) {
debug.error('Error calculating average star rating:', error);
return 0;
}
}
/**
* Count the number of reviews associated with this Thing object.
*
* @returns {Number} the number of reviews
* @memberof Thing
* @instance
*/
async function getReviewCount() {
try {
const reviewTableName = Thing.dal.schemaNamespace ? `${Thing.dal.schemaNamespace}reviews` : 'reviews';
const query = `
SELECT COUNT(*) as review_count
FROM ${reviewTableName}
WHERE thing_id = $1
AND (_old_rev_of IS NULL)
AND (_rev_deleted IS NULL OR _rev_deleted = false)
`;
const result = await Thing.dal.query(query, [this.id]);
return parseInt(result.rows[0]?.review_count || 0);
} catch (error) {
debug.error('Error counting reviews:', error);
return 0;
}
}
/**
* Simple helper method to initialize files array for a Thing object.
*
* @param {File} file - File object to add
* @memberof Thing
* @instance
*/
function addFile(file) {
if (this.files === undefined) {
this.files = [];
}
this.files.push(file);
}
/**
* Create or update the canonical slug for this Thing.
*
* @param {String} userID - User ID responsible for the slug change
* @param {String} [language] - Preferred language for slug generation
* @returns {Thing} the updated Thing instance
* @memberof Thing
* @instance
*/
async function updateSlug(userID, language) {
const originalLanguage = this.originalLanguage || 'en';
const slugLanguage = language || originalLanguage;
if (slugLanguage !== originalLanguage) {
return this;
}
if (!this.label) {
return this;
}
const resolved = mlString.resolve(slugLanguage, this.label);
if (!resolved || typeof resolved.str !== 'string' || !resolved.str.trim()) {
return this;
}
let baseSlug;
try {
baseSlug = _generateSlugName(resolved.str);
} catch (error) {
return this;
}
if (!baseSlug || baseSlug === this.canonicalSlugName) {
return this;
}
if (!this.id) {
const { randomUUID } = require('crypto');
this.id = randomUUID();
}
const slug = new ThingSlug({});
slug.slug = baseSlug;
slug.name = baseSlug;
slug.baseName = baseSlug;
slug.thingID = this.id;
slug.createdOn = new Date();
slug.createdBy = userID;
try {
const savedSlug = await slug.qualifiedSave();
if (savedSlug && savedSlug.name) {
this.canonicalSlugName = savedSlug.name;
this._changed.add(Thing._getDbFieldName('canonicalSlugName'));
}
} catch (error) {
debug.error('Failed to update Thing slug:', error);
}
return this;
}
/**
* Obtain file objects for an array of file IDs, and associate them with this thing.
*
* @param {String[]} files - IDs of the files to add
* @param {String} [userID] - if given, uploader must match this user ID
* @memberof Thing
* @instance
*/
async function addFilesByIDsAndSave(files, userID) {
if (!Array.isArray(files) || files.length === 0) {
return this;
}
const uniqueIDs = [...new Set(files.filter(id => typeof id === 'string' && id.length))];
if (uniqueIDs.length === 0) {
return this;
}
try {
const placeholders = uniqueIDs.map((_, index) => `$${index + 1}`).join(', ');
const query = `
SELECT *
FROM ${File.tableName}
WHERE id IN (${placeholders})
AND (_old_rev_of IS NULL)
AND (_rev_deleted IS NULL OR _rev_deleted = false)
`;
const result = await File.dal.query(query, uniqueIDs);
const validFiles = result.rows
.map(row => File._createInstance(row))
.filter(file => !userID || file.uploadedBy === userID);
if (!validFiles.length) {
return this;
}
const junctionTable = Thing.dal.schemaNamespace ? `${Thing.dal.schemaNamespace}thing_files` : 'thing_files';
const insertValues = [];
const valueClauses = [];
let paramIndex = 1;
validFiles.forEach(file => {
valueClauses.push(`($${paramIndex}, $${paramIndex + 1})`);
insertValues.push(this.id, file.id);
paramIndex += 2;
});
if (valueClauses.length) {
const insertQuery = `
INSERT INTO ${junctionTable} (thing_id, file_id)
VALUES ${valueClauses.join(', ')}
ON CONFLICT DO NOTHING
`;
await Thing.dal.query(insertQuery, insertValues);
}
const existingIDs = new Set((this.files || []).map(file => file.id));
validFiles.forEach(file => {
if (!existingIDs.has(file.id)) {
this.addFile(file);
existingIDs.add(file.id);
}
});
} catch (error) {
debug.error('Failed to associate files with Thing:', error);
throw error;
}
return this;
}
// Helper functions
async function _attachUploadersToFiles(files) {
if (!Array.isArray(files) || files.length === 0) {
return;
}
const uploaderIDs = [];
const seen = new Set();
files.forEach(file => {
const uploaderID = file && file.uploadedBy;
if (typeof uploaderID === 'string' && !seen.has(uploaderID)) {
seen.add(uploaderID);
uploaderIDs.push(uploaderID);
}
});
if (!uploaderIDs.length) {
return;
}
try {
// Users don't have revisions, use whereIn for array of IDs
const result = await User.filter({}).whereIn('id', uploaderIDs, { cast: 'uuid[]' }).run();
const uploaderMap = new Map();
for (const uploader of result || []) {
if (!uploader || !uploader.id) {
continue;
}
uploaderMap.set(uploader.id, uploader);
}
files.forEach(file => {
if (!file || !file.uploadedBy) {
return;
}
const uploader = uploaderMap.get(file.uploadedBy);
if (uploader) {
file.uploader = uploader;
}
});
} catch (error) {
debug.error(`Failed to attach uploader data to files: ${error.message}`);
debug.error(error.stack);
}
}
/**
* Validate URL format
* @param {String} url - URL to check
* @returns {Boolean} true if valid
* @throws {ReportedError} if invalid
*/
function _isValidURL(url) {
if (urlUtils.validate(url)) {
return true;
} else {
throw new ReportedError({
message: 'Thing URL %s is not a valid URL.',
messageParams: [url],
userMessage: 'invalid url',
userMessageParams: []
});
}
}
function _generateSlugName(str) {
if (typeof str !== 'string') {
throw new Error('Source string is undefined or not a string.');
}
let slug = decodeHTML(str)
.trim()
.toLowerCase()
.replace(/[?&"″'`’<>:]/g, '')
.replace(/[ _/]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
slug = slug.replace(/[^a-z0-9-]/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
if (!slug) {
throw new Error('Source string cannot be converted to a valid slug.');
}
if (isUUID.v4(slug)) {
throw new Error('Source string cannot be a UUID.');
}
return slug;
}
/**
* Validate metadata JSONB structure
* @param {Object} metadata - Metadata object to validate
* @returns {Boolean} true if valid
*/
function _validateMetadata(metadata) {
if (metadata === null || metadata === undefined) {
return true;
}
if (typeof metadata !== 'object' || Array.isArray(metadata)) {
throw new Error('Metadata must be an object');
}
// Validate known metadata fields
const validFields = ['description', 'subtitle', 'authors'];
for (const [key, value] of Object.entries(metadata)) {
if (validFields.includes(key)) {
if (key === 'authors') {
// Authors should be an array of multilingual strings
if (!Array.isArray(value)) {
throw new Error('Authors metadata must be an array');
}
// Each author should be a multilingual string
for (const author of value) {
mlString.validate(author, { maxLength: 256 });
}
} else {
// Description and subtitle are multilingual strings
mlString.validate(value, { maxLength: key === 'description' ? 512 : 256 });
}
}
}
return true;
}
registerThingHandle({
initializeModel: initializeThingModel,
handleOptions: {
staticMethods: {
lookupByURL,
getWithData,
getLabel
},
instanceMethods: {
initializeFieldsFromAdapter,
populateUserInfo,
populateReviewMetrics,
setURLs,
updateActiveSyncs,
getSourceIDsOfActiveSyncs,
updateSlug,
getReviewsByUser,
getAverageStarRating,
getReviewCount,
addFile,
addFilesByIDsAndSave
}
},
additionalExports: {
initializeThingModel
}
});