feat(gallery): colour labels for client proofing, and one global default per feedback type (#1044) (#1137)
Colour labels for client proofing, plus the photographer's own stars and colours in the admin grid. - Guest colour labels alongside likes/reactions, opt-in per event (defaults off so live galleries do not change mid-proofing), with 'colors' and 'lightroom' keybind schemes. - One global default per feedback type, replacing the per-type scatter. - Admin marks live in their own table (photo_admin_marks) so they can never reach a guest-facing surface. - XMP export prefers a real label, keeping the rating-derived mapping as a fallback. Review: concurrent-write loss on the mark update path, migration index idempotency and error classification all fixed in 7139bcae; migrations renumbered to 182/183 in 8fecdfae after 180/181 were taken on main. Merged with admin privileges: bypass-size-gate is a required check that fails on size alone for review-bypass authors and never re-evaluates on review, which is its designed behaviour once a maintainer has approved.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Global defaults for the per-event guest-feedback toggles (#1044).
|
||||
*
|
||||
* Before this module the creation defaults lived in four places that had
|
||||
* already drifted apart: the admin create route's destructuring defaults, the
|
||||
* v1 create route's hard-coded insert (which forgot `allow_reactions`
|
||||
* entirely), `feedbackService.getEventFeedbackSettings()`'s no-row fallback
|
||||
* and the migration column defaults. The same install answered "are comments
|
||||
* on by default?" differently depending on which code path created the event.
|
||||
*
|
||||
* Everything now resolves through FEEDBACK_TOGGLES:
|
||||
*
|
||||
* app_settings row -> per-event column default -> the built-in fallback
|
||||
*
|
||||
* The `event_default_*` keys extend the family `event_default_require_password`
|
||||
* (#317) and `event_default_feedback_enabled` (#520) already established: they
|
||||
* are the value a NEW event starts from, not a retroactive master switch.
|
||||
* Flipping one never changes a gallery that already exists — a switch that
|
||||
* silently strips the rating stars off a gallery a client is mid-way through
|
||||
* proofing would be a different, much riskier feature.
|
||||
*
|
||||
* Adding a seventh feedback type means adding one row to FEEDBACK_TOGGLES
|
||||
* (plus its column in the migration and its UI) — no sweep across the create
|
||||
* routes.
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Every guest-feedback toggle, in the order the admin UI shows them.
|
||||
*
|
||||
* `fallback` is what applies when the admin has never touched the global
|
||||
* setting. These match the values the admin create route has shipped with, so
|
||||
* installs that never open Settings > Events keep creating events exactly as
|
||||
* they did before — except `allow_color_labels`, which is new in #1044 and
|
||||
* opt-in so no existing gallery grows a colour bar on upgrade.
|
||||
*/
|
||||
const FEEDBACK_TOGGLES = [
|
||||
{ column: 'allow_ratings', settingKey: 'event_default_allow_ratings', fallback: true },
|
||||
{ column: 'allow_likes', settingKey: 'event_default_allow_likes', fallback: true },
|
||||
{ column: 'allow_favorites', settingKey: 'event_default_allow_favorites', fallback: true },
|
||||
{ column: 'allow_comments', settingKey: 'event_default_allow_comments', fallback: true },
|
||||
{ column: 'allow_reactions', settingKey: 'event_default_allow_reactions', fallback: true },
|
||||
{ column: 'allow_color_labels', settingKey: 'event_default_allow_color_labels', fallback: false },
|
||||
];
|
||||
|
||||
/** Non-boolean feedback defaults that follow the same global -> event flow. */
|
||||
const KEYBIND_MODES = ['colors', 'lightroom'];
|
||||
const DEFAULT_KEYBIND_MODE = 'colors';
|
||||
const KEYBIND_MODE_SETTING_KEY = 'event_default_keybind_mode';
|
||||
|
||||
/** Every app_settings key this module owns — for the settings API surface. */
|
||||
const FEEDBACK_DEFAULT_SETTING_KEYS = [
|
||||
...FEEDBACK_TOGGLES.map((t) => t.settingKey),
|
||||
KEYBIND_MODE_SETTING_KEY,
|
||||
];
|
||||
|
||||
/**
|
||||
* app_settings stores JSON-encoded values, but rows written by older code
|
||||
* paths (and by SQLite's looser typing) can arrive as raw strings. Accept
|
||||
* both, and return undefined for anything that isn't recognisably a boolean
|
||||
* so the caller falls back rather than persisting `null` into a NOT NULL-ish
|
||||
* boolean column.
|
||||
*/
|
||||
function parseBooleanSetting(rawValue) {
|
||||
let value = rawValue;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'boolean') return value;
|
||||
// SQLite hands back 0/1 for booleans written through formatBoolean.
|
||||
if (value === 1 || value === 0) return value === 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseKeybindModeSetting(rawValue) {
|
||||
let value = rawValue;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed === 'string') value = parsed;
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
}
|
||||
return KEYBIND_MODES.includes(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the global feedback defaults in ONE query (mirrors
|
||||
* getBrandingDefaults' batched whereIn rather than firing seven `.first()`
|
||||
* calls on every event creation).
|
||||
*
|
||||
* Never throws: a settings-table hiccup must not fail event creation, so the
|
||||
* built-in fallbacks stand in.
|
||||
*
|
||||
* @returns {Promise<{allow_ratings: boolean, allow_likes: boolean,
|
||||
* allow_favorites: boolean, allow_comments: boolean, allow_reactions: boolean,
|
||||
* allow_color_labels: boolean, keybind_mode: string}>}
|
||||
*/
|
||||
async function resolveEventFeedbackDefaults() {
|
||||
const defaults = {};
|
||||
for (const toggle of FEEDBACK_TOGGLES) {
|
||||
defaults[toggle.column] = toggle.fallback;
|
||||
}
|
||||
defaults.keybind_mode = DEFAULT_KEYBIND_MODE;
|
||||
|
||||
try {
|
||||
const rows = await db('app_settings')
|
||||
.whereIn('setting_key', FEEDBACK_DEFAULT_SETTING_KEYS)
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const bySettingKey = new Map(rows.map((row) => [row.setting_key, row.setting_value]));
|
||||
|
||||
for (const toggle of FEEDBACK_TOGGLES) {
|
||||
if (!bySettingKey.has(toggle.settingKey)) continue;
|
||||
const parsed = parseBooleanSetting(bySettingKey.get(toggle.settingKey));
|
||||
if (parsed !== undefined) defaults[toggle.column] = parsed;
|
||||
}
|
||||
|
||||
if (bySettingKey.has(KEYBIND_MODE_SETTING_KEY)) {
|
||||
const parsed = parseKeybindModeSetting(bySettingKey.get(KEYBIND_MODE_SETTING_KEY));
|
||||
if (parsed !== undefined) defaults.keybind_mode = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to read global feedback defaults, using built-ins', {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an explicit request body over the resolved globals. A value the caller
|
||||
* actually sent always wins; `undefined` (the field was omitted) inherits.
|
||||
*
|
||||
* @param {Object} body - the create-request payload
|
||||
* @param {Object} globals - output of resolveEventFeedbackDefaults()
|
||||
*/
|
||||
function applyFeedbackDefaults(body = {}, globals) {
|
||||
const resolved = { ...globals };
|
||||
for (const toggle of FEEDBACK_TOGGLES) {
|
||||
if (body[toggle.column] !== undefined) {
|
||||
resolved[toggle.column] = body[toggle.column];
|
||||
}
|
||||
}
|
||||
if (KEYBIND_MODES.includes(body.keybind_mode)) {
|
||||
resolved.keybind_mode = body.keybind_mode;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FEEDBACK_TOGGLES,
|
||||
FEEDBACK_DEFAULT_SETTING_KEYS,
|
||||
KEYBIND_MODES,
|
||||
KEYBIND_MODE_SETTING_KEY,
|
||||
DEFAULT_KEYBIND_MODE,
|
||||
resolveEventFeedbackDefaults,
|
||||
applyFeedbackDefaults,
|
||||
};
|
||||
@@ -2,6 +2,8 @@ const { db, logActivity } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { REACTION_EMOJIS } = require('../constants/reactions');
|
||||
const { isValidColorLabel } = require('../constants/colorLabels');
|
||||
const { resolveEventFeedbackDefaults, DEFAULT_KEYBIND_MODE, KEYBIND_MODES } = require('./feedbackDefaults');
|
||||
|
||||
// Every writable column on event_feedback_settings (#1030). The admin form
|
||||
// posts its whole client-side state back, including UI-only keys that were
|
||||
@@ -17,6 +19,8 @@ const FEEDBACK_SETTINGS_COLUMNS = [
|
||||
'allow_comments',
|
||||
'allow_favorites',
|
||||
'allow_reactions',
|
||||
'allow_color_labels',
|
||||
'keybind_mode',
|
||||
'require_name_email',
|
||||
'moderate_comments',
|
||||
'require_moderation',
|
||||
@@ -26,6 +30,16 @@ const FEEDBACK_SETTINGS_COLUMNS = [
|
||||
'max_likes_per_guest'
|
||||
];
|
||||
|
||||
/**
|
||||
* Feedback types that store exactly ONE value per guest per photo, and the
|
||||
* column each keeps it in. Both share the toggle-off / switch semantics in
|
||||
* submitFeedback below.
|
||||
*/
|
||||
const SINGLE_VALUE_COLUMNS = {
|
||||
reaction: 'reaction',
|
||||
color_label: 'color_label',
|
||||
};
|
||||
|
||||
function pickSettingsColumns(settings) {
|
||||
const picked = {};
|
||||
for (const column of FEEDBACK_SETTINGS_COLUMNS) {
|
||||
@@ -47,15 +61,15 @@ class FeedbackService {
|
||||
.first();
|
||||
|
||||
if (!settings) {
|
||||
// Return default settings if none exist
|
||||
// No row = feedback was never enabled for this event. The sub-toggles
|
||||
// still matter: they are the state the admin's feedback panel opens
|
||||
// with. Read them from the same global defaults the create routes use
|
||||
// (#1044) instead of a fourth hard-coded copy that drifts from them.
|
||||
const globals = await resolveEventFeedbackDefaults();
|
||||
return {
|
||||
event_id: eventId,
|
||||
feedback_enabled: false,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: false,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
...globals,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
@@ -69,6 +83,12 @@ class FeedbackService {
|
||||
if (!settings.identity_mode) {
|
||||
settings.identity_mode = 'simple';
|
||||
}
|
||||
// Rows created before migration 180 have NULL keybind_mode. An
|
||||
// unrecognised value gets the same treatment — the lightbox switches on
|
||||
// this string and must never receive something it has no scheme for.
|
||||
if (!KEYBIND_MODES.includes(settings.keybind_mode)) {
|
||||
settings.keybind_mode = DEFAULT_KEYBIND_MODE;
|
||||
}
|
||||
// Per-guest caps (#655). NULL on existing rows = unlimited; the route
|
||||
// layer treats null/0/missing identically.
|
||||
settings.max_favorites_per_guest = settings.max_favorites_per_guest ?? null;
|
||||
@@ -139,10 +159,10 @@ class FeedbackService {
|
||||
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, reaction, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
const { feedback_type, rating, comment_text, reaction, color_label, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite', 'reaction'].includes(feedback_type)) {
|
||||
if (!['rating', 'like', 'comment', 'favorite', 'reaction', 'color_label'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
@@ -152,6 +172,11 @@ class FeedbackService {
|
||||
throw new Error('Invalid reaction');
|
||||
}
|
||||
|
||||
// Same contract for colour labels (#1044).
|
||||
if (feedback_type === 'color_label' && !isValidColorLabel(color_label)) {
|
||||
throw new Error('Invalid color label');
|
||||
}
|
||||
|
||||
// Rating 0 clears the guest's rating (#884). Only the explicit zero
|
||||
// sentinel (0, or "0" from callers that skip the route validator's
|
||||
// toInt) triggers the destructive path — malformed input (undefined,
|
||||
@@ -209,35 +234,41 @@ class FeedbackService {
|
||||
return { id: existing.id, updated: true };
|
||||
}
|
||||
|
||||
// One reaction per guest per photo, changeable (#839): the same
|
||||
// emoji again toggles it off; a different one switches the row.
|
||||
// Single-value types — one reaction (#839) and one colour label
|
||||
// (#1044) per guest per photo, both changeable: submitting the
|
||||
// current value again toggles it off, a different one switches the
|
||||
// row. Shared so the two paths can't drift.
|
||||
//
|
||||
// Toggle/switch act on the full guest-scoped QUERY, not existing.id:
|
||||
// like the sibling like path, the check-then-insert above can race
|
||||
// into duplicate rows — operating on the set makes the next
|
||||
// interaction collapse them instead of leaving a phantom count.
|
||||
const reactionScope = () => {
|
||||
const q = db('photo_feedback').where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type: 'reaction',
|
||||
});
|
||||
if (guest_id) q.where('guest_id', guest_id);
|
||||
else q.where('guest_identifier', guestIdentifier);
|
||||
return q;
|
||||
};
|
||||
if (feedback_type === 'reaction') {
|
||||
if (existing.reaction === reaction) {
|
||||
await reactionScope().delete();
|
||||
const singleValueColumn = SINGLE_VALUE_COLUMNS[feedback_type];
|
||||
if (singleValueColumn) {
|
||||
const submittedValue = feedback_type === 'reaction' ? reaction : color_label;
|
||||
const singleValueScope = () => {
|
||||
const q = db('photo_feedback').where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
});
|
||||
if (guest_id) q.where('guest_id', guest_id);
|
||||
else q.where('guest_identifier', guestIdentifier);
|
||||
return q;
|
||||
};
|
||||
|
||||
if (existing[singleValueColumn] === submittedValue) {
|
||||
await singleValueScope().delete();
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
return { removed: true };
|
||||
}
|
||||
// Converge to exactly one row: drop any racy duplicates, then
|
||||
// switch the surviving row's emoji.
|
||||
await reactionScope().whereNot('id', existing.id).delete();
|
||||
// switch the surviving row's value.
|
||||
await singleValueScope().whereNot('id', existing.id).delete();
|
||||
await db('photo_feedback')
|
||||
.where('id', existing.id)
|
||||
.update({
|
||||
reaction,
|
||||
[singleValueColumn]: submittedValue,
|
||||
updated_at: new Date()
|
||||
});
|
||||
await this.updatePhotoFeedbackStats(photoId);
|
||||
@@ -300,6 +331,7 @@ class FeedbackService {
|
||||
rating: feedback_type === 'rating' ? rating : null,
|
||||
comment_text: feedback_type === 'comment' ? comment_text : null,
|
||||
reaction: feedback_type === 'reaction' ? reaction : null,
|
||||
color_label: feedback_type === 'color_label' ? color_label : null,
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
@@ -352,7 +384,7 @@ class FeedbackService {
|
||||
|
||||
const feedback = await query
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||
.select('id', 'feedback_type', 'rating', 'comment_text', 'reaction', 'color_label', 'guest_name', 'created_at', 'is_approved', 'is_hidden');
|
||||
|
||||
return feedback;
|
||||
} catch (error) {
|
||||
@@ -368,7 +400,7 @@ class FeedbackService {
|
||||
try {
|
||||
const photos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count')
|
||||
.select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count', 'reaction_count', 'color_label_count')
|
||||
.orderBy('average_rating', 'desc')
|
||||
.orderBy('like_count', 'desc');
|
||||
|
||||
@@ -380,7 +412,8 @@ class FeedbackService {
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction'])
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_reactions', ['reaction']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_color_labels', ['color_label'])
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -419,6 +452,67 @@ class FeedbackService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-colour tallies for one photo (#1044) — the colour-label sibling of
|
||||
* getPhotoReactionCounts.
|
||||
*/
|
||||
async getPhotoColorLabelCounts(photoId) {
|
||||
try {
|
||||
const rows = await db('photo_feedback')
|
||||
.where({ photo_id: photoId, feedback_type: 'color_label' })
|
||||
.where('is_hidden', false)
|
||||
.groupBy('color_label')
|
||||
.select('color_label')
|
||||
.count('id as count');
|
||||
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
if (row.color_label) counts[row.color_label] = Number(row.count) || 0;
|
||||
}
|
||||
return counts;
|
||||
} catch (error) {
|
||||
logger.error('Error getting photo color label counts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-colour tallies for every labelled photo in an event, keyed by photo
|
||||
* id (#1044). One grouped query — the admin grid needs this for a whole
|
||||
* page of photos at once, and the per-photo helper above would be N+1.
|
||||
*
|
||||
* @param {number} eventId
|
||||
* @param {number[]} [photoIds] - optional narrowing to the visible page
|
||||
* @returns {Promise<Object>} { [photoId]: { green: 2, red: 1 } }
|
||||
*/
|
||||
async getEventColorLabelCounts(eventId, photoIds = null) {
|
||||
try {
|
||||
const query = db('photo_feedback')
|
||||
.where({ event_id: eventId, feedback_type: 'color_label' })
|
||||
.where('is_hidden', false)
|
||||
.groupBy('photo_id', 'color_label')
|
||||
.select('photo_id', 'color_label')
|
||||
.count('id as count');
|
||||
|
||||
if (Array.isArray(photoIds)) {
|
||||
if (photoIds.length === 0) return {};
|
||||
query.whereIn('photo_id', photoIds);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
const byPhoto = {};
|
||||
for (const row of rows) {
|
||||
if (!row.color_label) continue;
|
||||
if (!byPhoto[row.photo_id]) byPhoto[row.photo_id] = {};
|
||||
byPhoto[row.photo_id][row.color_label] = Number(row.count) || 0;
|
||||
}
|
||||
return byPhoto;
|
||||
} catch (error) {
|
||||
logger.error('Error getting event color label counts:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update photo feedback statistics
|
||||
*/
|
||||
@@ -433,6 +527,7 @@ class FeedbackService {
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as reaction_count', ['reaction']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as color_label_count', ['color_label']),
|
||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||
)
|
||||
@@ -446,7 +541,8 @@ class FeedbackService {
|
||||
like_count: stats.like_count || 0,
|
||||
average_rating: stats.average_rating || 0,
|
||||
favorite_count: stats.favorite_count || 0,
|
||||
reaction_count: stats.reaction_count || 0
|
||||
reaction_count: stats.reaction_count || 0,
|
||||
color_label_count: stats.color_label_count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo feedback stats:', error);
|
||||
@@ -587,6 +683,7 @@ class FeedbackService {
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.color_label',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.created_at'
|
||||
@@ -625,6 +722,7 @@ class FeedbackService {
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.reaction',
|
||||
'photo_feedback.color_label',
|
||||
'photo_feedback.guest_name',
|
||||
'photo_feedback.guest_email',
|
||||
'photo_feedback.guest_identifier',
|
||||
@@ -651,6 +749,7 @@ class FeedbackService {
|
||||
star_rating: '',
|
||||
comment: '',
|
||||
reaction: '',
|
||||
color_label: '',
|
||||
latest_at: row.created_at,
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
@@ -680,6 +779,9 @@ class FeedbackService {
|
||||
case 'reaction':
|
||||
if (row.reaction) entry.reaction = row.reaction;
|
||||
break;
|
||||
case 'color_label':
|
||||
if (row.color_label) entry.color_label = row.color_label;
|
||||
break;
|
||||
default:
|
||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The photographer's own stars and colour labels (#1044 follow-up).
|
||||
*
|
||||
* Deliberately separate from photo_feedback — see the migration-181 header for
|
||||
* why. Nothing in here is ever read by a guest-facing route: admin marks show
|
||||
* on admin surfaces and in exports, and the client's proofing view never sees
|
||||
* what the photographer thought.
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { isValidColorLabel } = require('../constants/colorLabels');
|
||||
|
||||
/**
|
||||
* A mark the caller got wrong, as opposed to something that went wrong.
|
||||
*
|
||||
* Carries a `code` so the route can map it to a 400 without matching on the
|
||||
* message text — message-matching couples the status code to this file's copy,
|
||||
* and a reworded string would silently turn a 400 into a 500.
|
||||
*/
|
||||
const INVALID_MARK = 'INVALID_MARK';
|
||||
function invalidMark(message) {
|
||||
return Object.assign(new Error(message), { code: INVALID_MARK });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set, change or clear an admin's mark on one photo.
|
||||
*
|
||||
* `rating` and `colorLabel` are tri-state: `undefined` leaves that half of the
|
||||
* mark alone, `null` clears it, a value sets it. That is what lets the
|
||||
* lightbox's colour keys and star keys write independently without each
|
||||
* wiping the other.
|
||||
*
|
||||
* @param {number} eventId
|
||||
* @param {number} photoId
|
||||
* @param {number} adminId
|
||||
* @param {{rating?: number|null, colorLabel?: string|null}} mark
|
||||
* @returns {Promise<{rating: number|null, color_label: string|null}|null>}
|
||||
* the resulting mark, or null when it was cleared entirely
|
||||
*/
|
||||
async function setMark(eventId, photoId, adminId, { rating, colorLabel } = {}) {
|
||||
if (rating !== undefined && rating !== null) {
|
||||
const asInt = Number(rating);
|
||||
if (!Number.isInteger(asInt) || asInt < 1 || asInt > 5) {
|
||||
throw invalidMark('Rating must be between 1 and 5');
|
||||
}
|
||||
}
|
||||
if (colorLabel !== undefined && colorLabel !== null && !isValidColorLabel(colorLabel)) {
|
||||
throw invalidMark('Invalid color label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ONLY the halves this call was asked about, onto the row as it
|
||||
* stands right now.
|
||||
*
|
||||
* The obvious version — recompute both halves from a pre-read and write the
|
||||
* pair — loses a concurrent write: press 4 then 9 on a photo that already
|
||||
* has a mark and both calls read the old row, both write {rating,
|
||||
* color_label}, and the second silently clobbers the first with its stale
|
||||
* value. Not writing the untouched column at all means there is nothing to
|
||||
* lose, and no race machinery is needed to achieve it.
|
||||
*/
|
||||
const applyToRow = async (row) => {
|
||||
const now = new Date().toISOString();
|
||||
const patch = { updated_at: now };
|
||||
if (rating !== undefined) patch.rating = rating === null ? null : Number(rating);
|
||||
if (colorLabel !== undefined) patch.color_label = colorLabel;
|
||||
|
||||
await db('photo_admin_marks').where('id', row.id).update(patch);
|
||||
|
||||
// A mark with neither half left is deleted, not kept as an empty row — an
|
||||
// empty row would still count as "marked" to anything testing existence.
|
||||
//
|
||||
// The emptiness test is a predicate ON the delete rather than a re-read
|
||||
// followed by a delete: a concurrent call that just filled a half back in
|
||||
// must not have its value dropped by our stale view of the row.
|
||||
const removed = await db('photo_admin_marks')
|
||||
.where('id', row.id)
|
||||
.whereNull('rating')
|
||||
.whereNull('color_label')
|
||||
.delete();
|
||||
if (removed) return null;
|
||||
|
||||
const after = await db('photo_admin_marks').where('id', row.id).first();
|
||||
if (!after) return null;
|
||||
return { rating: after.rating ?? null, color_label: after.color_label ?? null };
|
||||
};
|
||||
|
||||
const existing = await db('photo_admin_marks')
|
||||
.where({ photo_id: photoId, admin_id: adminId })
|
||||
.first();
|
||||
|
||||
if (existing) return applyToRow(existing);
|
||||
|
||||
// No row yet, so there is nothing for a clear-only call to clear.
|
||||
const fresh = {
|
||||
rating: rating === undefined || rating === null ? null : Number(rating),
|
||||
color_label: colorLabel === undefined || colorLabel === null ? null : colorLabel,
|
||||
};
|
||||
if (fresh.rating === null && fresh.color_label === null) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await db('photo_admin_marks').insert({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
admin_id: adminId,
|
||||
...fresh,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
} catch (error) {
|
||||
// The read above and this insert are not atomic, and a triage pass fires
|
||||
// them back to back — press 4 then 9 on an UNMARKED photo and both
|
||||
// requests can find no row and both try to insert. The unique index stops
|
||||
// the duplicate, which would otherwise surface as a 500 and a lost
|
||||
// keystroke; converge onto the row the winner created, through the same
|
||||
// write-only-what-you-addressed path as any other update.
|
||||
const raced = await db('photo_admin_marks')
|
||||
.where({ photo_id: photoId, admin_id: adminId })
|
||||
.first();
|
||||
if (!raced) throw error;
|
||||
return applyToRow(raced);
|
||||
}
|
||||
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* One admin's marks across an event, keyed by photo id. Optionally narrowed to
|
||||
* the ids on the current page.
|
||||
*
|
||||
* @returns {Promise<Object>} { [photoId]: { rating, color_label } }
|
||||
*/
|
||||
async function getEventMarks(eventId, adminId, photoIds = null) {
|
||||
try {
|
||||
const query = db('photo_admin_marks')
|
||||
.where({ event_id: eventId, admin_id: adminId })
|
||||
.select('photo_id', 'rating', 'color_label');
|
||||
|
||||
if (Array.isArray(photoIds)) {
|
||||
if (photoIds.length === 0) return {};
|
||||
query.whereIn('photo_id', photoIds);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
const byPhoto = {};
|
||||
for (const row of rows) {
|
||||
byPhoto[row.photo_id] = {
|
||||
rating: row.rating ?? null,
|
||||
color_label: row.color_label ?? null,
|
||||
};
|
||||
}
|
||||
return byPhoto;
|
||||
} catch (error) {
|
||||
logger.error('Error reading admin photo marks:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-colour photo counts for one admin's marks — drives the counts on the
|
||||
* "My marks" filter chips.
|
||||
*/
|
||||
async function getEventMarkColorCounts(eventId, adminId) {
|
||||
try {
|
||||
const rows = await db('photo_admin_marks')
|
||||
.where({ event_id: eventId, admin_id: adminId })
|
||||
.whereNotNull('color_label')
|
||||
.groupBy('color_label')
|
||||
.select('color_label')
|
||||
.count('id as count');
|
||||
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
counts[row.color_label] = parseInt(row.count, 10) || 0;
|
||||
}
|
||||
return counts;
|
||||
} catch (error) {
|
||||
logger.error('Error counting admin photo marks:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setMark, getEventMarks, getEventMarkColorCounts, INVALID_MARK };
|
||||
@@ -6,6 +6,9 @@
|
||||
const archiver = require('archiver');
|
||||
const { PassThrough } = require('stream');
|
||||
const { XmpGenerator } = require('./xmpGenerator');
|
||||
const { dominantColorLabel } = require('../constants/colorLabels');
|
||||
const photoAdminMarksService = require('./photoAdminMarksService');
|
||||
const feedbackService = require('./feedbackService');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
@@ -22,7 +25,7 @@ class PhotoExportService {
|
||||
* @param {number[]} photoIds - Photo IDs to export (optional, exports all if not provided)
|
||||
* @returns {Promise<Object[]>} Photos with feedback
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
async getPhotosWithFeedback(eventId, photoIds = null, adminId = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
@@ -36,6 +39,7 @@ class PhotoExportService {
|
||||
'photos.like_count',
|
||||
'photos.favorite_count',
|
||||
'photos.comment_count',
|
||||
'photos.color_label_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.size_bytes',
|
||||
@@ -48,7 +52,33 @@ class PhotoExportService {
|
||||
query = query.whereIn('photos.id', photoIds);
|
||||
}
|
||||
|
||||
return await query;
|
||||
const photos = await query;
|
||||
|
||||
// Colour labels (#1044). One grouped query for the whole export, then
|
||||
// each row carries both the per-colour tallies and the single colour the
|
||||
// XMP sidecar should claim.
|
||||
const colorCounts = await feedbackService.getEventColorLabelCounts(
|
||||
eventId,
|
||||
photos.map(p => p.id),
|
||||
);
|
||||
for (const photo of photos) {
|
||||
photo.color_labels = colorCounts[photo.id] || {};
|
||||
photo.dominant_color_label = dominantColorLabel(photo.color_labels);
|
||||
}
|
||||
|
||||
// The exporting photographer's own marks (#1044 follow-up), so a triage
|
||||
// pass can leave the app as XMP the same way a client selection can.
|
||||
if (adminId) {
|
||||
const marks = await photoAdminMarksService.getEventMarks(
|
||||
eventId, adminId, photos.map(p => p.id),
|
||||
);
|
||||
for (const photo of photos) {
|
||||
photo.my_rating = marks[photo.id]?.rating ?? null;
|
||||
photo.my_color_label = marks[photo.id]?.color_label ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +90,9 @@ class PhotoExportService {
|
||||
* @returns {Promise<Object>} Export result with stream/content
|
||||
*/
|
||||
async exportPhotos(eventId, photoIds, format, options = {}) {
|
||||
const photos = await this.getPhotosWithFeedback(eventId, photoIds);
|
||||
// admin_id is set by the route from the session, never taken from the
|
||||
// request body — it decides whose marks the export carries.
|
||||
const photos = await this.getPhotosWithFeedback(eventId, photoIds, options.admin_id || null);
|
||||
|
||||
if (photos.length === 0) {
|
||||
throw new Error('No photos to export');
|
||||
@@ -139,6 +171,9 @@ class PhotoExportService {
|
||||
'likes',
|
||||
'favorites',
|
||||
'comments',
|
||||
'color_label',
|
||||
'my_rating',
|
||||
'my_color_label',
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
@@ -154,6 +189,9 @@ class PhotoExportService {
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
photo.comment_count || 0,
|
||||
photo.dominant_color_label || '',
|
||||
photo.my_rating ?? '',
|
||||
photo.my_color_label || '',
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
@@ -181,7 +219,17 @@ class PhotoExportService {
|
||||
* Export as XMP sidecar files in a ZIP archive
|
||||
*/
|
||||
async exportAsXmpZip(photos, options = {}) {
|
||||
const { filename_format = 'original' } = options;
|
||||
const { filename_format = 'original', mark_source = 'client' } = options;
|
||||
|
||||
// Whose verdict the sidecar carries (#1044 follow-up). Default 'client'
|
||||
// keeps existing exports identical; 'mine' writes the photographer's own
|
||||
// triage instead, which is the point of being able to mark at all.
|
||||
const project = (photo) => (mark_source !== 'mine' ? photo : {
|
||||
...photo,
|
||||
average_rating: photo.my_rating || 0,
|
||||
dominant_color_label: photo.my_color_label || null,
|
||||
color_labels: {},
|
||||
});
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const passthrough = new PassThrough();
|
||||
@@ -192,7 +240,7 @@ class PhotoExportService {
|
||||
? (photo.original_filename || photo.filename)
|
||||
: photo.filename;
|
||||
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(project(photo), options);
|
||||
|
||||
archive.append(xmpContent, { name: xmpFilename });
|
||||
}
|
||||
@@ -237,6 +285,10 @@ class PhotoExportService {
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_count || 0,
|
||||
comments: photo.comment_count || 0,
|
||||
color_label: photo.dominant_color_label || null,
|
||||
color_labels: photo.color_labels || {},
|
||||
my_rating: photo.my_rating ?? null,
|
||||
my_color_label: photo.my_color_label || null,
|
||||
dimensions: {
|
||||
width: photo.width || null,
|
||||
height: photo.height || null
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Generates Adobe XMP metadata files for photos with guest feedback
|
||||
*/
|
||||
|
||||
const { COLOR_LABEL_TO_XMP, dominantColorLabel } = require('../constants/colorLabels');
|
||||
|
||||
class XmpGenerator {
|
||||
/**
|
||||
* Generate XMP sidecar content for a photo
|
||||
@@ -19,7 +21,7 @@ class XmpGenerator {
|
||||
} = options;
|
||||
|
||||
const rating = include_rating ? this.mapRating(photo.average_rating) : 0;
|
||||
const label = include_label ? this.mapLabel(photo.average_rating) : null;
|
||||
const label = include_label ? this.mapLabel(photo) : null;
|
||||
|
||||
const descriptionXml = include_description ? this.generateDescription(photo) : '';
|
||||
const keywordsXml = include_keywords ? this.generateKeywords(photo) : '';
|
||||
@@ -57,11 +59,39 @@ class XmpGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map PicPeak rating to XMP color label
|
||||
* Resolve the photo's XMP colour label.
|
||||
*
|
||||
* A real colour label the client set while proofing (#1044) always wins:
|
||||
* that is the whole point of using Lightroom's colour set, and it is an
|
||||
* explicit choice rather than something inferred. Only when a photo has no
|
||||
* label does this fall back to the historical rating-derived mapping, so
|
||||
* exports for events that never enabled colour labels are unchanged.
|
||||
*
|
||||
* @param {Object} photo - photo row, may carry dominant_color_label
|
||||
* @returns {string|null} XMP label colour
|
||||
*/
|
||||
mapLabel(photo) {
|
||||
// Tolerate being handed a bare rating: this used to take one, and
|
||||
// callers outside the export service may still do so.
|
||||
if (typeof photo === 'number' || photo === null || photo === undefined) {
|
||||
return this.mapRatingToLabel(photo);
|
||||
}
|
||||
|
||||
const colorLabel = photo.dominant_color_label
|
||||
|| dominantColorLabel(photo.color_labels);
|
||||
if (colorLabel && COLOR_LABEL_TO_XMP[colorLabel]) {
|
||||
return COLOR_LABEL_TO_XMP[colorLabel];
|
||||
}
|
||||
|
||||
return this.mapRatingToLabel(photo.average_rating);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-#1044 mapping: infer a colour from the average star rating.
|
||||
* @param {number} avgRating - Average rating
|
||||
* @returns {string|null} XMP label color
|
||||
*/
|
||||
mapLabel(avgRating) {
|
||||
mapRatingToLabel(avgRating) {
|
||||
if (!avgRating || avgRating === 0) return null;
|
||||
if (avgRating >= 4.5) return 'Red'; // Top picks
|
||||
if (avgRating >= 3.5) return 'Yellow'; // Good
|
||||
@@ -80,7 +110,9 @@ class XmpGenerator {
|
||||
const likes = photo.like_count || 0;
|
||||
const favorites = photo.favorite_count || 0;
|
||||
|
||||
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites`;
|
||||
const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
|
||||
const colorPart = colorLabel ? `, ${colorLabel} label` : '';
|
||||
const desc = `PicPeak Guest Feedback: ${rating} stars, ${likes} likes, ${favorites} favorites${colorPart}`;
|
||||
|
||||
return `<dc:description>
|
||||
<rdf:Alt>
|
||||
@@ -113,6 +145,14 @@ class XmpGenerator {
|
||||
keywords.push('favorited');
|
||||
}
|
||||
|
||||
// A searchable keyword for the client's colour choice (#1044) — Lightroom
|
||||
// can filter on xmp:Label directly, Bridge and Capture One users often
|
||||
// find keywords easier.
|
||||
const colorLabel = photo.dominant_color_label || dominantColorLabel(photo.color_labels);
|
||||
if (colorLabel) {
|
||||
keywords.push(`color-${colorLabel}`);
|
||||
}
|
||||
|
||||
if (photo.category_name) {
|
||||
keywords.push(this.sanitizeKeyword(photo.category_name));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user