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:
Luca
2026-08-23 11:15:01 +02:00
committed by GitHub
parent 7b77bbf243
commit e2844d1909
60 changed files with 3912 additions and 182 deletions
+170
View File
@@ -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,
};
+132 -30
View File
@@ -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 };
+57 -5
View File
@@ -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
+44 -4
View File
@@ -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));
}