feat(admin): photo providers require the journey addon

This commit is contained in:
Maurice
2026-09-01 15:17:24 +02:00
committed by Maurice
parent 2834967919
commit fef5c39581
17 changed files with 214 additions and 23 deletions
+16 -8
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ADDON_IDS } from '../../addons';
import { DatabaseService } from '../database/database.service';
import type { Addon } from '../../types';
import { getPhotoProviderConfig } from '../memories/memories.helpers';
@@ -85,14 +86,21 @@ export class AddonsService {
const addons = this.db
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
const providers = this.db
.prepare(
`SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id`,
)
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
// Photo providers surface only inside journeys, so with the journey addon
// off they are unavailable no matter what their own rows say. Deriving that
// here (instead of a migration) also covers installs that still hold an
// enabled provider under a disabled journey from before updateAddon
// cascaded the disable.
const providers = !this.isAddonEnabled(ADDON_IDS.JOURNEY)
? []
: (this.db
.prepare(
`SELECT id, name, icon, enabled, sort_order
FROM photo_providers
WHERE enabled = 1
ORDER BY sort_order, id`,
)
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>);
const fields = this.db
.prepare(
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
+13 -1
View File
@@ -643,10 +643,22 @@ export class AdminService {
return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 };
}
// Photo providers are Journey's shelf rows — their whole UI lives inside
// journeys, so enabling one under a disabled journey addon would only
// advertise an integration nothing can reach.
if (provider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) {
return { error: 'Enable the Journey addon first', status: 409 };
}
this.db.transaction(() => {
if (addon) {
if (data.enabled !== undefined)
if (data.enabled !== undefined) {
this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
// Journey off takes its providers with it: a row left enabled would
// resurface the moment journey returns, which nobody switched on.
if (id === ADDON_IDS.JOURNEY && !data.enabled)
this.db.run('UPDATE photo_providers SET enabled = 0');
}
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
+9 -2
View File
@@ -3,6 +3,8 @@ import {
errorResult, ok, type McpContext,
} from '../../nest-mcp';
import { z } from 'zod';
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';
@@ -31,7 +33,9 @@ const SYNOLOGY_DEFAULT_LIMIT = 100;
/**
* Availability gate. With every `photo_providers` row switched off the admin has
* turned the whole integration off, and the settings page stops offering it at
* all: nothing here has anything left to browse. Which of the two providers is
* all: nothing here has anything left to browse. The journey addon rides along
* because the providers exist to feed journey entries — journey off means no
* provider counts as on, whatever its row says. Which of the two providers is
* on is a per-call question, answered by providerRefusal().
*/
const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean =>
@@ -62,6 +66,7 @@ export class MemoriesMcp {
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly db: DatabaseService,
private readonly addons: AddonsService,
) {}
/**
@@ -69,6 +74,7 @@ export class MemoriesMcp {
* a method, the same reason the addon gates need a public `addons`.
*/
enabledProviderIds(): string[] {
if (!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);
}
@@ -81,7 +87,8 @@ export class MemoriesMcp {
private providerRefusal(provider: ProviderId) {
const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider);
if (!row) return errorResult(`Provider: "${provider}" is not supported`);
if (row.enabled !== 1) return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
if (row.enabled !== 1 || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))
return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
return null;
}
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
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';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { ImmichService } from './immich.service';
@@ -31,12 +33,17 @@ export class UnifiedMemoriesService {
private readonly synology: SynologyService,
private readonly access: MemoriesAccessService,
private readonly notifications: NotificationsService,
private readonly addons: AddonsService,
) {}
private _providers(): Array<{id: string; enabled: boolean}> {
// A provider only counts as enabled while the journey addon is — its whole
// surface lives inside journeys. Covers rows left enabled from before
// updateAddon cascaded the journey disable.
const journeyOn = this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
return rows.map(r => ({ id: r.id, enabled: r.enabled === 1 }));
}
return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 }));
}
private _validProvider(provider: string): ServiceResult<string> {
const providers = this._providers();