mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
fix journey bugs reported by roel-de-vries (#722-#736)
Mobile UI: - #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var) - #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries - #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA - #725 add back/settings buttons + journey title subtitle to mobile activity view - #726 active entry re-centers after scroll settle; tap inactive card activates it (does not jump straight into editor) Entry editor flow: - #727 photo uploads queue locally until Save for existing entries too (previously fired upload immediately; Cancel silently kept the new photo) - #728 Cancel/Close with unsaved changes now requires confirm (window.confirm) - #729 linking a Gallery photo into an entry now copies the row (old MOVE behavior meant Remove-from-Entry also nuked the Gallery original) - #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton entries to concrete 'entry' type when content is added Permissions: - #732 updateJourney switched from canEdit to isOwner — editors can still edit entries and photos, just not the journey shell (title, cover, status) - #733 Contributors list gains a per-row remove (X) control with confirm - #734 my_role is computed server-side and returned with the journey; UI gates Settings/Add/Edit/Delete controls based on role - #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require isOwner (previously NO permission check at all — anyone authenticated could publish or unpublish a journey) Immich upload (#730): - migration 111: add users.immich_auto_upload (default 0) - migration 112: seed provider_field for the toggle (idempotent, FK-safe) - journey photo upload only mirrors to Immich when the user has opted in - Settings UI gets a "Mirror journey photos to Immich on upload" checkbox Test updates: - JOURNEY-SVC-019 inverted to assert editor cannot update journey settings - JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink - FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save - client/tests still green (2676/2676) Also fixed en route: gallery entry title is now the literal 'Gallery' on the wire (used to send the translated label, which broke server-side title === 'Gallery' checks in non-English locales); confirm interpolation uses {username} single braces matching the existing i18n runtime; Settings footer uses icon-only delete/archive buttons on mobile so the row doesn't wrap.
This commit is contained in:
@@ -167,6 +167,19 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
||||
|
||||
// Determine the viewer's role on this journey so the UI can gate edit/settings
|
||||
// actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors.
|
||||
const journeyRow = journey as unknown as { user_id?: number };
|
||||
let myRole: 'owner' | 'editor' | 'viewer' | null = null;
|
||||
if (journeyRow.user_id === userId) {
|
||||
myRole = 'owner';
|
||||
} else {
|
||||
const contribRow = db.prepare(
|
||||
'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined;
|
||||
myRole = contribRow?.role ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...journey,
|
||||
entries: enrichedEntries,
|
||||
@@ -174,6 +187,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
my_role: myRole,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,7 +198,9 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
cover_image: string;
|
||||
status: string;
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
// Journey-level settings (title, cover, status) are owner-only — editors
|
||||
// may only edit entries and photos, not reshape the journey itself.
|
||||
if (!isOwner(journeyId, userId)) return null;
|
||||
|
||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||
@@ -615,6 +631,14 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Promote a skeleton suggestion to a concrete entry. Called whenever the user
|
||||
// adds content (photo upload, provider photo, gallery link) — a suggestion
|
||||
// with photos is no longer just a suggestion.
|
||||
function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
|
||||
if (entry.type !== 'skeleton') return;
|
||||
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
|
||||
}
|
||||
|
||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
@@ -629,6 +653,8 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
@@ -651,6 +677,8 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
@@ -664,21 +692,41 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
|
||||
const oldEntryId = source.entry_id;
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
|
||||
const sourceIsGallery = oldEntry?.title === 'Gallery';
|
||||
|
||||
// move photo to the target entry
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
// skip if target already has this photo (by trek_photo_id)
|
||||
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
|
||||
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
|
||||
|
||||
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
|
||||
if (oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
let resultId: number;
|
||||
|
||||
if (sourceIsGallery) {
|
||||
// Copy so the photo stays in the gallery even after being used in an entry.
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
|
||||
resultId = Number(res.lastInsertRowid);
|
||||
} else {
|
||||
// Non-gallery source: keep existing move behavior.
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
resultId = photoId;
|
||||
}
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
// If we moved out of a Gallery entry (shouldn't happen with the guard above,
|
||||
// but kept for any legacy data), clean up the Gallery wrapper if emptied.
|
||||
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
|
||||
if (remaining.c === 0) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { isOwner } from './journeyService';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
@@ -19,7 +20,11 @@ export function createOrUpdateJourneyShareLink(
|
||||
journeyId: number,
|
||||
createdBy: number,
|
||||
permissions: JourneySharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
): { token: string; created: boolean } | null {
|
||||
// Public sharing is an owner-only action — editors/viewers must not be
|
||||
// able to publish the journey or change which screens are shared.
|
||||
if (!isOwner(journeyId, createdBy)) return null;
|
||||
|
||||
const {
|
||||
share_timeline = true,
|
||||
share_gallery = true,
|
||||
@@ -51,8 +56,10 @@ export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo |
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteJourneyShareLink(journeyId: number): void {
|
||||
export function deleteJourneyShareLink(journeyId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||
|
||||
@@ -25,12 +25,18 @@ export function isValidAssetId(id: string): boolean {
|
||||
|
||||
export function getConnectionSettings(userId: number) {
|
||||
const creds = getImmichCredentials(userId);
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||
return {
|
||||
immich_url: creds?.immich_url || '',
|
||||
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
||||
auto_upload: !!(prefs?.immich_auto_upload),
|
||||
};
|
||||
}
|
||||
|
||||
export function setImmichAutoUpload(userId: number, enabled: boolean): void {
|
||||
db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||
}
|
||||
|
||||
export async function saveImmichSettings(
|
||||
userId: number,
|
||||
immichUrl: string | undefined,
|
||||
|
||||
Reference in New Issue
Block a user