mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(photos): trek photos, memories access and cache sweeps through repositories
This commit is contained in:
@@ -1,4 +1,48 @@
|
||||
import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TrekPhotoCacheMetaRepository extends TrekRepository<TrekPhotoCacheMeta> {}
|
||||
/** TC1's row — `content_type`/`fetched_at`, the two columns `getFresh` needs. */
|
||||
export interface TrekPhotoCacheFreshness {
|
||||
content_type: string;
|
||||
fetched_at: number;
|
||||
}
|
||||
|
||||
export class TrekPhotoCacheMetaRepository extends TrekRepository<TrekPhotoCacheMeta> {
|
||||
/** TC1 — `SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?`. */
|
||||
async findFreshness(cache_key: string): Promise<TrekPhotoCacheFreshness | null> {
|
||||
const row = await this.findOne({ cache_key }, { fields: ['content_type', 'fetched_at'] });
|
||||
return row ? { content_type: row.content_type, fetched_at: row.fetched_at } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TC2/TC3/TC6 — `DELETE FROM trek_photo_cache_meta WHERE cache_key = ?`,
|
||||
* the same statement text at three call sites (the TTL-expired branch, the
|
||||
* object-missing branch, and `sweepExpired`'s stale pass) — one method (D4).
|
||||
*/
|
||||
async deleteByCacheKey(cache_key: string): Promise<number> {
|
||||
return await this.nativeDelete({ cache_key });
|
||||
}
|
||||
|
||||
/**
|
||||
* TC4 (`put`) — `INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key,
|
||||
* content_type, fetched_at) VALUES (?, ?, ?)`. `cache_key` is the table's
|
||||
* only unique/primary column (a genuine nullable TEXT PK per R5's Task 0
|
||||
* finding — not a bug, and not touched here), so it is the sole conflict
|
||||
* target, same upsert shape as `AppSettingsRepository.setValue`.
|
||||
*/
|
||||
async upsertMeta(cache_key: string, content_type: string, fetched_at: number): Promise<void> {
|
||||
await this.upsert({ cache_key, content_type, fetched_at }, { onConflictFields: ['cache_key'], onConflictAction: 'merge' });
|
||||
}
|
||||
|
||||
/** TC5 — `SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?`, `sweepExpired`'s stale-row pass. */
|
||||
async listStale(cutoff: number): Promise<string[]> {
|
||||
const rows = await this.find({ fetched_at: { $lt: cutoff } }, { fields: ['cache_key'] });
|
||||
return rows.map((row) => row.cache_key).filter((key): key is string => key != null);
|
||||
}
|
||||
|
||||
/** TC7 — `SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?`, `sweepExpired`'s pass-2 row-less-object guard. */
|
||||
async existsByCacheKey(cache_key: string): Promise<boolean> {
|
||||
const row = await this.findOne({ cache_key }, { fields: ['cache_key'] });
|
||||
return !!row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,141 @@
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import type { TrekPhotos } from '../entities/TrekPhotos.entity';
|
||||
import { coalesceParam } from '../dialect/sql-functions';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TrekPhotosRepository extends TrekRepository<TrekPhotos> {}
|
||||
/**
|
||||
* `SELECT * FROM trek_photos WHERE id = ?`'s row (PH6/MA4 — the same
|
||||
* statement text, one method serving both `TrekPhotoRegistrationService
|
||||
* .resolve` and `MemoriesAccessService.canAccessTrekPhoto`'s entry read).
|
||||
* Reuses the pre-existing `TrekPhoto` type from `../../types` (the row shape
|
||||
* every consumer already imports) rather than a second, parallel row
|
||||
* interface — its key set matches the entity's scalar columns exactly
|
||||
* (`owner` is the hidden relation, `owner_id` its persist(false) twin).
|
||||
*/
|
||||
const _trekPhotoRowKeys: AssertRowKeys<TrekPhoto, TrekPhotos> = true;
|
||||
|
||||
export class TrekPhotosRepository extends TrekRepository<TrekPhotos> {
|
||||
/** PH1's SELECT half — `getOrCreate`'s existing-row lookup. */
|
||||
async findByProviderAsset(provider: string, asset_id: string, owner_id: number): Promise<{ id: number } | null> {
|
||||
const row = await this.findOne({ provider, asset_id, owner: owner_id }, { fields: ['id'] });
|
||||
return row ? { id: row.id } : null;
|
||||
}
|
||||
|
||||
/** PH2 — conditional passphrase re-encrypt on an already-registered remote photo. */
|
||||
async setPassphrase(id: number, passphrase: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { passphrase });
|
||||
}
|
||||
|
||||
/** PH3 — `INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type)`. */
|
||||
async insertRemote(data: {
|
||||
provider: string;
|
||||
asset_id: string;
|
||||
owner_id: number;
|
||||
passphrase: string | null;
|
||||
media_type: string;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
provider: data.provider,
|
||||
asset_id: data.asset_id,
|
||||
owner: data.owner_id,
|
||||
passphrase: data.passphrase,
|
||||
media_type: data.media_type,
|
||||
});
|
||||
}
|
||||
|
||||
/** PH4's SELECT half — `getOrCreateLocal`'s existing-row lookup. */
|
||||
async findLocalByPath(file_path: string): Promise<{ id: number } | null> {
|
||||
const row = await this.findOne({ provider: 'local', file_path }, { fields: ['id'] });
|
||||
return row ? { id: row.id } : null;
|
||||
}
|
||||
|
||||
/** PH5 — `INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms)`, always `provider = 'local'`. */
|
||||
async insertLocal(data: {
|
||||
file_path: string;
|
||||
thumbnail_path: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
media_type: string;
|
||||
duration_ms: number | null;
|
||||
}): Promise<number> {
|
||||
return await this.insert({
|
||||
provider: 'local',
|
||||
file_path: data.file_path,
|
||||
thumbnail_path: data.thumbnail_path,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
media_type: data.media_type,
|
||||
duration_ms: data.duration_ms,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PH6 (`TrekPhotoRegistrationService.resolve`) / MA4 (`MemoriesAccessService
|
||||
* .canAccessTrekPhoto`'s entry read) — byte-identical `SELECT * FROM
|
||||
* trek_photos WHERE id = ?`, one method for both call sites (D4).
|
||||
*/
|
||||
async findById(id: number): Promise<TrekPhoto | null> {
|
||||
const row = await this.findOne({ id });
|
||||
return row ? (toRow(row) as TrekPhoto) : null;
|
||||
}
|
||||
|
||||
/** PH7 — retarget a local photo onto a provider once it has been uploaded there. */
|
||||
async retarget(id: number, provider: string, asset_id: string, owner_id: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { provider, asset_id, owner: owner_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* PH8 — stamp a generated local thumbnail. `COALESCE(width, ?)`/
|
||||
* `COALESCE(height, ?)` keep dimensions a re-generated thumbnail already
|
||||
* knew (the caller-side guard for "don't blank what's known" stays on the
|
||||
* service, same as legacy).
|
||||
*/
|
||||
async patchThumbnail(id: number, thumbnail_path: string, width: number, height: number): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, {
|
||||
thumbnail_path,
|
||||
width: coalesceParam(platform, 'width', width),
|
||||
height: coalesceParam(platform, 'height', height),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PH9 — `COALESCE`d capture metadata write. The service computes the final
|
||||
* `taken_at`/`lat`/`lng` (the all-empty early return, the pair-or-neither
|
||||
* coordinate guard) before calling this; this method only issues the
|
||||
* three-column `COALESCE(col, ?)` UPDATE unconditionally.
|
||||
*/
|
||||
async patchCaptureMetadata(id: number, taken_at: string | null, lat: number | null, lng: number | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, {
|
||||
taken_at: coalesceParam(platform, 'taken_at', taken_at),
|
||||
lat: coalesceParam(platform, 'lat', lat),
|
||||
lng: coalesceParam(platform, 'lng', lng),
|
||||
});
|
||||
}
|
||||
|
||||
/** PH11 — `DELETE FROM trek_photos WHERE id = ? AND provider != 'local'`; local photos are never auto-reclaimed here. */
|
||||
async deleteNonLocal(id: number): Promise<number> {
|
||||
return await this.nativeDelete({ id, provider: { $ne: 'local' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* TH1 — `SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'`.
|
||||
* The caller (`ThumbnailService.sweepOrphanThumbs`) always passes the
|
||||
* literal `'journey/'` prefix; `$like` binds the wildcard-appended value as
|
||||
* a genuine parameter (no dialect helper needed — a plain `LIKE` with a
|
||||
* hardcoded prefix, no injection surface). Rows with a NULL `file_path`
|
||||
* never match `LIKE`, so every returned value is a real string.
|
||||
*/
|
||||
async listByFilePathPrefix(prefix: string): Promise<string[]> {
|
||||
const rows = await this.find({ file_path: { $like: `${prefix}%` } }, { fields: ['file_path'] });
|
||||
return rows.map((row) => row.file_path).filter((path): path is string => path != null);
|
||||
}
|
||||
|
||||
/** TH2 — `SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'`, same shape as TH1 on the sibling column. */
|
||||
async listByThumbnailPathPrefix(prefix: string): Promise<string[]> {
|
||||
const rows = await this.find({ thumbnail_path: { $like: `${prefix}%` } }, { fields: ['thumbnail_path'] });
|
||||
return rows.map((row) => row.thumbnail_path).filter((path): path is string => path != null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity';
|
||||
import { currentTimestamp } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {}
|
||||
/** MA7/MA8's shared row shape — album_id always needed, passphrase only by the sync path (MA8). */
|
||||
export interface TripAlbumLinkSyncRow {
|
||||
album_id: string;
|
||||
passphrase: string | null;
|
||||
}
|
||||
|
||||
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
|
||||
/**
|
||||
* MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same
|
||||
* user-scoped WHERE, different SELECT list in the legacy text
|
||||
* (`album_id` alone vs. `album_id, passphrase`); one method serving both,
|
||||
* per the inventory's own proposal, since MA7's caller simply ignores the
|
||||
* extra `passphrase` column. `SELECT album_id, passphrase FROM
|
||||
* trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?` — note
|
||||
* this is USER-scoped, not just trip-scoped: a link belongs to the user
|
||||
* who created it, not the whole trip.
|
||||
*/
|
||||
async findScoped(id: number, trip_id: number, user_id: number): Promise<TripAlbumLinkSyncRow | null> {
|
||||
const row = await this.findOne({ id, trip: trip_id, user: user_id }, { fields: ['album_id', 'passphrase'] });
|
||||
return row ? { album_id: row.album_id, passphrase: row.passphrase ?? null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MA9 — `UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP
|
||||
* WHERE id = ?`. Deliberately unscoped by trip/user, matching the legacy
|
||||
* statement: the caller (`syncImmichAlbum`/`syncSynologyAlbum`) has
|
||||
* already resolved `linkId` through `findScoped` (MA7/MA8) earlier in the
|
||||
* same request, so this write relies entirely on that prior resolution —
|
||||
* not a defect to fix here (see the class docstring on `MemoriesAccessService`).
|
||||
*/
|
||||
async touchSyncTime(id: number): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
import type { TripPhotos } from '../entities/TripPhotos.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripPhotosRepository extends TrekRepository<TripPhotos> {}
|
||||
export class TripPhotosRepository extends TrekRepository<TripPhotos> {
|
||||
/**
|
||||
* PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ?
|
||||
* LIMIT 1`, used by `TrekPhotoRegistrationService.deleteIfOrphan` alongside
|
||||
* the still-raw `journey_photos` existence check (`// PH10 — Plan 3g`).
|
||||
*/
|
||||
async existsForPhoto(photo_id: number): Promise<boolean> {
|
||||
const row = await this.findOne({ photo: photo_id }, { fields: ['id'] });
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* MA3 — the regular-trip branch of `MemoriesAccessService.canAccessUserPhoto`:
|
||||
* is this provider asset shared by this exact user into this exact trip?
|
||||
* `SELECT 1 FROM trip_photos tp JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
* WHERE tp.user_id = ? AND tkp.asset_id = ? AND tkp.provider = ? AND
|
||||
* tp.trip_id = ? AND tp.shared = 1 LIMIT 1`.
|
||||
*/
|
||||
async existsSharedForUser(trip_id: number, user_id: number, asset_id: string, provider: string): Promise<boolean> {
|
||||
const row = await this.qb('tp')
|
||||
.select(['tp.id'])
|
||||
.join('tp.photo', 'tkp')
|
||||
.where({ 'tp.user': user_id, 'tp.trip': trip_id, 'tp.shared': 1, 'tkp.asset_id': asset_id, 'tkp.provider': provider })
|
||||
.execute<{ id: number } | undefined>('get', false);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* MA5's source data — every trip a photo is shared into (`shared = 1`),
|
||||
* for `MemoriesAccessService.canAccessTrekPhoto` to check each one against
|
||||
* `TripsRepository.findAccessible` (R4: reuse the existing accessibility
|
||||
* builder rather than hand-translating the legacy `EXISTS(...UNION
|
||||
* ALL...)` predicate into a second, independent form). Legacy statement:
|
||||
* `SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1
|
||||
* AND EXISTS(...trip_members/trips...) LIMIT 1` — the `EXISTS` half is
|
||||
* exactly `findAccessible`'s own predicate, so this method hands back the
|
||||
* candidate trip ids and the caller does the short-circuiting `OR` over
|
||||
* `findAccessible`'s results instead.
|
||||
*
|
||||
* Full-row `find()`, not a `fields: ['trip_id']` projection: `trip_id` is a
|
||||
* `persist(false)` scalar twin of the `trip` relation, and a narrowed
|
||||
* `fields` selection silently drops it from the hydrated result (the same
|
||||
* bare-`persist(false)`-select trap `RoadtripDayBoundariesRepository`
|
||||
* documents — verified empirically here too, not assumed) — only an
|
||||
* unrestricted `find()` (or `columnRef` through a `qb()`) hydrates it.
|
||||
*/
|
||||
async listTripIdsSharedForPhoto(photo_id: number): Promise<number[]> {
|
||||
const rows = await this.find({ photo: photo_id, shared: 1 });
|
||||
return rows.map((row) => row.trip_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
|
||||
import { getCountryFromCoords } from '../atlas/atlas-geo';
|
||||
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
|
||||
|
||||
@@ -94,7 +94,7 @@ export class JourneyDomainService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
private readonly photos: TrekPhotoRegistrationService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { fail, success, type ServiceResult } from './memories.helpers';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
|
||||
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
|
||||
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
|
||||
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository';
|
||||
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
|
||||
import type { TripAlbumLinksRepository } from '../../db/repositories/TripAlbumLinks.repository';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
|
||||
/**
|
||||
* Who may see which photo, and the album-link lookups the provider syncs need.
|
||||
@@ -10,10 +20,25 @@ import { fail, success, type ServiceResult } from './memories.helpers';
|
||||
* access checks answer for photos that live under a trip *or* a journey — a
|
||||
* provider asset is reachable through either, which is why neither check can
|
||||
* live in the trips or journey domain alone.
|
||||
*
|
||||
* Stays a service, not a single-table repository (Plan 3e): it composes
|
||||
* access logic across `trip_photos`/`trek_photos`/`trip_album_links`
|
||||
* (this plan's own tables) and `journeys`/`journey_contributors`/
|
||||
* `journey_photos` (Plan 3g's, not yet converted — every read against them
|
||||
* below stays raw, marked `// <SITE> — Plan 3g`). `DatabaseService` stays
|
||||
* injected for exactly those raw reads and for the `canAccessTrip` primitive
|
||||
* (a cross-cutting primitive, not a per-domain SQL statement this plan
|
||||
* converts).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MemoriesAccessService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
|
||||
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
|
||||
@InjectRepository(TripAlbumLinks) private readonly tripAlbumLinks: TripAlbumLinksRepository,
|
||||
@InjectRepository(Trips) private readonly trips: TripsRepository,
|
||||
) {}
|
||||
|
||||
async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
|
||||
if (requestingUserId === ownerUserId) {
|
||||
@@ -22,6 +47,7 @@ export class MemoriesAccessService {
|
||||
|
||||
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
||||
if (tripId === '0') {
|
||||
// MA1 — Plan 3g (journey_photos is not yet an owned repository table).
|
||||
const journeyPhoto = this.db.get<{ journey_id: number }>(`
|
||||
SELECT gp.journey_id
|
||||
FROM journey_photos gp
|
||||
@@ -33,6 +59,7 @@ export class MemoriesAccessService {
|
||||
`, assetId, provider, ownerUserId);
|
||||
if (!journeyPhoto) return false;
|
||||
|
||||
// MA2 — Plan 3g (journeys/journey_contributors are not yet owned repository tables).
|
||||
const access = this.db.get(`
|
||||
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
||||
UNION ALL
|
||||
@@ -42,18 +69,11 @@ export class MemoriesAccessService {
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// Regular trip photos — join through trek_photos
|
||||
const sharedAsset = this.db.get(`
|
||||
SELECT 1
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.user_id = ?
|
||||
AND tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tp.trip_id = ?
|
||||
AND tp.shared = 1
|
||||
LIMIT 1
|
||||
`, ownerUserId, assetId, provider, tripId);
|
||||
// Regular trip photos — join through trek_photos (MA3).
|
||||
const tripIdNum = toRowId(tripId);
|
||||
const sharedAsset = tripIdNum != null
|
||||
? await this.tripPhotos.existsSharedForUser(tripIdNum, ownerUserId, assetId, provider)
|
||||
: false;
|
||||
|
||||
if (!sharedAsset) {
|
||||
return false;
|
||||
@@ -64,27 +84,23 @@ export class MemoriesAccessService {
|
||||
// ── Unified photo access check (trek_photos based) ──────────────────────
|
||||
|
||||
async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise<boolean> {
|
||||
const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId);
|
||||
const photo = await this.trekPhotos.findById(trekPhotoId);
|
||||
if (!photo) return false;
|
||||
|
||||
// Owner always has access
|
||||
if (photo.owner_id === requestingUserId) return true;
|
||||
|
||||
// Check trip_photos — is this photo shared in a trip the user has access to?
|
||||
const tripAccess = this.db.get(`
|
||||
SELECT 1 FROM trip_photos tp
|
||||
WHERE tp.photo_id = ?
|
||||
AND tp.shared = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`, trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (tripAccess) return true;
|
||||
// MA5: the legacy `EXISTS(...trip_members/trips UNION ALL...)` predicate
|
||||
// is exactly `TripsRepository.findAccessible`'s own — reuse that builder
|
||||
// (R4) instead of hand-translating it into a second, independent form.
|
||||
const sharedTripIds = await this.tripPhotos.listTripIdsSharedForPhoto(trekPhotoId);
|
||||
for (const tripId of sharedTripIds) {
|
||||
if (await this.trips.findAccessible(tripId, requestingUserId)) return true;
|
||||
}
|
||||
|
||||
// Check journey_photos — is this photo in a journey the user can access?
|
||||
// MA6 — Plan 3g (journey_photos/journeys/journey_contributors are not yet owned repository tables).
|
||||
const journeyAccess = this.db.get(`
|
||||
SELECT 1 FROM journey_photos gp
|
||||
WHERE gp.photo_id = ?
|
||||
@@ -112,10 +128,13 @@ export class MemoriesAccessService {
|
||||
if (!access) return fail('Trip not found or access denied', 404);
|
||||
|
||||
try {
|
||||
const row = this.db.get<{ album_id: string }>(
|
||||
'SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
|
||||
linkId, tripId, userId,
|
||||
);
|
||||
// MA7: `toRowId` on `tripId`/`linkId` — both already validated as a
|
||||
// reachable trip by `canAccessTrip` above; a non-canonical spelling of
|
||||
// either (rule 15's accepted narrowing) answers the same "not found"
|
||||
// the legacy raw-bind affinity would have for a row it couldn't match.
|
||||
const id = toRowId(linkId);
|
||||
const tripIdNum = toRowId(tripId);
|
||||
const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null;
|
||||
|
||||
return row ? success(row.album_id) : fail('Album link not found', 404);
|
||||
} catch {
|
||||
@@ -128,10 +147,10 @@ export class MemoriesAccessService {
|
||||
if (!access) return fail('Trip not found or access denied', 404);
|
||||
|
||||
try {
|
||||
const row = this.db.get<{ album_id: string; passphrase: string | null }>(
|
||||
'SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
|
||||
linkId, tripId, userId,
|
||||
);
|
||||
// MA8 — same `toRowId` treatment as MA7 above.
|
||||
const id = toRowId(linkId);
|
||||
const tripIdNum = toRowId(tripId);
|
||||
const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null;
|
||||
|
||||
if (!row) return fail('Album link not found', 404);
|
||||
|
||||
@@ -143,6 +162,13 @@ export class MemoriesAccessService {
|
||||
}
|
||||
|
||||
async updateSyncTimeForAlbumLink(linkId: string): Promise<void> {
|
||||
this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId);
|
||||
// MA9: unscoped by trip/user, matching the legacy statement — the caller
|
||||
// has already resolved `linkId` through `getAlbumIdFromLink`/
|
||||
// `getAlbumLinkForSync` (MA7/MA8) earlier in the same request. A
|
||||
// non-canonical id (rule 15) is a no-op here, same as a legacy UPDATE
|
||||
// that matched zero rows.
|
||||
const id = toRowId(linkId);
|
||||
if (id == null) return;
|
||||
await this.tripAlbumLinks.touchSyncTime(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { MemoriesService } from './memories.service';
|
||||
import { MemoriesAccessService } from './memories-access.service';
|
||||
import { ImmichService } from './immich.service';
|
||||
@@ -25,6 +26,11 @@ import { ImmichPhotoProvider } from './providers/immich.provider';
|
||||
import { SynologyPhotoProvider } from './providers/synology.provider';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
|
||||
@@ -45,9 +51,19 @@ import { StorageModule } from '../storage/storage.module';
|
||||
* (#584): adding a photo backend means adding an adapter to this one list, not
|
||||
* finding every `switch (photo.provider)`. Registered here rather than in the
|
||||
* adapters themselves so the set is readable in one place.
|
||||
*
|
||||
* `MikroOrmModule.forFeature` registers `TripPhotosRepository`/
|
||||
* `TrekPhotosRepository`/`TripAlbumLinksRepository`/`TripsRepository`
|
||||
* (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).
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule],
|
||||
imports: [
|
||||
NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule,
|
||||
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta]),
|
||||
],
|
||||
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
|
||||
providers: [
|
||||
MemoriesService,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import exifr from 'exifr';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
|
||||
|
||||
/**
|
||||
* Ask the provider when and where a photo was taken, and record it (#1614).
|
||||
@@ -21,7 +21,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
export class PhotoCaptureBackfillService {
|
||||
constructor(
|
||||
private readonly resolver: PhotoResolverService,
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
private readonly photos: TrekPhotoRegistrationService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
|
||||
import { ThumbnailService } from './thumbnail.service';
|
||||
import { TrekPhotoCacheService } from './trek-photo-cache.service';
|
||||
import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers';
|
||||
@@ -23,7 +23,7 @@ import { StorageService } from '../storage/storage.service';
|
||||
@Injectable()
|
||||
export class PhotoResolverService {
|
||||
constructor(
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
private readonly photos: TrekPhotoRegistrationService,
|
||||
private readonly thumbnails: ThumbnailService,
|
||||
private readonly cache: TrekPhotoCacheService,
|
||||
private readonly providers: PhotoProviderRegistry,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Jimp } from 'jimp'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectRepository } from '@mikro-orm/nestjs'
|
||||
import { ADDON_IDS } from '../../addons'
|
||||
import { AddonsService } from '../addons/addons.service'
|
||||
import { DatabaseService } from '../database/database.service'
|
||||
import { StorageService } from '../storage/storage.service'
|
||||
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'
|
||||
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository'
|
||||
|
||||
const THUMB_MAX = 800
|
||||
const THUMB_QUALITY = 80
|
||||
@@ -31,7 +33,7 @@ export class ThumbnailService {
|
||||
constructor(
|
||||
private readonly addons: AddonsService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
|
||||
) {}
|
||||
|
||||
async ensureLocalThumbnail(
|
||||
@@ -91,15 +93,13 @@ export class ThumbnailService {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0
|
||||
|
||||
const live = new Set<string>()
|
||||
for (const row of this.db.all<{ file_path: string }>(
|
||||
"SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'",
|
||||
)) {
|
||||
live.add(journeyThumbName(row.file_path))
|
||||
// TH1
|
||||
for (const filePath of await this.trekPhotos.listByFilePathPrefix('journey/')) {
|
||||
live.add(journeyThumbName(filePath))
|
||||
}
|
||||
for (const row of this.db.all<{ thumbnail_path: string }>(
|
||||
"SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'",
|
||||
)) {
|
||||
live.add(row.thumbnail_path.slice('journey/'.length))
|
||||
// TH2
|
||||
for (const thumbnailPath of await this.trekPhotos.listByThumbnailPathPrefix('journey/thumbs/')) {
|
||||
live.add(thumbnailPath.slice('journey/'.length))
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Response } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { StorageNotFoundError } from '../storage/storage.types';
|
||||
import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity';
|
||||
import type { TrekPhotoCacheMetaRepository } from '../../db/repositories/TrekPhotoCacheMeta.repository';
|
||||
|
||||
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
@@ -30,7 +32,7 @@ function objectName(key: string): string {
|
||||
@Injectable()
|
||||
export class TrekPhotoCacheService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(TrekPhotoCacheMeta) private readonly cacheMeta: TrekPhotoCacheMetaRepository,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
@@ -39,19 +41,18 @@ export class TrekPhotoCacheService {
|
||||
}
|
||||
|
||||
async getFresh(key: string): Promise<{ contentType: string } | null> {
|
||||
const row = this.db.get<{ content_type: string; fetched_at: number }>(
|
||||
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?', key,
|
||||
);
|
||||
// TC1
|
||||
const row = await this.cacheMeta.findFreshness(key);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (Date.now() - row.fetched_at >= CACHE_TTL) {
|
||||
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
|
||||
await this.cacheMeta.deleteByCacheKey(key); // TC2
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await this.storage.exists('photos-trek', objectName(key)))) {
|
||||
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
|
||||
await this.cacheMeta.deleteByCacheKey(key); // TC3
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,10 +62,8 @@ export class TrekPhotoCacheService {
|
||||
async put(key: string, bytes: Buffer, contentType: string): Promise<void> {
|
||||
await this.storage.put('photos-trek', objectName(key), Readable.from(bytes));
|
||||
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)',
|
||||
key, contentType, Date.now(),
|
||||
);
|
||||
// TC4
|
||||
await this.cacheMeta.upsertMeta(key, contentType, Date.now());
|
||||
}
|
||||
|
||||
async serveFresh(res: Response, key: string): Promise<boolean> {
|
||||
@@ -99,13 +98,11 @@ export class TrekPhotoCacheService {
|
||||
|
||||
async sweepExpired(): Promise<void> {
|
||||
const cutoff = Date.now() - CACHE_TTL * 2;
|
||||
const stale = this.db.all<{ cache_key: string }>(
|
||||
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?', cutoff,
|
||||
);
|
||||
const stale = await this.cacheMeta.listStale(cutoff); // TC5
|
||||
|
||||
for (const row of stale) {
|
||||
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key);
|
||||
await this.storage.delete('photos-trek', objectName(row.cache_key));
|
||||
for (const cacheKey of stale) {
|
||||
await this.cacheMeta.deleteByCacheKey(cacheKey); // TC6
|
||||
await this.storage.delete('photos-trek', objectName(cacheKey));
|
||||
}
|
||||
|
||||
// Pass 2 (fix #4, spec rev 3.2): getFresh's expiry path deletes the meta
|
||||
@@ -116,8 +113,8 @@ export class TrekPhotoCacheService {
|
||||
for await (const stat of this.storage.list('photos-trek')) {
|
||||
if (stat.key.includes('/') || !stat.key.endsWith('.bin') || stat.mtimeMs >= cutoff) continue;
|
||||
const key = stat.key.slice(0, -'.bin'.length);
|
||||
const row = this.db.get('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?', key);
|
||||
if (!row) {
|
||||
const exists = await this.cacheMeta.existsByCacheKey(key); // TC7
|
||||
if (!exists) {
|
||||
await this.storage.delete('photos-trek', stat.key).catch(() => { /* race */ });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { broadcast } from '../../websocket';
|
||||
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
|
||||
import { ImmichService } from './immich.service';
|
||||
import { SynologyService } from './synology.service';
|
||||
import { MemoriesAccessService } from './memories-access.service';
|
||||
@@ -29,7 +29,7 @@ import { UnitOfWork } from '../database/unit-of-work';
|
||||
export class UnifiedMemoriesService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly photos: TrekPhotosRepository,
|
||||
private readonly photos: TrekPhotoRegistrationService,
|
||||
private readonly immich: ImmichService,
|
||||
private readonly synology: SynologyService,
|
||||
private readonly access: MemoriesAccessService,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TrekPhotosRepository } from './trek-photos.repository';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { TrekPhotoRegistrationService } from './trek-photos.repository';
|
||||
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
|
||||
|
||||
/**
|
||||
* The trek_photos store on its own, so both halves can have it without
|
||||
@@ -10,9 +13,14 @@ import { TrekPhotosRepository } from './trek-photos.repository';
|
||||
* bytes. That is a genuine mutual need, and a module this small breaks it
|
||||
* without a forwardRef: PhotosModule -> MemoriesModule -> TrekPhotosModule is
|
||||
* a straight line.
|
||||
*
|
||||
* `MikroOrmModule.forFeature` registers `TrekPhotosRepository`/
|
||||
* `TripPhotosRepository` (the ORM ones) for `TrekPhotoRegistrationService`'s
|
||||
* `@InjectRepository` constructor params (Plan 3e Task 6).
|
||||
*/
|
||||
@Module({
|
||||
providers: [TrekPhotosRepository],
|
||||
exports: [TrekPhotosRepository],
|
||||
imports: [MikroOrmModule.forFeature([TrekPhotos, TripPhotos])],
|
||||
providers: [TrekPhotoRegistrationService],
|
||||
exports: [TrekPhotoRegistrationService],
|
||||
})
|
||||
export class TrekPhotosModule {}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
|
||||
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository';
|
||||
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
|
||||
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
|
||||
|
||||
/**
|
||||
* The `trek_photos` table: register a photo, look one up, retarget it, drop it
|
||||
@@ -16,11 +21,24 @@ import type { TrekPhoto } from '../../types';
|
||||
*
|
||||
* It also draws the photos/ vs memories/ line: storage lives here, provider
|
||||
* dispatch stays with memories.
|
||||
*
|
||||
* Renamed from `TrekPhotosRepository` (Plan 3e R13): despite the name, it was
|
||||
* never a MikroORM repository — a `DatabaseService`-backed service that ran
|
||||
* raw SQL directly — and the old name collided with the generated ORM
|
||||
* repository this class now injects (`../../db/repositories/TrekPhotos
|
||||
* .repository.ts`, also `TrekPhotosRepository`, per the `EntityRepositoryType`
|
||||
* convention every entity in this program follows). Same shape as the 3d
|
||||
* §18.3 `ReservationsReadRepository` → `ReservationsReadService` precedent.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrekPhotosRepository {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
export class TrekPhotoRegistrationService {
|
||||
constructor(
|
||||
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
|
||||
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
/** PH1 — find-or-register a remote provider asset, re-encrypting a changed album passphrase on an existing row. */
|
||||
async getOrCreate(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
@@ -28,24 +46,24 @@ export class TrekPhotosRepository {
|
||||
passphrase?: string,
|
||||
mediaType: string = 'image',
|
||||
): Promise<number> {
|
||||
const existing = this.db.get<{ id: number }>(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?',
|
||||
provider, assetId, ownerId,
|
||||
);
|
||||
const existing = await this.trekPhotos.findByProviderAsset(provider, assetId, ownerId);
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
this.db.run('UPDATE trek_photos SET passphrase = ? WHERE id = ?', encrypt_api_key(passphrase), existing.id);
|
||||
await this.trekPhotos.setPassphrase(existing.id, encrypt_api_key(passphrase));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const res = this.db.run(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type) VALUES (?, ?, ?, ?, ?)',
|
||||
provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null, mediaType,
|
||||
);
|
||||
return Number(res.lastInsertRowid);
|
||||
return await this.trekPhotos.insertRemote({
|
||||
provider,
|
||||
asset_id: assetId,
|
||||
owner_id: ownerId,
|
||||
passphrase: passphrase ? encrypt_api_key(passphrase) : null,
|
||||
media_type: mediaType,
|
||||
});
|
||||
}
|
||||
|
||||
/** PH4/PH5 — find-or-register a locally uploaded photo, keyed on its stored path. */
|
||||
async getOrCreateLocal(
|
||||
filePath: string,
|
||||
thumbnailPath?: string | null,
|
||||
@@ -54,43 +72,40 @@ export class TrekPhotosRepository {
|
||||
mediaType: string = 'image',
|
||||
durationMs?: number | null,
|
||||
): Promise<number> {
|
||||
const existing = this.db.get<{ id: number }>(
|
||||
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath,
|
||||
);
|
||||
const existing = await this.trekPhotos.findLocalByPath(filePath);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = this.db.run(
|
||||
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
'local', filePath, thumbnailPath || null, width || null, height || null, mediaType, durationMs ?? null,
|
||||
);
|
||||
return Number(res.lastInsertRowid);
|
||||
return await this.trekPhotos.insertLocal({
|
||||
file_path: filePath,
|
||||
thumbnail_path: thumbnailPath || null,
|
||||
width: width || null,
|
||||
height: height || null,
|
||||
media_type: mediaType,
|
||||
duration_ms: durationMs ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** PH6 — `SELECT * FROM trek_photos WHERE id = ?`, the same statement `MemoriesAccessService.canAccessTrekPhoto` (MA4) reads through `TrekPhotosRepository.findById`. */
|
||||
async resolve(photoId: number): Promise<TrekPhoto | null> {
|
||||
return this.db.get<TrekPhoto>('SELECT * FROM trek_photos WHERE id = ?', photoId) || null;
|
||||
return await this.trekPhotos.findById(photoId);
|
||||
}
|
||||
|
||||
/** Retarget an existing row — used when a local photo is uploaded to Immich. */
|
||||
/** PH7 — retarget an existing row, used when a local photo is uploaded to Immich. */
|
||||
async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise<void> {
|
||||
this.db.run(
|
||||
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?',
|
||||
provider, assetId, ownerId, trekPhotoId,
|
||||
);
|
||||
await this.trekPhotos.retarget(trekPhotoId, provider, assetId, ownerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions
|
||||
* that were already known — a re-generated thumbnail must not blank them.
|
||||
* PH8 — stamp a generated local thumbnail onto the row. COALESCE keeps
|
||||
* dimensions that were already known — a re-generated thumbnail must not
|
||||
* blank them.
|
||||
*/
|
||||
async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise<void> {
|
||||
this.db.run(
|
||||
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?',
|
||||
thumbnailPath, width, height, photoId,
|
||||
);
|
||||
await this.trekPhotos.patchThumbnail(photoId, thumbnailPath, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where and when the picture was taken (#1614).
|
||||
* PH9 — where and when the picture was taken (#1614).
|
||||
*
|
||||
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
|
||||
* three sources disagree in how much they know. A provider search answers with
|
||||
@@ -98,7 +113,10 @@ export class TrekPhotosRepository {
|
||||
* only gives them up once its EXIF has been read. Whichever arrives first wins,
|
||||
* and a later, emptier answer must not blank what is already there.
|
||||
*
|
||||
* A photo with neither is the normal case, not a failure.
|
||||
* A photo with neither is the normal case, not a failure. The all-empty
|
||||
* early return and the pair-or-neither coordinate guard both stay here —
|
||||
* the repository's `patchCaptureMetadata` issues the COALESCE write
|
||||
* unconditionally once called.
|
||||
*/
|
||||
async recordCaptureMetadata(
|
||||
photoId: number,
|
||||
@@ -109,28 +127,26 @@ export class TrekPhotosRepository {
|
||||
// Coordinates are stored as a pair or not at all — a lone latitude is not a
|
||||
// place, and half a pair would put the photo on the null island.
|
||||
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
|
||||
this.db.run(
|
||||
`UPDATE trek_photos
|
||||
SET taken_at = COALESCE(taken_at, ?),
|
||||
lat = COALESCE(lat, ?),
|
||||
lng = COALESCE(lng, ?)
|
||||
WHERE id = ?`,
|
||||
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
|
||||
);
|
||||
await this.trekPhotos.patchCaptureMetadata(photoId, takenAt, hasPair ? lat : null, hasPair ? lng : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the row once no trip and no journey references it. Local photos are
|
||||
* kept: their bytes are ours, and the file would outlive the row.
|
||||
* PH10/PH11 — drop the row once no trip and no journey references it.
|
||||
* Local photos are kept: their bytes are ours, and the file would outlive
|
||||
* the row.
|
||||
*
|
||||
* PH10's original statement was one `UNION ALL` existence check across
|
||||
* `trip_photos` and `journey_photos`; split into its two halves here
|
||||
* (`TripPhotosRepository.existsForPhoto` for the 3e-owned table, a still
|
||||
* -raw `journey_photos` read for the not-yet-converted Plan 3g table) —
|
||||
* logically identical (neither half needs to run if the other already
|
||||
* found a row), never a transaction (both are reads).
|
||||
*/
|
||||
async deleteIfOrphan(photoId: number): Promise<void> {
|
||||
const stillUsed = this.db.get(`
|
||||
SELECT 1 FROM trip_photos WHERE photo_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_photos WHERE photo_id = ?
|
||||
LIMIT 1
|
||||
`, photoId, photoId);
|
||||
if (stillUsed) return;
|
||||
this.db.run("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'", photoId);
|
||||
if (await this.tripPhotos.existsForPhoto(photoId)) return;
|
||||
// PH10 — Plan 3g (journey_photos is not yet an owned repository table).
|
||||
const stillUsedByJourney = this.db.get('SELECT 1 FROM journey_photos WHERE photo_id = ?', photoId);
|
||||
if (stillUsedByJourney) return;
|
||||
await this.trekPhotos.deleteNonLocal(photoId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user