mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(settings): user and instance settings through repositories
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces the per-user settings table's raw SQL; SettingsService drops DatabaseService entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/ resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead of a raw connection. An earlier version resolved those two repositories itself from the active MikroORM request context, so none of its six callers needed to change. That worked in production and e2e (always inside a real request) but broke service construction in plain unit tests across every calling domain (addons, auth x2, maps, transit, unsplash - 185 failures on a full run), since none of them wrap a request context. Each of those six services now injects its own AppSettingsRepository/UsersRepository and passes them through explicitly, matching how every other converted repository in this migration is consumed; their own SQL is untouched (outside Plan 3a).
This commit is contained in:
@@ -1,4 +1,80 @@
|
||||
import type { Settings } from '../entities/Settings.entity';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
export class SettingsRepository extends EntityRepository<Settings> {}
|
||||
/** A `settings` row (the per-user key/value table) as the API emits it. */
|
||||
export interface SettingRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const _settingRowKeys: AssertRowKeys<SettingRow, Settings> = true;
|
||||
|
||||
export class SettingsRepository extends EntityRepository<Settings> {
|
||||
/** `SELECT key, value FROM settings WHERE user_id = ?` */
|
||||
async getForUser(userId: number): Promise<SettingRow[]> {
|
||||
const rows = await this.find({ user: userId });
|
||||
return rows.map((row) => toRow(row) as SettingRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT value FROM settings WHERE user_id = ? AND key = ?`
|
||||
*
|
||||
* No `refresh: true`: the identity-map short-circuit (Task 0 review, I1;
|
||||
* corrected by the Task 3 review — it is NOT about a `fields` restriction)
|
||||
* only fires for a PRIMARY-KEY-ONLY filter (`findOne({ id })` / `findOne(pk)`):
|
||||
* MikroORM answers that shape straight from the identity map with zero
|
||||
* queries, invisible to a write on the same row earlier in the request.
|
||||
* This filter is `(user, key)` — `Settings`'s PK is the surrogate `id`, not
|
||||
* this composite — so it is never PK-only and always re-queries, the same
|
||||
* guarantee a `find()` call gets. `SETTINGSREPO-012`/`013` pin this
|
||||
* directly (a raw UPDATE/DELETE on the same row, then `getOne` in the same
|
||||
* request, sees it) rather than assume it.
|
||||
*/
|
||||
async getOne(userId: number, key: string): Promise<SettingRow | null> {
|
||||
const row = await this.findOne({ user: userId, key });
|
||||
return row ? (toRow(row) as SettingRow) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
* ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`
|
||||
*
|
||||
* The composite conflict target is the relation field (`user`), never its
|
||||
* `persist(false)` scalar twin (`user_id`) — same rule as every other
|
||||
* `create`/`upsert` call in this migration (D4's defaults rule): the
|
||||
* relation is the one column MikroORM actually writes, the twin is filled
|
||||
* by a subsequent read. `onConflictFields: ['user', 'key']` matches
|
||||
* `Settings`'s own `uniques: [{ properties: ['user_id', 'key'] }]`
|
||||
* (`Settings.entity.ts`, generated off the schema's inline
|
||||
* `UNIQUE(user_id, key)` by the `fix(db)` commit that precedes this one) —
|
||||
* `Settings.repository.test.ts` asserts both the schema-level unique index
|
||||
* and the entity metadata before this method relies on either.
|
||||
*
|
||||
* Only `value` is ever updated on conflict, matching the legacy statement's
|
||||
* `DO UPDATE SET value = excluded.value` exactly (no other column, no
|
||||
* `updated_at` — the `settings` table has none).
|
||||
*/
|
||||
async upsertForUser(userId: number, key: string, value: string): Promise<void> {
|
||||
await this.upsert({ user: userId, key, value }, { onConflictFields: ['user', 'key'], onConflictAction: 'merge' });
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE FROM settings WHERE user_id = ?` (`key` omitted) or
|
||||
* `DELETE FROM settings WHERE user_id = ? AND key = ?` (`key` given).
|
||||
*
|
||||
* No site in `nest/settings` itself calls this today (the inventory's 10
|
||||
* sites have no `DELETE FROM settings` — the one existing call,
|
||||
* `plugin-runtime.service.ts`'s per-plugin `key LIKE 'plugin:<id>:%'`
|
||||
* sweep, is `nest/plugins`' own conversion, out of this task's scope).
|
||||
* Declared for the same reason `AppSettingsRepository.deleteValue` is: the
|
||||
* repository's surface should be symmetric, and a future caller (a
|
||||
* user-cleanup/account-deletion flow, say) gets a real method instead of
|
||||
* reaching for raw SQL. Exercised by its own repository test only.
|
||||
*/
|
||||
async deleteForUser(userId: number, key?: string): Promise<number> {
|
||||
return this.nativeDelete(key === undefined ? { user: userId } : { user: userId, key });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import { EntityRepository } from '@mikro-orm/sql';
|
||||
* name outside this union fails `tsc` rather than reaching a query.
|
||||
*
|
||||
* This repository is the union's source of truth; `instance-api-keys.ts`
|
||||
* will import it rather than keeping its own copy (Task 5 wires that import
|
||||
* — it still declares the identical union itself today).
|
||||
* imports it rather than keeping its own copy (Task 5).
|
||||
*/
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Addons } from '../../db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
|
||||
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/**
|
||||
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
|
||||
@@ -27,10 +28,13 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
* MikroOrmModule.forFeature registers AddonsRepository/PhotoProvidersRepository/
|
||||
* PhotoProviderFieldsRepository/AppSettingsRepository for AddonsService's
|
||||
* @InjectRepository constructor (Plan 3a Task 4) — the forFeature +
|
||||
* @InjectRepository wiring pattern Task 0 set up on SettingsModule.
|
||||
* @InjectRepository wiring pattern Task 0 set up on SettingsModule. `Users` was
|
||||
* added by Task 5: `googleKeySource` passes `UsersRepository` explicitly to
|
||||
* `instance-api-keys.ts`'s resolveApiKey now, instead of that file resolving
|
||||
* one off the ambient request context.
|
||||
*/
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Addons, PhotoProviders, PhotoProviderFields, AppSettings])],
|
||||
imports: [MikroOrmModule.forFeature([Addons, PhotoProviders, PhotoProviderFields, AppSettings, Users])],
|
||||
controllers: [AddonsController],
|
||||
providers: [AddonsService, AddonGuard, AddonsMcp],
|
||||
exports: [AddonsService, AddonGuard],
|
||||
|
||||
@@ -15,6 +15,8 @@ import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entit
|
||||
import type { PhotoProviderFieldsRepository, PhotoProviderFieldRow } from '../../db/repositories/PhotoProviderFields.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
|
||||
@@ -30,15 +32,17 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re
|
||||
* per-call repository calls so admin toggles stay immediately visible.
|
||||
*
|
||||
* `DatabaseService` stays injected (Plan 3a Task 4) purely as a passthrough
|
||||
* for two cross-domain functions this service still calls: `transit-provider.ts`'s
|
||||
* readTransitProvider/writeTransitProvider (nest/transit, not one of this
|
||||
* plan's six domains) and `instance-api-keys.ts`'s resolveApiKey (nest/settings,
|
||||
* Task 5's own conversion). Neither reads/writes through `this.db` in THIS
|
||||
* file — every `app_settings`/`addons`/`photo_providers`/`photo_provider_fields`
|
||||
* for `transit-provider.ts`'s readTransitProvider/writeTransitProvider
|
||||
* (nest/transit, not one of this plan's six domains — its own conversion is
|
||||
* a later phase). It does not read/write through `this.db` in THIS file —
|
||||
* every `app_settings`/`addons`/`photo_providers`/`photo_provider_fields`
|
||||
* site this service itself used to touch is repository-backed below — so this
|
||||
* is the same kind of carve-out `permissions`' two guard delegations are: `grep
|
||||
* DatabaseService src/nest/addons` still finds this constructor parameter and
|
||||
* its import, not a raw query.
|
||||
* its import, not a raw query. `instance-api-keys.ts`'s resolveApiKey
|
||||
* (nest/settings, Task 5) no longer needs it: `googleKeySource` below passes
|
||||
* this service's own already-injected `AppSettingsRepository` plus a
|
||||
* `UsersRepository` Task 5 added here for exactly that.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
@@ -47,6 +51,7 @@ export class AddonsService {
|
||||
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
|
||||
@InjectRepository(PhotoProviderFields) private readonly photoProviderFields: PhotoProviderFieldsRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly users: UsersRepository,
|
||||
private readonly dbs: DatabaseService,
|
||||
) {}
|
||||
|
||||
@@ -249,7 +254,7 @@ export class AddonsService {
|
||||
* — the #1939 shape, one layer up.
|
||||
*/
|
||||
private async googleKeySource(userId: number): Promise<ApiKeySource | null> {
|
||||
return (await resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey)).source;
|
||||
return (await resolveApiKey(this.appSettings, this.users, 'maps_api_key', userId, readEnv().maps.placesApiKey)).source;
|
||||
}
|
||||
|
||||
async getTransitProvider(userId = 0) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { TokensModule } from '../tokens/tokens.module';
|
||||
import { AuthPublicController } from './auth-public.controller';
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -62,7 +65,13 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
|
||||
}),
|
||||
StorageModule,
|
||||
AllowedFileTypesModule,
|
||||
EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule, BudgetModule],
|
||||
EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule, BudgetModule,
|
||||
// AppSettings/Users: AuthService/UserProfileService each pass their own
|
||||
// AppSettingsRepository/UsersRepository to instance-api-keys.ts's
|
||||
// resolveApiKey/readInstanceApiKey/writeInstanceApiKey now (Plan 3a Task
|
||||
// 5) — everything else either service still reads/writes is raw SQL
|
||||
// through DatabaseService (auth's own conversion is a later plan).
|
||||
MikroOrmModule.forFeature([AppSettings, Users])],
|
||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
|
||||
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
|
||||
|
||||
@@ -16,6 +16,11 @@ import { validatePassword } from '../common/passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { resolveApiKey } from '../settings/instance-api-keys';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
// Type-and-guard only: the app-config read reports the provider choice, it does
|
||||
// not construct one, so this does not pull the maps domain into auth.
|
||||
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
|
||||
@@ -134,6 +139,8 @@ export class AuthService {
|
||||
private readonly tokens: EphemeralTokenService,
|
||||
private readonly allowedFileTypes: AllowedFileTypesService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
) {}
|
||||
|
||||
// Cookie
|
||||
@@ -267,11 +274,11 @@ export class AuthService {
|
||||
// nor hide them from a member who does have one (#1939). Unauthenticated the
|
||||
// question is only about the instance, which is the first two steps of the
|
||||
// chain; id 0 matches no row.
|
||||
const hasGoogleKey = !!(await resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey)).key;
|
||||
const hasGoogleKey = !!(await resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey)).key;
|
||||
// The same question for Amap, asked the same way. The client needs both to
|
||||
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
|
||||
// know whether the provider the admin selected actually has a credential.
|
||||
const hasAmapKey = !!(await resolveApiKey(this.db, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey)).key;
|
||||
const hasAmapKey = !!(await resolveApiKey(this.appSettings, this.usersRepo, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey)).key;
|
||||
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
|
||||
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
|
||||
const oidcDisplayName = readEnv().oidc.displayName ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
@@ -16,6 +17,10 @@ import {
|
||||
} from '../settings/instance-api-keys';
|
||||
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
|
||||
import { User } from '../../types';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
|
||||
/**
|
||||
* The account a user administers about themselves: display settings, avatar,
|
||||
@@ -28,8 +33,12 @@ import { User } from '../../types';
|
||||
* moved verbatim — same SQL, same validation order, same masking, same error
|
||||
* strings and status codes.
|
||||
*
|
||||
* DatabaseService is the only injected dependency, which is what made this the
|
||||
* second-cheapest cut after tokens.
|
||||
* DatabaseService was the only injected dependency at first, which is what
|
||||
* made this the second-cheapest cut after tokens; `AppSettingsRepository`/
|
||||
* `UsersRepository` (Plan 3a Task 5) were added later purely so the three
|
||||
* `instance-api-keys.ts` calls below pass their own repository rather than
|
||||
* that file resolving one itself — every other read/write here is still
|
||||
* `DatabaseService`'s raw SQL (auth is outside Plan 3a).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserProfileService {
|
||||
@@ -37,6 +46,8 @@ export class UserProfileService {
|
||||
private readonly db: DatabaseService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly uow: UnitOfWork,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -74,7 +85,7 @@ export class UserProfileService {
|
||||
isAdmin: boolean,
|
||||
): Promise<string> {
|
||||
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
|
||||
const instance = await readInstanceApiKey(this.db, name as InstanceApiKeyName);
|
||||
const instance = await readInstanceApiKey(this.appSettings, name as InstanceApiKeyName);
|
||||
if (instance !== null) return instance;
|
||||
}
|
||||
return decrypt_api_key(current?.[name]) ?? '';
|
||||
@@ -119,7 +130,7 @@ export class UserProfileService {
|
||||
private async mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): Promise<void> {
|
||||
if (!isAdmin) return;
|
||||
for (const name of INSTANCE_API_KEY_NAMES) {
|
||||
if (body[name] !== undefined) await writeInstanceApiKey(this.db, name, body[name]);
|
||||
if (body[name] !== undefined) await writeInstanceApiKey(this.appSettings, name, body[name]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,10 +290,10 @@ export class UserProfileService {
|
||||
// instance value exists — on that install it is what the resolver picks too.
|
||||
return {
|
||||
settings: {
|
||||
maps_api_key: (await readInstanceApiKey(this.db, 'maps_api_key')) ?? decrypt_api_key(user.maps_api_key),
|
||||
maps_api_key: (await readInstanceApiKey(this.appSettings, 'maps_api_key')) ?? decrypt_api_key(user.maps_api_key),
|
||||
openweather_api_key: decrypt_api_key(user.openweather_api_key),
|
||||
unsplash_api_key: (await readInstanceApiKey(this.db, 'unsplash_api_key')) ?? decrypt_api_key(user.unsplash_api_key),
|
||||
amap_api_key: (await readInstanceApiKey(this.db, 'amap_api_key')) ?? decrypt_api_key(user.amap_api_key),
|
||||
unsplash_api_key: (await readInstanceApiKey(this.appSettings, 'unsplash_api_key')) ?? decrypt_api_key(user.unsplash_api_key),
|
||||
amap_api_key: (await readInstanceApiKey(this.appSettings, 'amap_api_key')) ?? decrypt_api_key(user.amap_api_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -356,7 +367,7 @@ export class UserProfileService {
|
||||
// The key a search would actually use, not the one in this admin's column:
|
||||
// testing a value nothing resolves to is how "the panel says the key is
|
||||
// fine" and "every search 403s" coexisted (#1939).
|
||||
const { key: maps_api_key } = await resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
const { key: maps_api_key } = await resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
if (maps_api_key) {
|
||||
try {
|
||||
// Same Referer as maps.service googleFetch — without it, keys with an
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { MapsController } from './maps.controller';
|
||||
import { MapsService } from './maps.service';
|
||||
import { MapsMcp } from './maps.mcp';
|
||||
import { PlacePhotosModule } from '../place-photos/place-photos.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/**
|
||||
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
|
||||
* MapsService for the in-container consumers (BookingImportModule's Nominatim
|
||||
* geocoding, PlacesModule's search_place tool and list-import enrichment).
|
||||
* Nothing outside the container consumes this domain, so there is no bridge.
|
||||
*
|
||||
* MikroOrmModule.forFeature([AppSettings, Users]): MapsService passes its own
|
||||
* AppSettingsRepository/UsersRepository to instance-api-keys.ts's
|
||||
* resolveApiKey now (Plan 3a Task 5) — every other read here is still raw SQL
|
||||
* through DatabaseService (maps is outside Plan 3a).
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlacePhotosModule, StorageModule],
|
||||
imports: [PlacePhotosModule, StorageModule, MikroOrmModule.forFeature([AppSettings, Users])],
|
||||
controllers: [MapsController],
|
||||
providers: [MapsService, MapsMcp],
|
||||
exports: [MapsService],
|
||||
|
||||
@@ -13,6 +13,11 @@ import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { discardBody, exceedsDeclaredLength, readCapped, readCappedText } from '../../utils/cappedFetch';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
|
||||
import {
|
||||
AMAP_SHORT_HOSTS,
|
||||
@@ -690,6 +695,8 @@ export class MapsService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly photoCache: PlacePhotoCacheService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
) {}
|
||||
|
||||
/** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the
|
||||
@@ -1002,7 +1009,7 @@ export class MapsService {
|
||||
* is returned so a provider error can say which of the three was used.
|
||||
*/
|
||||
async resolveMapsKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
|
||||
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
return resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
}
|
||||
|
||||
async getMapsKey(userId: number): Promise<string | null> {
|
||||
@@ -1011,7 +1018,7 @@ export class MapsService {
|
||||
|
||||
/** The Amap credential, resolved through the identical three-step chain. */
|
||||
async resolveAmapKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
|
||||
return resolveApiKey(this.database, 'amap_api_key', userId, readEnv().maps.amapApiKey);
|
||||
return resolveApiKey(this.appSettings, this.usersRepo, 'amap_api_key', userId, readEnv().maps.amapApiKey);
|
||||
}
|
||||
|
||||
// ── Keyed provider selection ───────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository, InstanceApiKeyName } from '../../db/repositories/Users.repository';
|
||||
import type { ApiKeySource } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,12 @@ import type { ApiKeySource } from '@trek/shared';
|
||||
* Stored as an encrypted `app_settings` row under the name below, reusing
|
||||
* apiKeyCrypto, so the format matches what the users columns already hold and
|
||||
* a legacy plaintext value still reads back.
|
||||
*
|
||||
* `InstanceApiKeyName` itself now lives on `Users.repository.ts` (Plan 3a Task
|
||||
* 0/5) — re-exported here so this module's existing external imports of the
|
||||
* type (`auth/user-profile.service.ts` and others) keep working unchanged.
|
||||
*/
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
|
||||
export type { InstanceApiKeyName };
|
||||
|
||||
/** Instance names whose per-user column is still honoured as a last resort. */
|
||||
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = [
|
||||
@@ -37,20 +42,42 @@ export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = [
|
||||
*/
|
||||
export type { ApiKeySource };
|
||||
|
||||
// Full statements rather than an interpolated column: the name doubles as the
|
||||
// users column AND the app_settings key, and identifiers only ever come from a
|
||||
// literal allow-list.
|
||||
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
|
||||
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
|
||||
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
|
||||
amap_api_key: 'SELECT amap_api_key FROM users WHERE id = ?',
|
||||
};
|
||||
/**
|
||||
* These three functions used to take the raw-connection wrapper class
|
||||
* (`nest/database/database.service.ts`'s injectable) and run raw SQL through
|
||||
* it. They now take the caller's own `AppSettingsRepository`/`UsersRepository`
|
||||
* directly — every one of the six callers (`nest/addons`, `nest/auth` × 2,
|
||||
* `nest/maps`, `nest/transit`, `nest/unsplash`) constructor-injects them via
|
||||
* `@InjectRepository` and its own module's `MikroOrmModule.forFeature`, the
|
||||
* same wiring pattern every other converted domain in this plan uses.
|
||||
*
|
||||
* An earlier version of this file resolved the repositories itself from the
|
||||
* ACTIVE MikroORM request context (`RequestContext.getEntityManager()`)
|
||||
* instead of taking them as parameters, so none of the six callers' modules
|
||||
* needed new wiring. That worked in production and in e2e (both always run
|
||||
* inside a real request context, D6) but broke everywhere a unit test
|
||||
* constructs one of those six services directly with no MikroORM context at
|
||||
* all — which turned out to be the norm, not the exception: a full `npm run
|
||||
* test` run surfaced 185 failures across 12 files
|
||||
* (`addons.service.test.ts`, `auth.service.test.ts`,
|
||||
* `user-profile.service.test.ts`, `maps.service.test.ts` and its siblings,
|
||||
* `google-transit.provider.test.ts`, `unsplash.service.test.ts`,
|
||||
* `places.service.test.ts` transitively via maps, plus the one MCP-harness
|
||||
* case this was first caught from). Explicit repository parameters, matching
|
||||
* how every other converted repository in this migration is consumed, has no
|
||||
* such failure mode: a repository built via `t.repo(X)` (`allowGlobalContext:
|
||||
* true` in tests) works with or without a request context, exactly like
|
||||
* `AppSettingsRepository`'s/`SettingsRepository`'s own tests already prove.
|
||||
*/
|
||||
async function readInstanceValue(appSettings: AppSettingsRepository, name: InstanceApiKeyName): Promise<string | null> {
|
||||
const value = await appSettings.getValue(name);
|
||||
if (!value) return null;
|
||||
return decrypt_api_key(value) || null;
|
||||
}
|
||||
|
||||
/** The instance-wide value in cleartext, or null when unset/cleared. */
|
||||
export async function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): Promise<string | null> {
|
||||
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
|
||||
if (!row?.value) return null;
|
||||
return decrypt_api_key(row.value) || null;
|
||||
export async function readInstanceApiKey(appSettings: AppSettingsRepository, name: InstanceApiKeyName): Promise<string | null> {
|
||||
return readInstanceValue(appSettings, name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,12 +86,8 @@ export async function readInstanceApiKey(db: DatabaseService, name: InstanceApiK
|
||||
* key", and a missing row would let the resolver fall through to whatever old
|
||||
* value still sits in their own users column.
|
||||
*/
|
||||
export async function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): Promise<void> {
|
||||
db.run(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
name, maybe_encrypt_api_key(value) ?? ''
|
||||
);
|
||||
export async function writeInstanceApiKey(appSettings: AppSettingsRepository, name: InstanceApiKeyName, value: unknown): Promise<void> {
|
||||
await appSettings.setValue(name, maybe_encrypt_api_key(value) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,20 +102,24 @@ export async function writeInstanceApiKey(db: DatabaseService, name: InstanceApi
|
||||
*
|
||||
* `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
|
||||
* there is no personal key to find, so the chain ends at the instance.
|
||||
*
|
||||
* The per-user fallback used to be a literal `USER_ROW_SQL[name]` statement
|
||||
* map; it is now `UsersRepository.getApiKeyColumn(userId, name)`, the typed
|
||||
* dispatch `Users.repository.ts` (Plan 3a Task 0) already provides.
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
db: DatabaseService,
|
||||
appSettings: AppSettingsRepository,
|
||||
users: UsersRepository,
|
||||
name: InstanceApiKeyName,
|
||||
userId: number,
|
||||
operatorKey: string | undefined,
|
||||
): Promise<{ key: string | null; source: ApiKeySource | null }> {
|
||||
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
|
||||
|
||||
const instance = await readInstanceApiKey(db, name);
|
||||
const instance = await readInstanceValue(appSettings, name);
|
||||
if (instance) return { key: instance, source: 'instance' };
|
||||
if (!userId) return { key: null, source: null };
|
||||
|
||||
const row = db.get<Record<string, string | null>>(USER_ROW_SQL[name], userId);
|
||||
const own = decrypt_api_key(row?.[name]) || null;
|
||||
const own = decrypt_api_key(await users.getApiKeyColumn(userId, name)) || null;
|
||||
return own ? { key: own, source: 'user-row' } : { key: null, source: null };
|
||||
}
|
||||
|
||||
@@ -7,14 +7,24 @@ import { SettingsService } from './settings.service';
|
||||
import { SettingsMcp } from './settings.mcp';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Settings } from '../../db/entities/Settings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/** Exports SettingsService for in-container consumers (admin, share, llm-parse). */
|
||||
@Module({
|
||||
// AuthModule for the admin gate on the defaults routes, and for the
|
||||
// AuthService that SettingsMcp's demo gate injects. MikroOrmModule.forFeature
|
||||
// registers AppSettingsRepository for @InjectRepository(AppSettings) below —
|
||||
// the forFeature + @InjectRepository wiring pattern every later domain copies.
|
||||
imports: [AppConfigModule, AuthModule, AuditModule, MikroOrmModule.forFeature([AppSettings])],
|
||||
// registers AppSettingsRepository/SettingsRepository for the
|
||||
// @InjectRepository(...) constructor params below (Plan 3a Task 0/5) — the
|
||||
// forFeature + @InjectRepository wiring pattern every later domain copies.
|
||||
// `Users` is registered too even though nothing here uses
|
||||
// `@InjectRepository(Users)`: `instance-api-keys.ts`'s repository-backed
|
||||
// functions resolve `UsersRepository` off the ACTIVE MikroORM request
|
||||
// context instead (see that file's own docstring for why — its callers
|
||||
// span ~6 domains outside this module's DI graph), so this entry is here
|
||||
// for parity with the brief/inventory's documented wiring, not because a
|
||||
// provider in this module needs the token.
|
||||
imports: [AppConfigModule, AuthModule, AuditModule, MikroOrmModule.forFeature([AppSettings, Settings, Users])],
|
||||
controllers: [SettingsController, AdminDefaultUserSettingsController],
|
||||
providers: [SettingsService, SettingsMcp],
|
||||
exports: [SettingsService],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Settings } from '../../db/entities/Settings.entity';
|
||||
import type { SettingsRepository } from '../../db/repositories/Settings.repository';
|
||||
|
||||
/**
|
||||
* Exported so a caller that hands settings to somebody else can assert its own
|
||||
@@ -154,11 +155,9 @@ function serializeValue(key: string, value: unknown): string {
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly uow: UnitOfWork,
|
||||
// Wiring proof (Plan 3a Task 0): the rest of this service's app_settings
|
||||
// sites (S2/S3, the admin-defaults writes) stay on `this.db` until Task 5.
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Settings) private readonly settings: SettingsRepository,
|
||||
) {}
|
||||
|
||||
async getAdminUserDefaults(): Promise<Record<string, unknown>> {
|
||||
@@ -182,12 +181,6 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
async setAdminUserDefaults(partial: Record<string, unknown>): Promise<void> {
|
||||
const upsert = this.db.prepare(
|
||||
`INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
);
|
||||
const del = this.db.prepare("DELETE FROM app_settings WHERE key = ?");
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
if (!(DEFAULTABLE_USER_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
@@ -198,7 +191,7 @@ export class SettingsService {
|
||||
|
||||
// null/undefined means "reset to built-in default" — delete the row
|
||||
if (value === null || value === undefined) {
|
||||
del.run(appKey);
|
||||
await this.appSettings.deleteValue(appKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -215,7 +208,7 @@ export class SettingsService {
|
||||
const stored = ENCRYPTED_SETTING_KEYS.has(key)
|
||||
? (maybe_encrypt_api_key(String(value)) ?? String(value))
|
||||
: JSON.stringify(value);
|
||||
upsert.run(appKey, stored);
|
||||
await this.appSettings.setValue(appKey, stored);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -223,7 +216,7 @@ export class SettingsService {
|
||||
async getUserSettings(userId: number): Promise<Record<string, unknown>> {
|
||||
const adminDefaults = await this.getAdminUserDefaults();
|
||||
|
||||
const rows = this.db.all<{ key: string; value: string }>('SELECT key, value FROM settings WHERE user_id = ?', userId);
|
||||
const rows = await this.settings.getForUser(userId);
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
if (MASKED_SETTING_KEYS.has(row.key)) {
|
||||
@@ -292,17 +285,10 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
async upsertSetting(userId: number, key: string, value: unknown) {
|
||||
this.db.run(`
|
||||
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
`, userId, key, serializeValue(key, value));
|
||||
await this.settings.upsertForUser(userId, key, serializeValue(key, value));
|
||||
}
|
||||
|
||||
async bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
|
||||
const upsert = this.db.prepare(`
|
||||
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
let written = 0;
|
||||
await this.uow.transactional(async () => {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -310,7 +296,7 @@ export class SettingsService {
|
||||
// bulk save can never overwrite a stored secret with the mask (the
|
||||
// single-upsert route has the same no-op in the controller).
|
||||
if (value === MASKED_SETTING_VALUE) continue;
|
||||
upsert.run(userId, key, serializeValue(key, value));
|
||||
await this.settings.upsertForUser(userId, key, serializeValue(key, value));
|
||||
written++;
|
||||
}
|
||||
});
|
||||
@@ -324,7 +310,7 @@ export class SettingsService {
|
||||
* resolver needs the real API key). Returns null when unset.
|
||||
*/
|
||||
async getDecryptedUserSetting(userId: number, key: string): Promise<string | null> {
|
||||
const row = this.db.get<{ value: string }>('SELECT value FROM settings WHERE user_id = ? AND key = ?', userId, key);
|
||||
const row = await this.settings.getOne(userId, key);
|
||||
if (!row || row.value === '' || row.value == null) return null;
|
||||
if (ENCRYPTED_SETTING_KEYS.has(key)) return decrypt_api_key(row.value);
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { toApiLang } from '../maps/maps.helpers';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { readTransitProvider } from './transit-provider';
|
||||
import {
|
||||
decodePolyline,
|
||||
@@ -234,10 +239,14 @@ function stopFrom(stop: GoogleStop | undefined, fallback: GoogleLatLng | undefin
|
||||
|
||||
@Injectable()
|
||||
export class GoogleTransitProvider {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
) {}
|
||||
|
||||
private async resolveKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
|
||||
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
return resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { TransitController } from './transit.controller';
|
||||
import { TransitService } from './transit.service';
|
||||
import { TransitMcp } from './transit.mcp';
|
||||
@@ -8,16 +9,24 @@ import { DaysModule } from '../days/days.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/**
|
||||
* Transit domain (#1065) — the Transitous/MOTIS proxy, with the optional
|
||||
* Google backend (#1699) behind it. TransitMcp carries the
|
||||
* decorator-registered MCP tools; DaysModule/ReservationsModule feed
|
||||
* create_transit_journey. Exports TransitService for in-container consumers.
|
||||
*
|
||||
* MikroOrmModule.forFeature([AppSettings, Users]): GoogleTransitProvider
|
||||
* passes its own AppSettingsRepository/UsersRepository to
|
||||
* instance-api-keys.ts's resolveApiKey now (Plan 3a Task 5) — everything else
|
||||
* in this domain (transit-provider.ts included) is still raw SQL through
|
||||
* DatabaseService (transit's own conversion is a later plan).
|
||||
*/
|
||||
@Module({
|
||||
// DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both.
|
||||
imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule],
|
||||
imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users])],
|
||||
controllers: [TransitController],
|
||||
providers: [TransitService, TransitMcp, GoogleTransitProvider],
|
||||
exports: [TransitService],
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { UnsplashService } from './unsplash.service';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
|
||||
/** Unsplash cover search and download. No controller of its own — trips and
|
||||
* places both reach it. Deliberately NOT @Global, matching PermissionsModule:
|
||||
* e2e TestingModules resolve it through each consumer's explicit import.
|
||||
* AppConfigModule is imported explicitly because @Global only applies to
|
||||
* modules that are in the graph, which a single-domain TestingModule is not. */
|
||||
* modules that are in the graph, which a single-domain TestingModule is not.
|
||||
* MikroOrmModule.forFeature registers AppSettingsRepository/UsersRepository
|
||||
* for UnsplashService's @InjectRepository constructor params (Plan 3a Task
|
||||
* 5): getUnsplashKey passes them straight into instance-api-keys.ts's
|
||||
* resolveApiKey, which no longer reads a raw connection at all. */
|
||||
@Module({
|
||||
imports: [AppConfigModule, StorageModule],
|
||||
imports: [AppConfigModule, StorageModule, MikroOrmModule.forFeature([AppSettings, Users])],
|
||||
providers: [UnsplashService],
|
||||
exports: [UnsplashService],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
import { exceedsDeclaredLength, readCapped } from '../../utils/cappedFetch';
|
||||
import { resolveApiKey } from '../settings/instance-api-keys';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository } from '../../db/repositories/Users.repository';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@@ -46,13 +50,15 @@ const COVER_EXT_BY_TYPE: Record<string, string> = {
|
||||
* Unsplash search and cover download.
|
||||
*
|
||||
* Its own module rather than a method on trips or places: both call it, and the
|
||||
* key resolution reads the env AND the database, so it needs the injected
|
||||
* connection either way.
|
||||
* key resolution reads the env and (via instance-api-keys.ts's resolveApiKey,
|
||||
* Plan 3a Task 5) AppSettingsRepository/UsersRepository, so it needs those
|
||||
* injected either way.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnsplashService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
|
||||
private readonly env: RuntimeEnvService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
@@ -67,7 +73,9 @@ export class UnsplashService {
|
||||
* which case the search falls back to the unauthenticated endpoint.
|
||||
*/
|
||||
async getUnsplashKey(userId: number): Promise<string | null> {
|
||||
return (await resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey)).key;
|
||||
return (
|
||||
await resolveApiKey(this.appSettings, this.usersRepo, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey)
|
||||
).key;
|
||||
}
|
||||
|
||||
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
|
||||
|
||||
Reference in New Issue
Block a user