mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(db): AppSettings and Users repositories; forFeature wiring pattern
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user