refactor(memories): unified, immich and synology photo sync through repositories

This commit is contained in:
jubnl
2026-09-23 18:38:36 +02:00
parent 5453836a06
commit bceaf4b134
14 changed files with 857 additions and 128 deletions
@@ -30,4 +30,29 @@ export class PhotoProvidersRepository extends TrekRepository<PhotoProviders> {
const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as PhotoProviderRow);
}
/**
* UM1 (`UnifiedMemoriesService._providers`) — `SELECT id, enabled FROM
* photo_providers` (`.all()`, no WHERE, no ORDER BY): every provider row,
* enabled or not — the caller does its own journey-addon-gated filtering
* in JS afterward. Distinct from {@link listEnabled} above, which is a
* different legacy statement (WHERE-filtered, ordered) for a different
* caller (the admin listing); do not collapse the two (D4).
*/
async listAll(): Promise<Array<{ id: string; enabled: number }>> {
const rows = await this.find({}, { fields: ['id', 'enabled'] });
return rows.map((row) => ({ id: row.id ?? '', enabled: row.enabled ?? 0 }));
}
/** MMC1 (`MemoriesMcp.enabledProviderIds`) — `SELECT id FROM photo_providers WHERE enabled = 1`, no ORDER BY (unlike {@link listEnabled}'s admin-listing statement). */
async listEnabledIds(): Promise<string[]> {
const rows = await this.find({ enabled: 1 }, { fields: ['id'] });
return rows.map((row) => row.id).filter((id): id is string => id != null);
}
/** MMC2 (`MemoriesMcp.providerRefusal`) — `SELECT enabled FROM photo_providers WHERE id = ?`. */
async findEnabled(id: string): Promise<{ enabled: number | null } | null> {
const row = await this.findOne({ id }, { fields: ['enabled'] });
return row ? { enabled: row.enabled ?? null } : null;
}
}
@@ -8,6 +8,73 @@ export interface TripAlbumLinkSyncRow {
passphrase: string | null;
}
/**
* `trip_album_links`'s single-table write shape (Plan 3e Task 7,
* UM7/UM10). `trip_id`/`id` are typed `number | string` — the legacy
* statements bound `tripId`/`linkId` raw (T5's raw-bind seam), including the
* primary-key `id` column in UM10's `DELETE ... WHERE id = ?`.
*/
interface TripAlbumLinksWriteKyselyDB {
trip_album_links: {
id: number | string;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
passphrase: string | null;
};
}
/**
* UM7's insert-only shape — `id`/`sync_enabled`/`last_synced_at`/`created_at`
* are autoincrement/defaulted and omitted from `.values()` (the legacy
* statement's own column list omits them too), so a SEPARATE interface from
* {@link TripAlbumLinksWriteKyselyDB} (the `FileLinksWriteKyselyDB` /
* `TripPhotosInsertKyselyDB` precedent) — that one declares `id` as
* required, which `.insertInto()` would then also require in `.values()`.
*/
interface TripAlbumLinksInsertKyselyDB {
trip_album_links: {
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
passphrase: string | null;
};
}
/** UM3's joined projection (`UnifiedMemoriesService.listTripAlbumLinks`) — own Kysely shape, one purpose-shaped interface per statement. */
interface TripAlbumLinksListKyselyDB {
trip_album_links: {
id: number;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
sync_enabled: number;
last_synced_at: string | null;
created_at: string | null;
};
users: { id: number; username: string | null };
}
/** UM3's row shape — `tal.*, u.username`. */
export interface TripAlbumLinkListRow {
id: number;
trip_id: number | string;
user_id: number;
provider: string;
album_id: string;
album_name: string;
sync_enabled: number;
last_synced_at: string | null;
created_at: string | null;
username: string | null;
}
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
/**
* MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same
@@ -36,4 +103,63 @@ export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) });
}
/**
* UM7 (`UnifiedMemoriesService.createTripAlbumLink`) — `INSERT OR IGNORE
* INTO trip_album_links (trip_id, user_id, provider, album_id, album_name,
* passphrase) VALUES (?, ?, ?, ?, ?, ?)`. `(trip, user, provider,
* album_id)` is the entity's real `uniques` constraint, so `ON CONFLICT
* (trip_id, user_id, provider, album_id) DO NOTHING` is the exact SQLite
* equivalent of the legacy `OR IGNORE`. Returns whether a row was actually
* inserted (legacy `result.changes === 0` drove the "already linked" 409).
*/
async insertIgnore(row: { trip_id: number | string; user_id: number; provider: string; album_id: string; album_name: string; passphrase: string | null }): Promise<boolean> {
const result = await this.kysely<TripAlbumLinksInsertKyselyDB>()
.insertInto('trip_album_links')
.values({ trip_id: row.trip_id, user_id: row.user_id, provider: row.provider, album_id: row.album_id, album_name: row.album_name, passphrase: row.passphrase })
.onConflict((oc) => oc.columns(['trip_id', 'user_id', 'provider', 'album_id']).doNothing())
.executeTakeFirst();
return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n;
}
/** UM10 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?`. */
async deleteScoped(id: number | string, trip_id: number | string, user_id: number): Promise<void> {
await this.kysely<TripAlbumLinksWriteKyselyDB>()
.deleteFrom('trip_album_links')
.where('id', '=', id)
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.execute();
}
/**
* UM3 (`UnifiedMemoriesService.listTripAlbumLinks`) — `SELECT tal.id,
* tal.trip_id, tal.user_id, tal.provider, tal.album_id, tal.album_name,
* tal.sync_enabled, tal.last_synced_at, tal.created_at, u.username FROM
* trip_album_links tal JOIN users u WHERE tal.trip_id = ? AND tal.provider
* IN (dynamic) ORDER BY tal.created_at ASC`.
*/
async listForTrip(trip_id: number | string, enabled_providers: string[]): Promise<TripAlbumLinkListRow[]> {
if (enabled_providers.length === 0) return [];
const rows = await this.kysely<TripAlbumLinksListKyselyDB>()
.selectFrom('trip_album_links as tal')
.innerJoin('users as u', 'u.id', 'tal.user_id')
.select([
'tal.id as id',
'tal.trip_id as trip_id',
'tal.user_id as user_id',
'tal.provider as provider',
'tal.album_id as album_id',
'tal.album_name as album_name',
'tal.sync_enabled as sync_enabled',
'tal.last_synced_at as last_synced_at',
'tal.created_at as created_at',
'u.username as username',
])
.where('tal.trip_id', '=', trip_id)
.where('tal.provider', 'in', enabled_providers)
.orderBy('tal.created_at', 'asc')
.execute();
return rows;
}
}
@@ -1,6 +1,68 @@
import type { TripPhotos } from '../entities/TripPhotos.entity';
import { TrekRepository } from './_shared/trek-repository';
/**
* `trip_photos`'s single-table write/lookup shape (Plan 3e Task 7,
* UM4/UM5/UM6/UM8/UM9). `trip_id`/`album_link_id` are typed `number |
* string` — the legacy statements bound `tripId`/`albumLinkId` raw, straight
* from the route param, with no `Number()`/`toRowId` conversion (the same
* T5 raw-bind seam `TripsRepository.findForViewer`'s docstring documents),
* so this Kysely shape has to accept both to bind the identical value.
*/
interface TripPhotosKyselyDB {
trip_photos: {
id: number;
trip_id: number | string;
user_id: number;
photo_id: number;
shared: number;
album_link_id: number | string | null;
added_at: string | null;
};
}
/**
* UM4's insert-only shape — `id`/`added_at` are autoincrement/`DEFAULT
* CURRENT_TIMESTAMP` and omitted from `.values()` (the legacy statement's
* own column list omits them too), so Kysely's `InsertObject` needs a
* SEPARATE interface that doesn't declare them (the `FileLinksWriteKyselyDB`
* precedent) — {@link TripPhotosKyselyDB} declares every column, so passing
* it to `.insertInto()` would require `id`/`added_at` in every `.values()`.
*/
interface TripPhotosInsertKyselyDB {
trip_photos: {
trip_id: number | string;
user_id: number;
photo_id: number;
shared: number;
album_link_id: number | string | null;
};
}
/**
* UM2's joined projection (`UnifiedMemoriesService.listTripPhotos`) — three
* tables, so its own Kysely shape rather than folding onto
* {@link TripPhotosKyselyDB} (one purpose-shaped interface per statement,
* the `FileLinksRepository` precedent).
*/
interface TripPhotosListKyselyDB {
trip_photos: { trip_id: number | string; user_id: number; photo_id: number; shared: number; added_at: string | null };
trek_photos: { id: number; asset_id: string | null; provider: string };
users: { id: number; username: string | null; avatar: string | null };
}
/** UM2's row shape — `tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar`. */
export interface TripPhotoListRow {
photo_id: number;
asset_id: string | null;
provider: string;
user_id: number;
shared: number;
added_at: string | null;
username: string | null;
avatar: string | null;
}
export class TripPhotosRepository extends TrekRepository<TripPhotos> {
/**
* PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ?
@@ -51,4 +113,99 @@ export class TripPhotosRepository extends TrekRepository<TripPhotos> {
const rows = await this.find({ photo: photo_id, shared: 1 });
return rows.map((row) => row.trip_id);
}
/**
* UM4 (`UnifiedMemoriesService._addTripPhoto`) — `INSERT OR IGNORE INTO
* trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES
* (?, ?, ?, ?, ?)`. `(trip, user, photo)` is the entity's real `uniques`
* constraint (this file's `TripPhotosSchema`), so `ON CONFLICT
* (trip_id, user_id, photo_id) DO NOTHING` is the exact SQLite equivalent
* of the legacy `OR IGNORE`. Returns whether a row was actually inserted
* (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows`
* is Kysely's dialect-general affected-row count, 0 when the conflict
* target already existed and the insert was ignored.
*/
async insertIgnore(row: { trip_id: number | string; user_id: number; photo_id: number; shared: number; album_link_id: number | string | null }): Promise<boolean> {
const result = await this.kysely<TripPhotosInsertKyselyDB>()
.insertInto('trip_photos')
.values({ trip_id: row.trip_id, user_id: row.user_id, photo_id: row.photo_id, shared: row.shared, album_link_id: row.album_link_id })
.onConflict((oc) => oc.columns(['trip_id', 'user_id', 'photo_id']).doNothing())
.executeTakeFirst();
return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n;
}
/** UM5 (`UnifiedMemoriesService.setTripPhotoSharing`) — `UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */
async setShared(trip_id: number | string, user_id: number, photo_id: number, shared: number): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.updateTable('trip_photos')
.set({ shared })
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.where('photo_id', '=', photo_id)
.execute();
}
/** UM6 (`UnifiedMemoriesService.removeTripPhoto`) — `DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */
async deleteForUserPhoto(trip_id: number | string, user_id: number, photo_id: number): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.deleteFrom('trip_photos')
.where('trip_id', '=', trip_id)
.where('user_id', '=', user_id)
.where('photo_id', '=', photo_id)
.execute();
}
/** UM8 (`UnifiedMemoriesService.removeAlbumLink`'s pre-tx orphan-candidate list) — `SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */
async listPhotoIdsForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise<number[]> {
const rows = await this.kysely<TripPhotosKyselyDB>()
.selectFrom('trip_photos')
.select(['photo_id'])
.where('trip_id', '=', trip_id)
.where('album_link_id', '=', album_link_id)
.execute();
return rows.map((r) => r.photo_id);
}
/** UM9 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */
async deleteForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise<void> {
await this.kysely<TripPhotosKyselyDB>()
.deleteFrom('trip_photos')
.where('trip_id', '=', trip_id)
.where('album_link_id', '=', album_link_id)
.execute();
}
/**
* UM2 (`UnifiedMemoriesService.listTripPhotos`) — `SELECT tp.photo_id,
* tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
* u.username, u.avatar FROM trip_photos tp JOIN trek_photos tkp JOIN
* users u WHERE tp.trip_id = ? AND (tp.user_id = ? OR tp.shared = 1) AND
* tkp.provider IN (dynamic) ORDER BY tp.added_at ASC`. The caller already
* guards the empty-`enabled_providers` case (`ServiceResult` 400 before
* this is ever reached); the early return here is defensive, matching an
* `IN ()` that can never match any row.
*/
async listForTrip(trip_id: number | string, user_id: number, enabled_providers: string[]): Promise<TripPhotoListRow[]> {
if (enabled_providers.length === 0) return [];
const rows = await this.kysely<TripPhotosListKyselyDB>()
.selectFrom('trip_photos as tp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'tp.photo_id')
.innerJoin('users as u', 'u.id', 'tp.user_id')
.select([
'tp.photo_id as photo_id',
'tkp.asset_id as asset_id',
'tkp.provider as provider',
'tp.user_id as user_id',
'tp.shared as shared',
'tp.added_at as added_at',
'u.username as username',
'u.avatar as avatar',
])
.where('tp.trip_id', '=', trip_id)
.where((eb) => eb.or([eb('tp.user_id', '=', user_id), eb('tp.shared', '=', 1)]))
.where('tkp.provider', 'in', enabled_providers)
.orderBy('tp.added_at', 'asc')
.execute();
return rows;
}
}
@@ -1114,4 +1114,142 @@ export class UsersRepository extends TrekRepository<Users> {
const row = await this.findOne({ feed_token: token }, { fields: ['id', 'username'] });
return row ? { id: row.id, username: row.username } : undefined;
}
// ---------------------------------------------------------------------
// Plan 3e Task 7 (memories' provider half) — additive: the Immich/Synology
// encrypted-credential statements (IM1-5/SY1-8, inventory §7d, R6). Every
// value stays exactly what the SERVICE (`ImmichService`/`SynologyService`)
// hands this repository — encrypted, plaintext or null, at the same point
// in the pipeline the raw SQL saw it. This repository never calls
// `encrypt_api_key`/`decrypt_api_key`/`maybe_encrypt_api_key` itself.
// ---------------------------------------------------------------------
/** IM1 (`ImmichService.getImmichCredentials`) — `SELECT immich_url, immich_api_key FROM users WHERE id = ?`. */
async getImmichCredentials(id: number): Promise<{ immich_url: string | null; immich_api_key: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key'] });
return row ? { immich_url: row.immich_url ?? null, immich_api_key: row.immich_api_key ?? null } : null;
}
/** IM2 (`ImmichService.getConnectionSettings`'s prefs read) — `SELECT immich_auto_upload FROM users WHERE id = ?`. */
async getImmichAutoUpload(id: number): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['immich_auto_upload'] });
return row?.immich_auto_upload ?? null;
}
/** IM3 (`ImmichService.setImmichAutoUpload`) — `UPDATE users SET immich_auto_upload = ? WHERE id = ?`. */
async setImmichAutoUpload(id: number, enabled: number): Promise<void> {
await this.nativeUpdate({ id }, { immich_auto_upload: enabled });
}
/**
* IM4/IM5 (`ImmichService.saveImmichSettings`'s two branches) — `UPDATE
* users SET immich_url = ?, immich_api_key = ? WHERE id = ?`, byte-identical
* statement at both legacy sites (the URL branch's trimmed value, the else
* branch's `null`) — one method, per R6.
*/
async setImmichSettings(id: number, immich_url: string | null, immich_api_key: string | null): Promise<void> {
await this.nativeUpdate({ id }, { immich_url, immich_api_key });
}
/**
* SY1 (`SynologyService._readSynologyUser`) — `SELECT synology_url,
* synology_username, synology_password, synology_sid, synology_did,
* synology_skip_ssl FROM users WHERE id = ?`, column-filtered in JS by the
* legacy code. This method selects only the requested columns (MikroORM's
* typed `fields`) rather than all six and filtering after — the RESULT is
* identical (only the requested keys populated, matching the legacy
* `filtered` object exactly; an unrequested key is `undefined`, same as
* the legacy object never having had it set). `null` means no such user
* row (SY1's "User not found" branch).
*
* Explicit per-column `if` guards, not a generic `Pick<UserRow, K>` +
* indexed-assignment loop: MikroORM's `fields` option types against
* `AutoPath<Users, K, ...>`, which does not resolve for a free type
* parameter `K` the way it does for the concrete literal union used here
* (the same reason `getApiKeyColumn` above stays non-generic); an
* indexed-assignment loop over a union key has the identical problem on
* the write side. Six `if`s reads worse but needs neither a generic nor a
* cast.
*/
async getSynologyFields(id: number, columns: SynologyUserColumn[]): Promise<SynologyFieldsRow | null> {
const row = await this.findOne({ id }, { fields: columns });
if (!row) return null;
const result: SynologyFieldsRow = {};
if (columns.includes('synology_url')) result.synology_url = row.synology_url ?? null;
if (columns.includes('synology_username')) result.synology_username = row.synology_username ?? null;
if (columns.includes('synology_password')) result.synology_password = row.synology_password ?? null;
if (columns.includes('synology_sid')) result.synology_sid = row.synology_sid ?? null;
if (columns.includes('synology_did')) result.synology_did = row.synology_did ?? null;
if (columns.includes('synology_skip_ssl')) result.synology_skip_ssl = row.synology_skip_ssl;
return result;
}
/** SY2 (`SynologyService._clearSynologySID`) — `UPDATE users SET synology_sid = NULL WHERE id = ?`. */
async clearSynologySID(id: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid: null });
}
/** SY3 (`SynologyService._clearSynologySession`) — `UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?`. */
async clearSynologySession(id: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid: null, synology_did: null });
}
/**
* SY4/SY7 (`SynologyService._getSynologySession`'s session-refresh write,
* `testSynologyConnection`'s sid persist) — byte-identical `UPDATE users
* SET synology_sid = ? WHERE id = ?` at both legacy sites, one method (D4).
*/
async setSynologySid(id: number, synology_sid: string): Promise<void> {
await this.nativeUpdate({ id }, { synology_sid });
}
/** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */
async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise<void> {
await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl });
}
/** SY6 (`SynologyService.getSynologyStatus`) — `SELECT synology_username FROM users WHERE id = ?`. */
async getSynologyUsername(id: number): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['synology_username'] });
return row?.synology_username ?? null;
}
/** SY8 (`SynologyService.testSynologyConnection`'s did persist) — `UPDATE users SET synology_did = ? WHERE id = ?`. */
async setSynologyDid(id: number, synology_did: string): Promise<void> {
await this.nativeUpdate({ id }, { synology_did });
}
// ---------------------------------------------------------------------
// UM11 — UnifiedMemoriesService._notifySharedTripPhotos
// ---------------------------------------------------------------------
/** UM11 — `SELECT username, email FROM users WHERE id = ?`, the shared-photos notification's actor lookup. */
async findUsernameEmail(id: number): Promise<{ username: string; email: string } | undefined> {
const row = await this.findOne({ id }, { fields: ['username', 'email'] });
return row ? { username: row.username, email: row.email } : undefined;
}
}
/**
* The six Synology credential/session columns `_readSynologyUser` reads
* selectively — a closed union, not a dynamic column string, matching
* `InstanceApiKeyName`'s reasoning above.
*/
export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl';
/**
* {@link UsersRepository.getSynologyFields}'s return shape — structurally
* identical to `synology.service.ts`'s own `SynologyUserRecord` (every field
* optional, so an unrequested column is simply absent), deliberately
* defined here rather than imported from the service: a repository does not
* depend on a domain service's types, and the two stay structurally
* assignable without either side casting.
*/
export interface SynologyFieldsRow {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
}
+11 -17
View File
@@ -1,14 +1,16 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { checkSsrf, safeFetch } from '../../utils/ssrfGuard';
import { AuditService } from '../audit/audit.service';
import { StorageService } from '../storage/storage.service';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { fail, handleServiceResult, isWithinLocalDayRange, pipeAsset, shiftCalendarDay, sortAssetsByTakenAtDesc, type Selection } from './memories.helpers';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
const ALBUM_PAGE_SIZE = 1000;
const ALBUM_MAX_PAGES = 20;
@@ -34,18 +36,18 @@ const SEARCH_MAX_RAW_PAGES = 20;
@Injectable()
export class ImmichService {
constructor(
private readonly db: DatabaseService,
private readonly audit: AuditService,
private readonly access: MemoriesAccessService,
private readonly storage: StorageService,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
async getImmichCredentials(userId: number) {
const user = this.db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
const user = await this.users.getImmichCredentials(userId);
if (!user?.immich_url || !user?.immich_api_key) return null;
const apiKey = decrypt_api_key(user.immich_api_key);
if (!apiKey) return null;
return { immich_url: user.immich_url as string, immich_api_key: apiKey };
return { immich_url: user.immich_url, immich_api_key: apiKey };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
@@ -73,16 +75,16 @@ export class ImmichService {
async getConnectionSettings(userId: number) {
const creds = await this.getImmichCredentials(userId);
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
const autoUpload = await this.users.getImmichAutoUpload(userId);
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
auto_upload: !!(prefs?.immich_auto_upload),
auto_upload: !!autoUpload,
};
}
async setImmichAutoUpload(userId: number, enabled: boolean): Promise<void> {
this.db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
await this.users.setImmichAutoUpload(userId, enabled ? 1 : 0);
}
async saveImmichSettings(
@@ -99,11 +101,7 @@ export class ImmichService {
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
await this.users.setImmichSettings(userId, immichUrl.trim(), maybe_encrypt_api_key(immichApiKey));
if (ssrf.isPrivate) {
await this.audit.writeAudit({
userId,
@@ -117,11 +115,7 @@ export class ImmichService {
};
}
} else {
this.db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
await this.users.setImmichSettings(userId, null, maybe_encrypt_api_key(immichApiKey));
}
return { success: true };
}
+6 -4
View File
@@ -3,11 +3,13 @@ import {
errorResult, ok, type McpContext,
} from '../../nest-mcp';
import { z } from 'zod';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
/**
* The photo backends TREK can talk to, named rather than taken as a free
@@ -65,8 +67,8 @@ export class MemoriesMcp {
constructor(
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly db: DatabaseService,
private readonly addons: AddonsService,
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
) {}
/**
@@ -75,7 +77,7 @@ export class MemoriesMcp {
*/
async enabledProviderIds(): Promise<string[]> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id);
return await this.photoProviders.listEnabledIds();
}
/**
@@ -85,7 +87,7 @@ export class MemoriesMcp {
* themselves never checked it, so this only ever narrows what REST allows.
*/
private async providerRefusal(provider: ProviderId) {
const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider);
const row = await this.photoProviders.findEnabled(provider);
if (!row) return errorResult(`Provider: "${provider}" is not supported`);
if (row.enabled !== 1 || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)))
return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
+8 -2
View File
@@ -31,6 +31,8 @@ import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import { Users } from '../../db/entities/Users.entity';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
@@ -57,12 +59,16 @@ import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity'
* (owned elsewhere, reached the same way every cross-domain module in this
* program reaches another domain's entity) for `MemoriesAccessService`'s
* `@InjectRepository` constructor params, and `TrekPhotoCacheMetaRepository`
* for `TrekPhotoCacheService`'s (Plan 3e Task 6).
* for `TrekPhotoCacheService`'s (Plan 3e Task 6). `PhotoProviders` (owned by
* this module) and `Users` (owned elsewhere, reached the same way `Trips`
* is above) are added for `UnifiedMemoriesService`/`ImmichService`/
* `SynologyService`/`MemoriesMcp`'s `@InjectRepository` constructor params
* (Plan 3e Task 7).
*/
@Module({
imports: [
NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule,
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta]),
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta, PhotoProviders, Users]),
],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [
+16 -19
View File
@@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { DatabaseService } from '../database/database.service';
import { MemoriesAccessService } from './memories-access.service';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository, SynologyUserColumn } from '../../db/repositories/Users.repository';
import {
dayStartEpochSeconds,
fail,
@@ -129,24 +131,19 @@ interface SynologyPhotoItem {
@Injectable()
export class SynologyService {
constructor(
private readonly db: DatabaseService,
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
private async _readSynologyUser(userId: number, columns: string[]): Promise<ServiceResult<SynologyUserRecord>> {
private async _readSynologyUser(userId: number, columns: SynologyUserColumn[]): Promise<ServiceResult<SynologyUserRecord>> {
try {
const row = this.db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
const filtered = await this.users.getSynologyFields(userId, columns);
if (!row) {
if (!filtered) {
return fail('User not found', 404);
}
const filtered: SynologyUserRecord = {};
for (const column of columns) {
filtered[column] = row[column];
}
return success(filtered);
} catch {
return fail('Failed to read Synology user data', 500);
@@ -306,11 +303,11 @@ export class SynologyService {
private async _clearSynologySID(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
await this.users.clearSynologySID(userId);
}
private async _clearSynologySession(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
await this.users.clearSynologySession(userId);
}
private _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
@@ -349,7 +346,7 @@ export class SynologyService {
return resp as ServiceResult<string>;
}
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
await this.users.setSynologySid(userId, encrypt_api_key(resp.data.sid));
return success(resp.data.sid);
}
@@ -403,12 +400,12 @@ export class SynologyService {
}
try {
this.db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
await this.users.setSynologySettings(
userId,
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
@@ -422,8 +419,8 @@ export class SynologyService {
if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = this.db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
const synologyUsername = await this.users.getSynologyUsername(userId);
return success({ connected: true, user: { name: synologyUsername || 'unknown user' } });
} catch (err: unknown) {
return success({ connected: true, user: { name: 'unknown user' } });
}
@@ -443,9 +440,9 @@ export class SynologyService {
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
this.db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
await this.users.setSynologySid(userId, encrypt_api_key(resp.data.sid));
if (resp.data.did) {
this.db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
await this.users.setSynologyDid(userId, encrypt_api_key(resp.data.did));
}
return success({ connected: true, user: { name: synologyUsername } });
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ADDON_IDS } from '../../addons';
import { broadcast } from '../../websocket';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
@@ -18,6 +18,17 @@ import {
} from './memories.helpers';
import { NotificationsService } from '../notifications/notifications.service';
import { UnitOfWork } from '../database/unit-of-work';
import { RealtimeService } from '../realtime/realtime.service';
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import type { TripAlbumLinksRepository } from '../../db/repositories/TripAlbumLinks.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
/**
* The provider-agnostic trip photo surface: which photos a trip shows, which
@@ -36,6 +47,12 @@ export class UnifiedMemoriesService {
private readonly notifications: NotificationsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
private readonly realtime: RealtimeService,
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
@InjectRepository(TripAlbumLinks) private readonly tripAlbumLinks: TripAlbumLinksRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
@@ -43,7 +60,7 @@ export class UnifiedMemoriesService {
// surface lives inside journeys. Covers rows left enabled from before
// updateAddon cascaded the journey disable.
const journeyOn = await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
const rows = await this.photoProviders.listAll();
return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 }));
}
@@ -76,17 +93,7 @@ export class UnifiedMemoriesService {
return fail('No photo providers enabled', 400);
}
const photos = this.db.prepare(`
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders);
const photos = await this.tripPhotos.listForTrip(tripId, userId, enabledProviders);
return success(photos);
} catch (error) {
@@ -108,23 +115,7 @@ export class UnifiedMemoriesService {
}
try {
const links = this.db.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
AND tal.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tal.created_at ASC
`).all(tripId, ...enabledProviders);
const links = await this.tripAlbumLinks.listForTrip(tripId, enabledProviders);
return success(links);
} catch (error) {
@@ -142,10 +133,14 @@ export class UnifiedMemoriesService {
}
try {
const photoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase);
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0);
const added = await this.tripPhotos.insertIgnore({
trip_id: tripId,
user_id: userId,
photo_id: photoId,
shared: shared ? 1 : 0,
album_link_id: albumLinkId || null,
});
return success(added);
}
catch (error) {
return mapDbError(error, 'Failed to add photo to trip');
@@ -189,7 +184,7 @@ export class UnifiedMemoriesService {
}
await this._notifySharedTripPhotos(tripId, userId, added);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success({ added, shared });
}
@@ -207,16 +202,10 @@ export class UnifiedMemoriesService {
}
try {
this.db.prepare(`
UPDATE trip_photos
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(shared ? 1 : 0, tripId, userId, photoId);
await this.tripPhotos.setShared(tripId, userId, photoId, shared ? 1 : 0);
await this._notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
return mapDbError(error, 'Failed to update photo sharing');
@@ -235,15 +224,10 @@ export class UnifiedMemoriesService {
}
try {
this.db.prepare(`
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND photo_id = ?
`).run(tripId, userId, photoId);
await this.tripPhotos.deleteForUserPhoto(tripId, userId, photoId);
await this.photos.deleteIfOrphan(photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
this.realtime.broadcast(tripId, 'memories:updated', { userId }, sid);
return success(true);
} catch (error) {
@@ -279,11 +263,16 @@ export class UnifiedMemoriesService {
try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = this.db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
const added = await this.tripAlbumLinks.insertIgnore({
trip_id: tripId,
user_id: userId,
provider,
album_id: albumId,
album_name: albumName,
passphrase: encryptedPassphrase,
});
if (result.changes === 0) {
if (!added) {
return fail('Album already linked', 409);
}
@@ -300,17 +289,14 @@ export class UnifiedMemoriesService {
}
try {
const linkedPhotos = this.db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.all(tripId, linkId) as Array<{ photo_id: number }>;
const linkedPhotoIds = await this.tripPhotos.listPhotoIdsForAlbumLink(tripId, linkId);
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
.run(tripId, linkId);
this.db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
await this.tripPhotos.deleteForAlbumLink(tripId, linkId);
await this.tripAlbumLinks.deleteScoped(linkId, tripId, userId);
});
for (const { photo_id } of linkedPhotos) {
for (const photo_id of linkedPhotoIds) {
await this.photos.deleteIfOrphan(photo_id);
}
@@ -332,12 +318,11 @@ export class UnifiedMemoriesService {
if (added <= 0) return success(undefined);
try {
const actorRow = this.db.prepare('SELECT username, email FROM users WHERE id = ?').get(actorUserId) as { username: string | null, email: string | null };
const actorRow = await this.users.findUsernameEmail(actorUserId);
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const tripTitle = await this.trips.getTitle(tripId);
this.notifications.send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
this.notifications.send({ event: 'photos_shared', actorId: actorUserId, scope: 'trip', targetId: Number(tripId), params: { trip: tripTitle || 'Untitled', actor: actorRow?.email || 'Unknown', count: String(added), tripId: String(tripId) } }).catch(() => {});
return success(undefined);
} catch {
return fail('Failed to send notifications', 500);
+22 -6
View File
@@ -68,6 +68,7 @@ import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../src/db/entities/TrekPhotoCacheMeta.entity';
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
@@ -132,6 +133,7 @@ import {
createTestUsersRepo,
} from './test-uow';
import { createTestOrm } from './test-orm';
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
@@ -182,7 +184,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo),
new MailerService(dbService),
new EphemeralTokenService(),
new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection),
new AllowedFileTypesService(appSettings), await createTestUnitOfWork(dbService.connection),
appSettings, usersRepo, inviteTokensRepo, mcpTokensRepoForAuth, oauthTokensRepo, webauthnCredentialsRepoForAuth, passwordResetTokensRepo,
);
const queryHelpersService = new QueryHelpersService(await createTestTagsRepo(dbService.connection), await createTestPlaceRatingsRepo(dbService.connection), await createTestAssignmentParticipantsRepo(dbService.connection));
@@ -265,10 +267,24 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
await createTestUnitOfWork(dbService.connection),
mcpOrm.em,
);
// Plan 3e Task 1 (files): FilesService now also takes uow + the repositories
// its R2 transactions and R12 cross-object trip-scoping guard need. Built
// once and shared by both consumers below (readModelService, FilesMcp)
// rather than reconstructed twice with the same seven-repository tail.
const filesService = new FilesService(
dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em,
await createTestUnitOfWork(dbService.connection),
await createTestTripFilesRepo(dbService.connection),
await createTestFileLinksRepo(dbService.connection),
await createTestReservationsRepo(dbService.connection),
await createTestPlacesRepo(dbService.connection),
await createTestDayAssignmentsRepo(dbService.connection),
await createTestBudgetItemsRepo(dbService.connection),
);
const readModelService = new TripReadModelService(
await createTestTripsRepo(dbService.connection), membersService, daysService, accommodationsService, budgetService,
packingService, reservationsService, collabService, placesService, todoService,
new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em),
filesService,
);
const calendarService = new CalendarService(
reservationsService,
@@ -286,8 +302,8 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// stubbed: an empty provider registry would make the backfill answer "unknown
// provider" for every id and hide a wiring mistake behind a caught error.
const memoriesAccess = new MemoriesAccessService(dbService, mcpOrm.repo(TripPhotos), mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripAlbumLinks), mcpOrm.repo(Trips));
const immichService = new ImmichService(dbService, new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage);
const synologyService = new SynologyService(dbService, memoriesAccess, notificationsStub());
const immichService = new ImmichService(new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage, usersRepo);
const synologyService = new SynologyService(memoriesAccess, notificationsStub(), usersRepo);
const trekPhotos = new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), dbService);
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, mcpOrm.repo(TrekPhotos)), new TrekPhotoCacheService(mcpOrm.repo(TrekPhotoCacheMeta), generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
return createTestRegistry(
@@ -318,7 +334,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
authService,
addonsService,
),
new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage, mcpOrm.em), authService, guards),
new FilesMcp(filesService, authService, guards),
new AccommodationsMcp(accommodationsService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
@@ -335,7 +351,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService, appSettings, usersRepo)), daysService, reservationsService, dbService, authService, guards),
new AtlasMcp(new AtlasService(dbService, await createTestUnitOfWork(dbService.connection)), addonsService, authService),
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(await createTestUnitOfWork(dbService.connection), appSettings, await createTestSettingsRepo(dbService.connection))), addonsService, authService, captureBackfill),
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
new MemoriesMcp(immichService, synologyService, addonsService, mcpOrm.repo(PhotoProviders)),
new NotificationsMcp(await makeNotificationsService(dbService, realtimeService), authService),
new AirtrailMcp(new AirtrailService(dbService, new AuditService(auditLogRepo, usersRepo), new AirtrailClient()), addonsService),
new ReservationImportMcp(new AirtrailImportService(dbService, realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(dbService, new AuditService(auditLogRepo, usersRepo), new AirtrailClient())), dbService, authService, guards, addonsService),
+14 -2
View File
@@ -89,6 +89,7 @@ import {
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -112,7 +113,18 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const queryHelpers = new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection));
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage, (await sharedTestOrm(dbs.connection)).em);
// Plan 3e Task 1 (files): FilesService now also takes uow + the repositories
// its R2 transactions and R12 cross-object trip-scoping guard need.
const files = new FilesService(
dbs, permissions, realtime, new EphemeralTokenService(), generalStorage, (await sharedTestOrm(dbs.connection)).em,
await createTestUnitOfWork(dbs.connection),
await createTestTripFilesRepo(dbs.connection),
await createTestFileLinksRepo(dbs.connection),
await createTestReservationsRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestBudgetItemsRepo(dbs.connection),
);
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection));
const vacay = new VacayService(dbs, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const days = new DaysService(
@@ -182,7 +194,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
new TodoRpc(todos, realtime, guards),
new DayNotesRpc(dayNotes, realtime, guards),
new PackingRpc(packing, realtime, guards),
new FilesRpc(files, realtime, dbs, guards, generalStorage),
new FilesRpc(files, realtime, usersRepo, guards, generalStorage),
new PlacesRpc(places, journey, realtime, guards),
new DaysRpc(days, realtime, guards),
new AccommodationsRpc(accommodations, realtime, guards),
+31 -6
View File
@@ -25,11 +25,14 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({
decryptMock: vi.fn((v: string) => v),
maybeEncryptMock: vi.fn((v: string) => v),
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: decryptMock,
encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: maybeEncryptMock,
}));
const { safeFetch, checkSsrf } = vi.hoisted(() => ({ safeFetch: vi.fn(), checkSsrf: vi.fn() }));
@@ -42,19 +45,18 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ImmichService } from '../../../src/nest/memories/immich.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import fs from 'node:fs';
import path from 'node:path';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { createTestUsersRepo } from '../../helpers/test-uow';
const audit = { writeAudit: vi.fn() };
const access = { getAlbumIdFromLink: vi.fn() };
const dbs = new DatabaseService(testDb);
const journeyFx = makeStorageFixture('journey/');
const svc = new ImmichService(dbs, audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage);
let svc: ImmichService;
const USER = 1;
@@ -75,14 +77,17 @@ function upstream(opts: { ok?: boolean; status?: number; json?: unknown; url?: s
};
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const users = await createTestUsersRepo(testDb);
svc = new ImmichService(audit as unknown as AuditService, access as unknown as MemoriesAccessService, journeyFx.storage, users);
});
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((v: string) => v);
maybeEncryptMock.mockImplementation((v: string) => v);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
testDb.prepare('DELETE FROM users').run();
seedUser(USER, 'https://immich.test', 'key-1');
@@ -175,6 +180,26 @@ describe('saveImmichSettings', () => {
expect(checkSsrf).not.toHaveBeenCalled();
expect(await svc.getImmichCredentials(USER)).toBeNull();
});
// R6 — the encrypt call still runs; this file mocks `maybe_encrypt_api_key`
// as identity everywhere else (so cases above can assert on the plaintext
// round-trip), so this one case swaps in the REAL crypto for the duration
// of a single call and reads the raw column back — proving
// `UsersRepository.setImmichSettings` never bypasses the service's encrypt
// step. `beforeEach` restores the identity stub for every other case.
it('IMMICH-051 (R6): the stored immich_api_key is the encrypted envelope, never the plaintext', async () => {
const real = await vi.importActual<typeof import('../../../src/nest/common/crypto/apiKeyCrypto')>(
'../../../src/nest/common/crypto/apiKeyCrypto',
);
maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key);
const plaintext = 'synthetic-test-immich-key-001';
await svc.saveImmichSettings(USER, 'https://immich.test', plaintext, null);
const row = testDb.prepare('SELECT immich_api_key FROM users WHERE id = ?').get(USER) as { immich_api_key: string };
expect(row.immich_api_key).not.toBe(plaintext);
expect(row.immich_api_key.startsWith('enc:v1:')).toBe(true);
});
});
describe('testConnection', () => {
@@ -27,11 +27,14 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
const { decryptMock } = vi.hoisted(() => ({ decryptMock: vi.fn((v: string) => v) }));
const { decryptMock, maybeEncryptMock } = vi.hoisted(() => ({
decryptMock: vi.fn((v: string) => v),
maybeEncryptMock: vi.fn((v: string) => v),
}));
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: decryptMock,
encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: maybeEncryptMock,
}));
const { safeFetch, checkSsrf, SsrfBlockedError } = vi.hoisted(() => {
@@ -46,13 +49,13 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { SynologyService } from '../../../src/nest/memories/synology.service';
import type { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUsersRepo } from '../../helpers/test-uow';
const access = { getAlbumLinkForSync: vi.fn(), updateSyncTimeForAlbumLink: vi.fn() };
const svc = new SynologyService(new DatabaseService(testDb), access as unknown as MemoriesAccessService, notificationsStub());
let svc: SynologyService;
const USER = 1;
@@ -76,14 +79,17 @@ function httpError(status: number) {
return { ok: false, status, headers: { get: () => null }, json: async () => ({}), arrayBuffer: async () => Buffer.from('x') };
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const users = await createTestUsersRepo(testDb);
svc = new SynologyService(access as unknown as MemoriesAccessService, notificationsStub(), users);
});
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((v: string) => v);
maybeEncryptMock.mockImplementation((v: string) => v);
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' });
testDb.prepare('DELETE FROM users').run();
seedUser(USER);
@@ -223,6 +229,27 @@ describe('updateSynologySettings', () => {
expect(row.synology_password).toBe('pw');
expect(row.synology_url).toBe('https://nas2.test');
});
// R6 — this file mocks `maybe_encrypt_api_key` as identity elsewhere (so
// the cases above can assert on the plaintext round-trip); this one case
// swaps in the REAL crypto for the duration of a single call and reads the
// raw column back — proving `UsersRepository.setSynologySettings` never
// bypasses the service's encrypt step. `beforeEach` restores the identity
// stub for every other case.
it('SYNO-U032 (R6): the stored synology_password is the encrypted envelope, never the plaintext', async () => {
safeFetch.mockResolvedValue(api({ sid: 's' }));
const real = await vi.importActual<typeof import('../../../src/nest/common/crypto/apiKeyCrypto')>(
'../../../src/nest/common/crypto/apiKeyCrypto',
);
maybeEncryptMock.mockImplementation(real.maybe_encrypt_api_key);
const plaintext = 'synthetic-test-synology-pw-001';
await svc.updateSynologySettings(USER, 'https://nas3.test', 'ada', plaintext);
const row = testDb.prepare('SELECT synology_password FROM users WHERE id = ?').get(USER) as { synology_password: string };
expect(row.synology_password).not.toBe(plaintext);
expect(row.synology_password.startsWith('enc:v1:')).toBe(true);
});
});
describe('testSynologyConnection', () => {
@@ -36,7 +36,6 @@ vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -51,16 +50,43 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
import type { ServiceResult } from '../../../src/nest/memories/memories.helpers';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import type { TripPhotosRepository } from '../../../src/db/repositories/TripPhotos.repository';
import type { TripAlbumLinksRepository } from '../../../src/db/repositories/TripAlbumLinks.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import { createTestUnitOfWork, sharedTestOrm, createTestUsersRepo } from '../../helpers/test-uow';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
const dbs = new DatabaseService(testDb);
let svc: UnifiedMemoriesService;
const realtimeMock = { broadcast: vi.fn() };
let tripPhotosRepo: TripPhotosRepository;
let tripAlbumLinksRepo: TripAlbumLinksRepository;
let usersRepo: UsersRepository;
// Narrows the `ServiceResult` discriminated union without an `as any` cast —
// used by the new (Task 7) cases below only; the pre-existing cases above
// keep their original `(result as any)` shape unchanged.
function expectFailure<T>(result: ServiceResult<T>): { message: string; status: number } {
// `'error' in result`, not `result.success` — discriminant narrowing over a
// union with a generic member (`{ success: true; data: T }`) doesn't
// resolve here (a TS control-flow limitation on generic discriminated
// unions); `in` narrows correctly, the same idiom `handleServiceResult`
// (`memories.helpers.ts`) already uses.
if (!('error' in result)) throw new Error('expected a ServiceResult failure, got success');
return result.error;
}
function expectSuccess<T>(result: ServiceResult<T>): T {
if ('error' in result) throw new Error(`expected a ServiceResult success, got: ${result.error.message}`);
return result.data;
}
// Legacy free-function names forwarding to the service, so the moved cases read
// as before. Typed forwarders rather than `.bind` aliases: a bound alias is
@@ -86,6 +112,9 @@ beforeAll(async () => {
const t = await sharedTestOrm(testDb);
const real = new DatabaseService(testDb, t.em);
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
tripPhotosRepo = t.repo(TripPhotos);
tripAlbumLinksRepo = t.repo(TripAlbumLinks);
usersRepo = await createTestUsersRepo(testDb);
svc = new UnifiedMemoriesService(
dbs,
new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs),
@@ -95,6 +124,12 @@ beforeAll(async () => {
notificationsStub(),
await createTestAddonsService(testDb, dbs),
await createTestUnitOfWork(testDb),
realtimeMock as unknown as RealtimeService,
t.repo(PhotoProviders),
tripPhotosRepo,
tripAlbumLinksRepo,
usersRepo,
t.repo(Trips),
);
});
@@ -104,6 +139,7 @@ beforeEach(() => {
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
// Providers only count as enabled under an enabled journey addon (migration 84 seeds it off).
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
realtimeMock.broadcast.mockClear();
});
afterAll(() => {
@@ -256,6 +292,20 @@ describe('createTripAlbumLink', () => {
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(400);
});
it('MEM-UNIFIED-019: UM7 insertIgnore reports the 409 "already linked" on a re-link (duplicate-ignore, not a crash)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'First Name');
expectSuccess(first);
const second = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'Second Name');
expect(expectFailure(second).status).toBe(409);
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
expect(rows.c).toBe(1);
});
});
// ── removeAlbumLink ───────────────────────────────────────────────────────────
@@ -266,4 +316,173 @@ describe('removeAlbumLink', () => {
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
it('MEM-UNIFIED-020: UM8/UM9/UM10 — the transaction removes the link and every trip_photos row it owned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const linked = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-rm-1', 'To Remove');
expect(linked.success).toBe(true);
const linkRow = testDb.prepare('SELECT id FROM trip_album_links WHERE trip_id = ? AND user_id = ? AND album_id = ?').get(trip.id, user.id, 'album-rm-1') as { id: number };
await addTripPhotos(String(trip.id), user.id, true, [{ provider: 'immich', asset_ids: ['asset-rm-1'] }], 'sid-6', String(linkRow.id));
const photoRow = testDb.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id) as { photo_id: number };
expect(photoRow).toBeDefined();
const result = await removeAlbumLink(String(trip.id), String(linkRow.id), user.id);
expect(result.success).toBe(true);
const remainingLink = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkRow.id);
expect(remainingLink).toBeUndefined();
const remainingPhoto = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id);
expect(remainingPhoto).toBeUndefined();
// The orphaned trek_photos row is reclaimed too (TrekPhotoRegistrationService.deleteIfOrphan, PH10/PH11).
const orphan = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoRow.photo_id);
expect(orphan).toBeUndefined();
});
});
// ── UM2/UM3 parity — full-key toEqual against the legacy statement run raw ────
describe('listTripPhotos / listTripAlbumLinks — parity', () => {
it('PARITY-UM2: matches the legacy join, enabled/disabled providers mixed', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// A photo under a disabled provider must not appear — the legacy `tkp.provider IN (...)`
// only ever names the enabled set.
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, 50)'
).run('disabled-x', 'Disabled X', 'desc', 'Image');
const enabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-enabled', user.id).lastInsertRowid as number;
const disabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('disabled-x', 'asset-disabled', user.id).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, enabledPhoto);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, disabledPhoto);
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
const oracle = testDb.prepare(`
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(trip.id, user.id, ...enabledProviders);
const result = await listTripPhotos(String(trip.id), user.id);
const data = expectSuccess(result);
expect(data).toEqual(oracle);
expect(data.some((r: { provider: string }) => r.provider === 'disabled-x')).toBe(false);
});
it('PARITY-UM3: matches the legacy join for an album link with a passphrase', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await createTripAlbumLink(String(trip.id), user.id, 'synologyphotos', 'album-p1', 'Passphrase Album', 'secret-pass');
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
const oracle = testDb.prepare(`
SELECT tal.id,
tal.trip_id,
tal.user_id,
tal.provider,
tal.album_id,
tal.album_name,
tal.sync_enabled,
tal.last_synced_at,
tal.created_at,
u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
AND tal.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tal.created_at ASC
`).all(trip.id, ...enabledProviders);
const result = await listTripAlbumLinks(String(trip.id), user.id);
const data = expectSuccess(result);
expect(data).toEqual(oracle);
// The passphrase column round-trips through the repository encrypted, same
// as it always was — not part of this listing's SELECT list either way.
expect(data[0].album_id).toBe('album-p1');
});
});
// ── Access-check mutation proof — a non-member (not "trip missing") ───────────
describe('access check — non-member refusal', () => {
it('MEM-UNIFIED-014: a real trip, a real non-member user, still 404s (red if the gate is loosened)', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const result = await listTripPhotos(String(trip.id), stranger.id);
expect(expectFailure(result).status).toBe(404);
});
});
// ── R8 — RealtimeService.broadcast pins ────────────────────────────────────────
describe('R8 — injected RealtimeService, not the legacy module broadcast', () => {
it('MEM-UNIFIED-015: addTripPhotos calls this.realtime.broadcast with the exact legacy event/payload/arity', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-r8-1'] }], 'sid-1');
expect(expectSuccess(result).added).toBe(1);
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-1');
});
it('MEM-UNIFIED-016: setTripPhotoSharing calls this.realtime.broadcast', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-2', user.id).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
const result = await setTripPhotoSharing(String(trip.id), user.id, photoId, true, 'sid-2');
expect(result.success).toBe(true);
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-2');
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId) as { shared: number };
expect(row.shared).toBe(1);
});
it('MEM-UNIFIED-018: UM4 insertIgnore reports 0 added on a re-add of the same photo (duplicate-ignore, not a crash)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-4');
expect(expectSuccess(first).added).toBe(1);
realtimeMock.broadcast.mockClear();
const second = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-5');
expect(expectSuccess(second).added).toBe(0);
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
expect(rows.c).toBe(1);
});
it('MEM-UNIFIED-017: removeTripPhoto calls this.realtime.broadcast and deletes the row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-3', user.id).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
const result = await removeTripPhoto(String(trip.id), user.id, photoId, 'sid-3');
expect(result.success).toBe(true);
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-3');
const row = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId);
expect(row).toBeUndefined();
});
});