feat(db): AppSettings and Users repositories; forFeature wiring pattern

This commit is contained in:
jubnl
2026-09-22 12:06:13 +02:00
parent 47e1801908
commit f460ae4fcb
30 changed files with 557 additions and 37 deletions
@@ -1,4 +1,66 @@
import type { AppSettings } from '../entities/AppSettings.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { EntityRepository } from '@mikro-orm/sql';
export class AppSettingsRepository extends EntityRepository<AppSettings> {}
/** An `app_settings` row as the API emits it. */
export interface AppSettingsRow {
key: string | null;
value: string | null;
}
const _appSettingsRowKeys: AssertRowKeys<AppSettingsRow, AppSettings> = true;
export class AppSettingsRepository extends EntityRepository<AppSettings> {
/** `SELECT value FROM app_settings WHERE key = ?` */
async getValue(key: string): Promise<string | null> {
const row = await this.findOne({ key }, { fields: ['value'] });
return row?.value ?? null;
}
/** `SELECT key, value FROM app_settings WHERE key IN (?, ?, ...)` */
async getValues(keys: string[]): Promise<Map<string, string>> {
const rows = await this.find({ key: { $in: keys } });
const values = new Map<string, string>();
for (const row of rows) {
if (row.key != null && row.value != null) values.set(row.key, row.value);
}
return values;
}
/**
* Upsert ruling: `app_settings` (`Migration20200101000000_baseline_schema.ts`)
* is `CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT)` and no
* later migration adds a column or a timestamp — the table has never carried
* anything beyond `key`/`value`. Every legacy call site across settings,
* addons and permissions is either `INSERT OR REPLACE INTO app_settings
* (key, value) VALUES (?, ?)` or `INSERT INTO app_settings (key, value)
* VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` —
* with no other column whose reset by REPLACE could be observable, both
* spellings are exactly `em.upsert`'s job, with the sole unique field
* (`key`, the primary key) as the conflict target.
*/
async setValue(key: string, value: string): Promise<void> {
await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' });
}
/**
* `SELECT key, value FROM app_settings WHERE key LIKE ?` — the caller
* passes the bare prefix; this appends the trailing `%` itself, matching
* every legacy call site (`'perm_%'`, `'default_user_setting_%'`).
*
* Escaping: neither the legacy `LIKE 'prefix%'` literals nor `$like`'s
* parameter binding here escape `%`/`_` in the prefix — both are plain
* SQL LIKE, where a bound parameter's `%`/`_` are wildcards exactly like a
* literal's are. The repository test proves the two behave identically for
* a prefix that itself contains `%`/`_`.
*/
async findByKeyPrefix(prefix: string): Promise<AppSettingsRow[]> {
const rows = await this.find({ key: { $like: `${prefix}%` } });
return rows.map((row) => toRow(row) as AppSettingsRow);
}
/** `DELETE FROM app_settings WHERE key = ?` */
async deleteValue(key: string): Promise<number> {
return this.nativeDelete({ key });
}
}
+30 -1
View File
@@ -1,4 +1,33 @@
import type { Users } from '../entities/Users.entity';
import { EntityRepository } from '@mikro-orm/sql';
export class UsersRepository extends EntityRepository<Users> {}
/**
* The three third-party keys that belong to the instance rather than to a
* person (see `nest/settings/instance-api-keys.ts`). A closed union, never a
* dynamic column string — `getApiKeyColumn` dispatches through MikroORM's
* typed `fields` option, which only accepts a real property of `Users`, so a
* name outside this union fails `tsc` rather than reaching a query.
*
* This repository is the union's source of truth; `instance-api-keys.ts`
* imports it rather than keeping its own copy (Task 5 wires that import).
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
export class UsersRepository extends EntityRepository<Users> {
/** `SELECT email FROM users WHERE id = ?` */
async getEmail(userId: number): Promise<string | null> {
const row = await this.findOne({ id: userId }, { fields: ['email'] });
return row?.email ?? null;
}
/**
* One of the three instance-API-key columns, selected by `name`:
* `SELECT <name> FROM users WHERE id = ?`. `name` is the typed union above,
* not an interpolated column — MikroORM's `fields` option only accepts a
* real property of `Users`, so this can never select an arbitrary column.
*/
async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise<string | null> {
const row = await this.findOne({ id: userId }, { fields: [name] });
return row ? (row[name] ?? null) : null;
}
}
@@ -0,0 +1,70 @@
import type { EntityRepository, FilterQuery, OrderDefinition } from '@mikro-orm/core';
/**
* Shared lookup shapes for entities scoped to a user, some of which also
* allow a globally-shared row (a nullable owner column).
*
* Built for `CategoriesRepository`/`TagsRepository` (Plan 3a Task 3), whose
* `list`/`getById`/`getByIdAndUser` methods are the same two query shapes
* over different tables: list-scoped-to-owner and find-one-by-id with an
* ownership check that is either strict (`tags`, whose `user_id` column is
* `NOT NULL`) or permissive (`categories`, whose `user_id` is nullable and
* `NULL` rows are read as globally shared). Task 3 decides, per call site,
* which shape a given legacy statement actually used — `categories.getById`
* today has no ownership filter at all (`SELECT * FROM categories WHERE id
* = ?`), so it stays a plain `findOne({ id })` rather than either helper
* here; changing that would be a behaviour change, not a refactor.
*
* `ownerField`/`orderField` are taken as parameters (`keyof T`) rather than
* hard-coded so one helper serves every entity shaped this way; the `as
* FilterQuery<T>`/`as Record<string, 'asc'>` casts are the one place that
* type is asserted rather than inferred; the entity's own properties still
* decide, at the call site, which literal `ownerField`/`orderField` values
* are legal (`keyof T`).
*/
/**
* List every row owned by `ownerId`, ordered by `orderField` ascending:
* `SELECT * FROM <table> WHERE <ownerField> = ? ORDER BY <orderField> ASC`.
*/
export async function listForOwner<T extends object>(
repo: EntityRepository<T>,
ownerField: keyof T,
ownerId: unknown,
orderField: keyof T,
): Promise<T[]> {
return repo.find({ [ownerField]: ownerId } as FilterQuery<T>, {
orderBy: { [orderField]: 'asc' } as OrderDefinition<T>,
});
}
/**
* Find one row by id, strictly scoped to an owner:
* `SELECT * FROM <table> WHERE id = ? AND <ownerField> = ?`.
*/
export async function findOwnedByUser<T extends object>(
repo: EntityRepository<T>,
id: unknown,
ownerField: keyof T,
ownerId: unknown,
): Promise<T | null> {
return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery<T>);
}
/**
* Find one row by id, matching either a row `ownerId` owns or a row with no
* owner at all (a nullable owner column used for globally-shared rows):
* `SELECT * FROM <table> WHERE id = ? AND (<ownerField> = ? OR <ownerField>
* IS NULL)`.
*/
export async function findOwnedOrGlobal<T extends object>(
repo: EntityRepository<T>,
id: unknown,
ownerField: keyof T,
ownerId: unknown,
): Promise<T | null> {
return repo.findOne({
id,
$or: [{ [ownerField]: ownerId }, { [ownerField]: null }],
} as unknown as FilterQuery<T>);
}
+10 -3
View File
@@ -13,10 +13,17 @@ export function toRow<T extends object>(entity: T): EntityDTO<T> {
return wrap(entity).toObject() as EntityDTO<T>;
}
/** The scalar (column-backed) keys of an entity class: everything that is not a relation or a collection. */
/**
* The scalar (column-backed) keys of an entity class: everything that is not
* a relation or a collection. `Exclude<keyof T, symbol>` drops MikroORM's
* internal meta-properties (`[PrimaryKeyProp]`, `[OptionalProps]`, …) —
* `unique symbol`-keyed, declared on an entity whose primary key isn't a
* plain autoincrement `id` (e.g. `AppSettings`'s `[PrimaryKeyProp]?: 'key'`)
* — which are not columns and have no place in a hand-written row interface.
*/
export type ScalarKeys<T> = {
[K in keyof T]-?: NonNullable<T[K]> extends Collection<object> | Reference<object> ? never : K;
}[keyof T];
[K in Exclude<keyof T, symbol>]-?: NonNullable<T[K]> extends Collection<object> | Reference<object> ? never : K;
}[Exclude<keyof T, symbol>];
/**
* Compile-time parity between a hand-written row interface and the entity's
+6 -2
View File
@@ -1,16 +1,20 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AdminDefaultUserSettingsController, SettingsController } from './settings.controller';
import { AuthModule } from '../auth/auth.module';
import { AuditModule } from '../audit/audit.module';
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';
/** 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.
imports: [AppConfigModule, AuthModule, AuditModule],
// 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])],
controllers: [SettingsController, AdminDefaultUserSettingsController],
providers: [SettingsService, SettingsMcp],
exports: [SettingsService],
+9 -5
View File
@@ -1,9 +1,12 @@
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';
/**
* Exported so a caller that hands settings to somebody else can assert its own
@@ -153,19 +156,20 @@ 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,
) {}
async getAdminUserDefaults(): Promise<Record<string, unknown>> {
const rows = this.db.all<{ key: string; value: string }>(
"SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'"
);
const rows = await this.appSettings.findByKeyPrefix('default_user_setting_');
const defaults: Record<string, unknown> = {};
for (const row of rows) {
const settingKey = row.key.slice('default_user_setting_'.length);
const settingKey = (row.key ?? '').slice('default_user_setting_'.length);
if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
} else {
defaults[settingKey] = parseValue(row.value);
defaults[settingKey] = parseValue(row.value ?? '');
}
}
return defaults;