{t('admin.placesProvider.subtitle')}
@@ -504,9 +502,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
- the operator's on a managed install, so the card is not shown there;
- which provider answers search stays the admin's call in the card above
- (server managed.ts). The per-place Google switches sit under the key
+ the operator's on a managed install, so the card is not shown there
+ (server managed.ts), and neither is the provider pick in the card
+ above. The per-place Google switches sit under the key
because they are set once and then never touched. */}
{!managed && (
diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts
index b8868aef8..1982c3927 100644
--- a/server/src/nest/addons/addons.service.ts
+++ b/server/src/nest/addons/addons.service.ts
@@ -120,8 +120,13 @@ export class AddonsService {
return this.photoProviders.listEnabledIds();
}
- /** wanderer may be used: Tours is on and the admin allowed it under Tours. */
+ /**
+ * wanderer may be used: Tours is on and the admin allowed it under Tours. Never on a managed
+ * install, where it would send searches to a server the user picks; the admin switch is locked
+ * there, so a value stored before the install became managed must not keep it on.
+ */
async isWandererAvailable(): Promise {
+ if (readEnv().managed.enabled) return false;
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
}
diff --git a/server/src/nest/notifications/storage-health-notifier.service.ts b/server/src/nest/notifications/storage-health-notifier.service.ts
index 37f153ac4..6bbe48b14 100644
--- a/server/src/nest/notifications/storage-health-notifier.service.ts
+++ b/server/src/nest/notifications/storage-health-notifier.service.ts
@@ -1,5 +1,6 @@
import { withRequestContext } from '../database/request-context';
import { StorageEventsService } from '../storage/storage-events.service';
+import { offeredEventTypes } from './notification-preferences.service';
import { NotificationsService } from './notifications.service';
import { ReplicaFailureDebouncer } from './replica-failure-debouncer';
import { MikroORM } from '@mikro-orm/core';
@@ -44,8 +45,19 @@ export class StorageHealthNotifierService implements OnApplicationBootstrap {
onApplicationBootstrap(): void {
this.events.onReplicaFailure((failure) => {
+ // On a managed install the replicas are the operator's, and the
+ // preference matrix has no row to mute this event, so the admins there
+ // would get alerts they can neither act on nor switch off. The warning
+ // keeps the failure in the operator's logs, behind the same debounce so
+ // an outage is one line an hour per backend rather than one per write.
const suppressed = this.debouncer.admit(failure.backend);
if (suppressed === null) return;
+ if (!offeredEventTypes().includes('replica_failure')) {
+ this.logger.warn(
+ `replica ${failure.op} failed on ${failure.backend}: ${failure.error} (${suppressed} suppressed)`,
+ );
+ return;
+ }
const sendNotification = () =>
this.notifications
.send({
diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts
index fb3695b0c..0d0afabb9 100644
--- a/server/tests/unit/nest/addons.service.test.ts
+++ b/server/tests/unit/nest/addons.service.test.ts
@@ -434,6 +434,22 @@ describe('AddonsService addon/feature flags', () => {
expect((await svc.list()).toursWanderer).toBe(false);
});
+ it('wanderer is never available on a managed install, whatever the stored switch says', async () => {
+ const saved = process.env.TREK_MANAGED;
+ try {
+ await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 });
+ await svc.updateToursWanderer(true);
+ expect(await svc.isWandererAvailable()).toBe(true);
+
+ process.env.TREK_MANAGED = '1';
+ expect(await svc.isWandererAvailable()).toBe(false);
+ expect((await svc.list()).toursWanderer).toBe(false);
+ } finally {
+ if (saved === undefined) delete process.env.TREK_MANAGED;
+ else process.env.TREK_MANAGED = saved;
+ }
+ });
+
it('OpenHikingMap under Tours is on until an admin switches it off, and offered only with Tours (MAP-01)', async () => {
expect(await svc.getToursHikingMap()).toEqual({ enabled: true }); // absent row → ON
expect((await svc.list()).toursHikingMap).toBe(false); // Tours is off
diff --git a/server/tests/unit/nest/notifications/storage-health-notifier.service.test.ts b/server/tests/unit/nest/notifications/storage-health-notifier.service.test.ts
index 4ca483da0..4a0806bb3 100644
--- a/server/tests/unit/nest/notifications/storage-health-notifier.service.test.ts
+++ b/server/tests/unit/nest/notifications/storage-health-notifier.service.test.ts
@@ -4,7 +4,7 @@ import { StorageEventsService } from '../../../../src/nest/storage/storage-event
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
-import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
+import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
// L4 (task-7-review.md): `orm` is now a required constructor param — in
// production it is always injected, so `@Optional()` was a fail-open seam
@@ -21,6 +21,12 @@ afterAll(async () => {
testDb.close();
});
+const savedManaged = process.env.TREK_MANAGED;
+afterEach(() => {
+ if (savedManaged === undefined) delete process.env.TREK_MANAGED;
+ else process.env.TREK_MANAGED = savedManaged;
+});
+
describe('StorageHealthNotifierService', () => {
it('NOTIF-001 subscribes on bootstrap and sends an admin-scoped replica_failure with params', () => {
const events = new StorageEventsService();
@@ -47,4 +53,14 @@ describe('StorageHealthNotifierService', () => {
notifier.onApplicationBootstrap();
expect(() => events.emitReplicaFailure({ backend: 'b', key: 'k', op: 'delete', error: 'e', at: 3 })).not.toThrow();
});
+
+ it('NOTIF-003 a managed install sends nothing, since its admins have no row to mute the alert', () => {
+ process.env.TREK_MANAGED = '1';
+ const events = new StorageEventsService();
+ const send = vi.fn().mockResolvedValue(undefined);
+ const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService, t.orm);
+ notifier.onApplicationBootstrap();
+ events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 4 });
+ expect(send).not.toHaveBeenCalled();
+ });
});