From b6de35a2cee486ae17b41c25007acef3ccc83e1b Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 24 Sep 2026 03:53:44 +0200 Subject: [PATCH] refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories --- server/src/db/repositories/Days.repository.ts | 29 ++++ .../PluginEntityMetadata.repository.ts | 62 +++++++++ .../PluginScheduledTasks.repository.ts | 52 +++++++ .../src/db/repositories/Plugins.repository.ts | 50 +++++++ .../src/db/repositories/Trips.repository.ts | 46 ++++++ .../src/db/repositories/Users.repository.ts | 16 +++ server/src/nest/journey/journal-rpc.module.ts | 19 ++- server/src/nest/journey/journal.rpc.ts | 19 +-- .../contributions/day-schedule.controller.ts | 9 +- .../contributions/day-tints.controller.ts | 9 +- .../contributions/place-details.controller.ts | 9 +- .../plugin-contributions.module.ts | 9 +- .../nest/plugins/host/rpc/host-surface.rpc.ts | 53 ++++--- server/src/nest/plugins/host/rpc/meta.rpc.ts | 87 +++++++----- .../plugins/oauth/plugin-oauth.controller.ts | 9 +- .../plugin-user-settings.controller.ts | 10 +- .../nest/plugins/plugins-feed.controller.ts | 12 +- .../nest/plugins/plugins-runtime.module.ts | 14 ++ server/src/nest/plugins/plugins.module.ts | 11 +- server/tests/helpers/plugin-host.ts | 23 ++- server/tests/helpers/rpc-host-deps.ts | 8 +- .../tests/unit/nest/roadtrip-router.test.ts | 10 +- .../unit/plugins/addon-gates.rpc.test.ts | 5 +- .../plugins/day-schedule.controller.test.ts | 5 +- .../unit/plugins/day-tints.controller.test.ts | 5 +- server/tests/unit/plugins/host-rpc.test.ts | 131 +++++++++++++++++- .../unit/plugins/journal-photo.rpc.test.ts | 32 ++--- .../plugins/place-details.controller.test.ts | 5 +- .../plugins/plugin-oauth.controller.test.ts | 8 +- .../plugin-user-settings.controller.test.ts | 8 +- .../plugins/plugins-feed.controller.test.ts | 8 +- .../unit/plugins/plugins-service.test.ts | 23 ++- 32 files changed, 633 insertions(+), 163 deletions(-) diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts index d5d27cea9..23be47c93 100644 --- a/server/src/db/repositories/Days.repository.ts +++ b/server/src/db/repositories/Days.repository.ts @@ -2,6 +2,7 @@ import type { Days } from '../entities/Days.entity'; import { DayAssignments } from '../entities/DayAssignments.entity'; import { DayNotes } from '../entities/DayNotes.entity'; import { DayAccommodations } from '../entities/DayAccommodations.entity'; +import { columnRef } from '../dialect/sql-functions'; import { toRow, type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; @@ -53,6 +54,34 @@ export class DaysRepository extends TrekRepository { return day ? (toRow(day) as DayRow) : undefined; } + /** + * CT1/CT2 (Plan 3j Task 5, `contributions/day-schedule.controller.ts` / + * `day-tints.controller.ts`, byte-identical text) — `SELECT id FROM days + * WHERE trip_id = ?`, the trip's own day-id set (used only for a + * membership check against a plugin-contributed `dayId`). + */ + async listIdsByTrip(trip_id: number): Promise { + const rows = await this.find({ trip: trip_id }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * MR9 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'day'` arm, + * R12's "one method per target table, no dynamic identifier dispatch" + * precedent — `PlacesRepository.findTripId`/`ReservationsRepository + * .findTripId`'s own docstrings) — `SELECT trip_id FROM days WHERE id = + * ?`. `trip_id` is a `persist(false)` mirror of the `trip` relation — + * `columnRef`, not a bare select (the program-wide trap). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('d') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + /** * The trip's highest `day_number`, 0 when it has no days. * diff --git a/server/src/db/repositories/PluginEntityMetadata.repository.ts b/server/src/db/repositories/PluginEntityMetadata.repository.ts index 87ab037ef..3bce51fba 100644 --- a/server/src/db/repositories/PluginEntityMetadata.repository.ts +++ b/server/src/db/repositories/PluginEntityMetadata.repository.ts @@ -1,4 +1,5 @@ import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; import { TrekRepository } from './_shared/trek-repository'; export class PluginEntityMetadataRepository extends TrekRepository { @@ -6,4 +7,65 @@ export class PluginEntityMetadataRepository extends TrekRepository { await this.nativeDelete({ plugin_id: pluginId }); } + + // ----------------------------------------------------------------------- + // MR1–MR6 (Plan 3j Task 5, `plugins/host/rpc/meta.rpc.ts`'s `meta.get`/ + // `meta.set`/`meta.list`/`meta.delete` RPC handlers — a plugin's own + // namespaced key/value store on a core entity). + // ----------------------------------------------------------------------- + + /** + * MR1/MR2 — `SELECT value FROM plugin_entity_metadata WHERE plugin_id=? + * AND entity_type=? AND entity_id=? AND key=?` — MR2 (`set`'s pre-write + * existence check) is byte-identical text, existence-only; the caller + * checks `!== null` rather than this repository exposing a second, + * narrower method for the same 4-column read. + */ + async findValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** MR3 — `SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?`, the `META_KEYS_MAX` per-entity quota. */ + async countForEntity(pluginId: string, entityType: string, entityId: number): Promise { + return await this.count({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId }); + } + + /** + * MR4 — `` INSERT INTO plugin_entity_metadata (plugin_id, entity_type, + * entity_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now')) + * ON CONFLICT(plugin_id, entity_type, entity_id, key) DO UPDATE SET value + * = excluded.value, updated_at = excluded.updated_at `` — the WIDEST + * composite-key upsert in the plan (4 key columns, the entity's own + * `uniques`). `PluginUserConfigRepository.upsertConfig`'s own precedent: + * `currentTimestamp(platform)` bound once so the inserted row and the + * `excluded.updated_at` merge target agree, matching the legacy + * single-statement `datetime('now')` call exactly. + */ + async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key, value, updated_at: currentTimestamp(platform) }, + { + onConflictFields: ['plugin_id', 'entity_type', 'entity_id', 'key'], + onConflictAction: 'merge', + onConflictMergeFields: ['value', 'updated_at'], + }, + ); + } + + /** MR5 — `SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key`. */ + async listForEntity(pluginId: string, entityType: string, entityId: number): Promise<{ key: string; value: string }[]> { + const rows = await this.find( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId }, + { fields: ['key', 'value'], orderBy: { key: 'asc' } }, + ); + return rows.map((r) => ({ key: r.key, value: r.value ?? '' })); + } + + /** MR6 — `DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?`, returning whether a row existed (`.changes > 0`). */ + async deleteValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }); + return changed > 0; + } } diff --git a/server/src/db/repositories/PluginScheduledTasks.repository.ts b/server/src/db/repositories/PluginScheduledTasks.repository.ts index 4d43feb6d..bf2801bfe 100644 --- a/server/src/db/repositories/PluginScheduledTasks.repository.ts +++ b/server/src/db/repositories/PluginScheduledTasks.repository.ts @@ -1,6 +1,15 @@ import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity'; import { TrekRepository } from './_shared/trek-repository'; +/** HR7's insert-only shape (`schedulerSet`'s VALUES list — `payload`/`every_ms` are always caller-supplied, never defaulted the way `id`/`created_at` are). */ +export interface PluginScheduledTaskUpsertInput { + plugin_id: string; + name: string; + due_at: number; + payload: string; + every_ms: number | null; +} + /** PR4 — the sweep's own due-task projection. */ export interface DueScheduledTaskRow { id: number; @@ -61,4 +70,47 @@ export class PluginScheduledTasksRepository extends TrekRepository { await this.nativeDelete({ plugin_id: pluginId }); } + + // ----------------------------------------------------------------------- + // HR5–HR8 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts`'s + // `scheduler.set`/`scheduler.cancel` RPC handlers — a plugin's own + // request to schedule/cancel one of its named tasks, distinct from PR4– + // PR10's sweep-tick statements above). + // ----------------------------------------------------------------------- + + /** HR5 — `SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?`, existence-only (the pre-quota check `schedulerSet` runs before counting). */ + async existsForPluginAndName(pluginId: string, name: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, name }, { fields: ['id'] }); + return row !== null; + } + + /** HR6 — `SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?`, the `SCHED_MAX` DoS-quota count. */ + async countForPlugin(pluginId: string): Promise { + return await this.count({ plugin_id: pluginId }); + } + + /** + * HR7 — `` INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, + * payload, every_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT (plugin_id, name) + * DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, + * every_ms = excluded.every_ms `` — the composite-key upsert on + * `(plugin_id, name)` (the entity's own `uniques`, `PluginScheduledTasks + * .entity.ts`): re-scheduling the same name replaces it in place. No + * COALESCE — every non-key column is re-assigned verbatim from `excluded` + * — so `em.upsert`'s `onConflictMergeFields` expresses it directly, + * `PluginUserConfigRepository.upsertConfig`'s own precedent for a plain + * (non-merging) composite upsert. + */ + async upsertTask(input: PluginScheduledTaskUpsertInput): Promise { + await this.upsert( + { plugin_id: input.plugin_id, name: input.name, due_at: input.due_at, payload: input.payload, every_ms: input.every_ms }, + { onConflictFields: ['plugin_id', 'name'], onConflictAction: 'merge', onConflictMergeFields: ['due_at', 'payload', 'every_ms'] }, + ); + } + + /** HR8 — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?` (`scheduler.cancel`), returning whether a row existed (`.changes > 0`). */ + async deleteByPluginAndName(pluginId: string, name: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, name }); + return changed > 0; + } } diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts index 5ab76e855..135a739db 100644 --- a/server/src/db/repositories/Plugins.repository.ts +++ b/server/src/db/repositories/Plugins.repository.ts @@ -54,6 +54,16 @@ export interface PluginNameCapabilitiesRow { capabilities: string; } +/** PFC1 — `plugins-feed.controller.ts#list`'s own client-facing feed projection. */ +export interface PluginFeedRow { + id: string; + name: string; + type: string; + icon: string | null; + capabilities: string; + granted_permissions: string; +} + /** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */ export interface PluginAdminRow { id: string; @@ -521,6 +531,46 @@ export class PluginsRepository extends TrekRepository { const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] }); return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null; } + + // ----------------------------------------------------------------------- + // Plan 3j Task 5 — the small plugin controllers' own statements (PFC1, + // PUC1/POC1). PAC1/PRF1 (the audit-read/append delegates) are Task 3's + // `plugin-audit.ts` conversion, not this repository's. + // ----------------------------------------------------------------------- + + /** + * PFC1 (`plugins-feed.controller.ts#list`) — `SELECT id, name, type, icon, + * capabilities, granted_permissions FROM plugins WHERE status = 'active' + * ORDER BY sort_order, name`. This row goes to the CLIENT, not a plugin + * process — R-facade's `withCamelCase` wrapper is never applied here (the + * client already speaks snake_case per the wire contract, D1); stays + * exactly as read. + */ + async findActiveFeedRows(): Promise { + const rows = await this.find( + { status: 'active' }, + { fields: ['id', 'name', 'type', 'icon', 'capabilities', 'granted_permissions'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] }, + ); + return rows.map((r) => ({ + id: r.id ?? '', + name: r.name, + type: r.type, + icon: r.icon ?? null, + capabilities: r.capabilities, + granted_permissions: r.granted_permissions ?? '', + })); + } + + /** + * PUC1/POC1 — `SELECT 1 FROM plugins WHERE id = ? AND status = 'active'`, + * byte-identical text, two call sites (`plugin-user-settings.controller.ts + * #activeWithUserFields`, `oauth/plugin-oauth.controller.ts#isActive`) — + * one shared method for both, existence-only. + */ + async existsActive(id: string): Promise { + const row = await this.findOne({ id, status: 'active' }, { fields: ['id'] }); + return row !== null; + } } /** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts index e92c29428..c01a3c28d 100644 --- a/server/src/db/repositories/Trips.repository.ts +++ b/server/src/db/repositories/Trips.repository.ts @@ -215,6 +215,52 @@ export class TripsRepository extends TrekRepository { .execute<{ id: number; user_id: number } | undefined>('get', false); } + /** + * MR8 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'trip'` arm) — + * `SELECT id FROM trips WHERE id = ?`, a bare existence probe (the entity + * type IS the trip, so the id resolves to itself once the row is + * confirmed to exist). + */ + async existsById(id: number): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where({ id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * HR9 (Plan 3j Task 5, `host/rpc/host-surface.rpc.ts#sharesATrip`, private) + * — byte-for-byte the legacy self-joined statement: + * SELECT 1 FROM trips t + * LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? + * LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? + * WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) + * AND (t.user_id = ? OR m2.user_id IS NOT NULL) + * LIMIT 1 + * — "does ANY trip exist where both users are owner-or-member" (the + * `users.getById` plugin RPC's own access gate — a bespoke bilateral + * membership check, NOT `canAccessTrip`-shaped, which is why it lives here + * rather than reusing `accessibleTripsQuery` — that builder joins ONE + * user's membership; this needs two independent joins, one per user, so + * neither side's `$or` can be satisfied by the other's row. + */ + async sharesTripWith(userIdA: number, userIdB: number): Promise { + const row = await this.qb('t') + .leftJoin('t.trip_members_collection', 'm1', { 'm1.user': userIdA }) + .leftJoin('t.trip_members_collection', 'm2', { 'm2.user': userIdB }) + .select(['t.id']) + .andWhere({ + $and: [ + { $or: [{ 't.user': userIdA }, { 'm1.user': { $ne: null } }] }, + { $or: [{ 't.user': userIdB }, { 'm2.user': { $ne: null } }] }, + ], + }) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + /** * `SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id * AND m.user_id = :userId WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts index 6abc5e597..08ddd75ed 100644 --- a/server/src/db/repositories/Users.repository.ts +++ b/server/src/db/repositories/Users.repository.ts @@ -1509,6 +1509,22 @@ export class UsersRepository extends TrekRepository { const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] }); return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null; } + + // --------------------------------------------------------------------- + // HR1 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts#getUser`) + // --------------------------------------------------------------------- + + /** + * HR1 — `SELECT id, username, display_name, avatar FROM users WHERE id = + * ?`. Returned RAW to the calling plugin (the RPC handler's own access + * gate runs first — self, or `TripsRepository.sharesTripWith`) — this + * repository method itself does no shaping, matching `findProfileBasic`'s + * narrower-but-similarly-shaped precedent above. + */ + async findPublicIdentity(id: number): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'display_name', 'avatar'] }); + return row ? { id: row.id, username: row.username, display_name: row.display_name ?? null, avatar: row.avatar ?? null } : null; + } } /** diff --git a/server/src/nest/journey/journal-rpc.module.ts b/server/src/nest/journey/journal-rpc.module.ts index 2b898e8a1..1a48e4827 100644 --- a/server/src/nest/journey/journal-rpc.module.ts +++ b/server/src/nest/journey/journal-rpc.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; import { AllowedFileTypesModule } from '../files/allowed-file-types.module'; import { MemoriesModule } from '../memories/memories.module'; import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { StorageModule } from '../storage/storage.module'; +import { DemoModule } from '../common/demo.module'; import { JourneyDomainModule } from './journey-domain.module'; import { JournalRpc } from './journal.rpc'; -import { Users } from '../../db/entities/Users.entity'; /** * The journal plugin surface, in its own container. @@ -22,13 +21,19 @@ import { Users } from '../../db/entities/Users.entity'; * JourneyDomainModule is re-exported so importing this one is a superset of * importing that one, and nothing that already depended on it has to change. * - * `MikroOrmModule.forFeature([Users])` — Plan 3g Task 3: `JournalRpc`'s - * demo-mode gate (JR1) now injects `UsersRepository.getEmail`, and - * `@InjectRepository` resolves from THIS module's own `forFeature` graph, - * not `AuthModule`'s (which this module does not import). + * `DemoModule` is imported explicitly (Plan 3i Task 4 fix wave's own + * precedent, restated here — Plan 3j Task 5, SV8): `DemoService` is + * `@Global()`, but that broadcast only reaches a module graph that actually + * imports it somewhere — a hand-built e2e `TestingModule` that never pulls in + * `AppModule` otherwise leaves `JournalRpc`'s `DemoService` dependency + * unresolved. The earlier `MikroOrmModule.forFeature([Users])` entry (Plan 3g + * Task 3, `JournalRpc`'s own `UsersRepository.getEmail` injection) is gone — + * `DemoService.isDemoUserId` resolves `Users` through its OWN constructor + * (an injected `EntityManager`, not `@InjectRepository`), so this module no + * longer needs the entity registered for that call. */ @Module({ - imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, MemoriesModule, PluginGuardsModule, MikroOrmModule.forFeature([Users])], + imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, MemoriesModule, PluginGuardsModule, DemoModule], providers: [JournalRpc], exports: [JourneyDomainModule], }) diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts index e941aac56..6b84b197c 100644 --- a/server/src/nest/journey/journal.rpc.ts +++ b/server/src/nest/journey/journal.rpc.ts @@ -2,20 +2,16 @@ import pathMod from 'node:path'; import { randomUUID } from 'node:crypto'; import { Readable } from 'node:stream'; import { journalPluginPhotoInputSchema } from '@trek/shared'; -import { InjectRepository } from '@mikro-orm/nestjs'; import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators'; import { PluginGuards } from '../plugins/host/plugin-guards.service'; import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors'; import { asPayload, num } from '../plugins/host/rpc-params'; import type { PluginRpcContext } from '../plugins/host/rpc-kit/types'; import { ADDON_IDS } from '../../addons'; -import { readEnv } from '../../app-config'; -import { isDemoEmail } from '../common/demo'; import { AllowedFileTypesService } from '../files/allowed-file-types.service'; import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service'; import { StorageService } from '../storage/storage.service'; -import { Users } from '../../db/entities/Users.entity'; -import type { UsersRepository } from '../../db/repositories/Users.repository'; +import { DemoService } from '../common/demo.service'; import { JourneyDomainService } from './journey-domain.service'; /** 10MB decoded, the same cap the file surface applies to plugin uploads. */ @@ -48,7 +44,11 @@ export class JournalRpc { private readonly storage: StorageService, private readonly allowedTypes: AllowedFileTypesService, private readonly captureBackfill: PhotoCaptureBackfillService, - @InjectRepository(Users) private readonly usersRepo: UsersRepository, + // SV8 (R-survivors) — Plan 3i: DemoService.isDemoUserId replaces the inline + // env + email lookup + isDemoEmail check (3g's own JR1 conversion of the + // SELECT onto UsersRepository.getEmail still duplicated the demo-gating + // LOGIC; this collapses it onto the shared primitive). + private readonly demo: DemoService, ) {} @PluginMethod('journal.listMine', { permission: 'db:read:journal' }) @@ -147,12 +147,7 @@ export class JournalRpc { // Mirrors the REST upload guard: a demo user must not write bytes to the // shared demo instance, not even through a plugin's db:write:journal. - // JR1 — reuses `UsersRepository.getEmail` (already built by another - // domain's demo-mode-check conversion), not a near-duplicate. - if (readEnv().demo.enabled) { - const uploaderEmail = await this.usersRepo.getEmail(userId); - if (isDemoEmail(uploaderEmail)) throw new ForbiddenResource('Uploads are disabled in demo mode.'); - } + if (await this.demo.isDemoUserId(userId)) throw new ForbiddenResource('Uploads are disabled in demo mode.'); // SV8 — Plan 3i // basename first: a name is a name, never a path. const original = pathMod.basename(input.name); diff --git a/server/src/nest/plugins/contributions/day-schedule.controller.ts b/server/src/nest/plugins/contributions/day-schedule.controller.ts index dfe3c1689..305cea28c 100644 --- a/server/src/nest/plugins/contributions/day-schedule.controller.ts +++ b/server/src/nest/plugins/contributions/day-schedule.controller.ts @@ -1,6 +1,9 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { DatabaseService } from '../../database/database.service'; +import { Days } from '../../../db/entities/Days.entity'; +import type { DaysRepository } from '../../../db/repositories/Days.repository'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; @@ -81,6 +84,8 @@ export class DayScheduleController { constructor( private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, + // CT1 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts. + @InjectRepository(Days) private readonly days: DaysRepository, ) {} @Get(':tripId') @@ -95,8 +100,8 @@ export class DayScheduleController { const ids = this.hooks.providersOf('dayScheduleProvider'); if (ids.length === 0) return { items: [] }; - const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>; - const tripDayIds: ReadonlySet = new Set(dayRows.map((d) => d.id)); + const dayIds = await this.days.listIdsByTrip(tripId); // CT1 — Plan 3j + const tripDayIds: ReadonlySet = new Set(dayIds); const perProvider = await Promise.all( ids.map(async (id): Promise => { diff --git a/server/src/nest/plugins/contributions/day-tints.controller.ts b/server/src/nest/plugins/contributions/day-tints.controller.ts index e8073f7d1..b9000e238 100644 --- a/server/src/nest/plugins/contributions/day-tints.controller.ts +++ b/server/src/nest/plugins/contributions/day-tints.controller.ts @@ -1,6 +1,9 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { DatabaseService } from '../../database/database.service'; +import { Days } from '../../../db/entities/Days.entity'; +import type { DaysRepository } from '../../../db/repositories/Days.repository'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; @@ -158,6 +161,8 @@ export class DayTintsController { constructor( private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, + // CT2 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts. + @InjectRepository(Days) private readonly days: DaysRepository, ) {} @Get(':tripId') @@ -172,8 +177,8 @@ export class DayTintsController { const ids = this.hooks.providersOf('dayTintProvider'); if (ids.length === 0) return { tints: [] }; - const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>; - const tripDayIds: ReadonlySet = new Set(dayRows.map((d) => d.id)); + const dayIds = await this.days.listIdsByTrip(tripId); // CT2 — Plan 3j + const tripDayIds: ReadonlySet = new Set(dayIds); const perProvider = await Promise.all( ids.map(async (id): Promise => { diff --git a/server/src/nest/plugins/contributions/place-details.controller.ts b/server/src/nest/plugins/contributions/place-details.controller.ts index a81769370..9680d3aed 100644 --- a/server/src/nest/plugins/contributions/place-details.controller.ts +++ b/server/src/nest/plugins/contributions/place-details.controller.ts @@ -1,6 +1,9 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { DatabaseService } from '../../database/database.service'; +import { Places } from '../../../db/entities/Places.entity'; +import type { PlacesRepository } from '../../../db/repositories/Places.repository'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; @@ -64,6 +67,8 @@ export class PlaceDetailsController { constructor( private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, + // CT7 (Plan 3j Task 5) — the place's owning trip id, converted onto Places.repository.ts. + @InjectRepository(Places) private readonly places: PlacesRepository, ) {} @Get(':placeId') @@ -77,8 +82,8 @@ export class PlaceDetailsController { if (!Number.isFinite(placeId) || userId == null) return { providers: [] }; // The place must belong to a trip the caller can access — same gate as a read. - const row = this.dbs.connection.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined; - if (!row || !(await this.dbs.canAccessTrip(row.trip_id, userId))) return { providers: [] }; + const tripId = await this.places.findTripId(placeId); // CT7 — Plan 3j + if (tripId === undefined || !(await this.dbs.canAccessTrip(tripId, userId))) return { providers: [] }; const ids = this.hooks.providersOf('placeDetailProvider'); const results = await Promise.all( diff --git a/server/src/nest/plugins/contributions/plugin-contributions.module.ts b/server/src/nest/plugins/contributions/plugin-contributions.module.ts index f6c1fe0fc..bfdd83dc2 100644 --- a/server/src/nest/plugins/contributions/plugin-contributions.module.ts +++ b/server/src/nest/plugins/contributions/plugin-contributions.module.ts @@ -5,6 +5,8 @@ import { AddonsModule } from '../../addons/addons.module'; import { JourneyDomainModule } from '../../journey/journey-domain.module'; import { JourneyEntries } from '../../../db/entities/JourneyEntries.entity'; import { Plugins } from '../../../db/entities/Plugins.entity'; +import { Days } from '../../../db/entities/Days.entity'; +import { Places } from '../../../db/entities/Places.entity'; import { PlaceDetailsController } from './place-details.controller'; import { PluginSearchController } from './plugin-search.controller'; import { TripWarningsController } from './trip-warnings.controller'; @@ -45,7 +47,10 @@ import { DemoModule } from '../../common/demo.module'; * entity needs its own registration here too. `Plugins` is the same shape * (Plan 3j Task 3): `PluginRoutesController`'s `declaredProfiles` call now * takes `PluginsRepository`, and `PluginsRuntimeModule`'s own registration - * of the same entity isn't exported either. + * of the same entity isn't exported either. `Days`/`Places` (Plan 3j Task 5, + * CT1/CT2/CT7): `DayScheduleController`/`DayTintsController`'s own day-id-set + * read and `PlaceDetailsController`'s own trip-id lookup, same reasoning — + * `DaysModule`/`PlacesModule` do not export `MikroOrmModule` either. * * `DemoModule` is imported explicitly (Plan 3i Task 4 fix wave): it is * `@Global()`, but that broadcast only reaches a module graph that actually @@ -54,7 +59,7 @@ import { DemoModule } from '../../common/demo.module'; * dependency unresolved. */ @Module({ - imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins])], + imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins, Days, Places])], controllers: [ PlaceDetailsController, PluginSearchController, diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index aaf1a7c73..b0c52f809 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -7,6 +7,12 @@ import { budgetFor } from '../plugin-host-state'; import { InjectRepository } from '@mikro-orm/nestjs'; import { PluginCapabilityAudit } from '../../../../db/entities/PluginCapabilityAudit.entity'; import type { PluginCapabilityAuditRepository } from '../../../../db/repositories/PluginCapabilityAudit.repository'; +import { Users } from '../../../../db/entities/Users.entity'; +import type { UsersRepository } from '../../../../db/repositories/Users.repository'; +import { Trips } from '../../../../db/entities/Trips.entity'; +import type { TripsRepository } from '../../../../db/repositories/Trips.repository'; +import { PluginScheduledTasks } from '../../../../db/entities/PluginScheduledTasks.entity'; +import type { PluginScheduledTasksRepository } from '../../../../db/repositories/PluginScheduledTasks.repository'; import { DatabaseService } from '../../../database/database.service'; import { RealtimeService } from '../../../realtime/realtime.service'; import { NotificationsService } from '../../../notifications/notifications.service'; @@ -46,6 +52,12 @@ export class HostSurfaceRpc { private readonly oauth: PluginOAuthService, private readonly guards: PluginGuards, @InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository, + // HR1 (Plan 3j Task 5) — the plugin-visible user row. + @InjectRepository(Users) private readonly users: UsersRepository, + // HR9 (Plan 3j Task 5) — the bilateral "do these two users share a trip" gate. + @InjectRepository(Trips) private readonly trips: TripsRepository, + // HR5–HR8 (Plan 3j Task 5) — a plugin's own scheduler.set/scheduler.cancel RPCs. + @InjectRepository(PluginScheduledTasks) private readonly scheduledTasks: PluginScheduledTasksRepository, ) {} @PluginMethod('users.getById', { permission: 'db:read:users' }) @@ -57,7 +69,11 @@ export class HostSurfaceRpc { if (id !== ctx.actingUserId && !(await this.sharesATrip(ctx.actingUserId, id))) { throw new ForbiddenResource(`no access to user ${id}`); } - return this.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id); + // `?? undefined`: `findPublicIdentity` returns `null` on a miss (this + // repository's own convention); the legacy `better-sqlite3` `.get()` + // returned `undefined` — preserved so a missing row still serializes + // the same way over the wire to the plugin. + return (await this.users.findPublicIdentity(id)) ?? undefined; } @PluginMethod('ws.broadcastToTrip', { permission: 'ws:broadcast:trip' }) @@ -188,41 +204,32 @@ export class HostSurfaceRpc { } const json = JSON.stringify(params.payload ?? null); if (json.length > SCHED_PAYLOAD_MAX) throw new BadParams(`scheduler payload too large (max ${SCHED_PAYLOAD_MAX} bytes)`); - const existing = this.db - .prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?') - .get(ctx.pluginId, name) as { id: number } | undefined; + const existing = await this.scheduledTasks.existsForPluginAndName(ctx.pluginId, name); // HR5 — Plan 3j if (!existing) { - const n = (this.db.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(ctx.pluginId) as { c: number }).c; + const n = await this.scheduledTasks.countForPlugin(ctx.pluginId); // HR6 — Plan 3j if (n >= SCHED_MAX) throw new BadParams(`too many scheduled tasks (max ${SCHED_MAX})`); } // Upsert by (plugin, name): re-scheduling the same name replaces it. - this.db - .prepare(`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?) - ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`) - .run(ctx.pluginId, name, Math.max(dueAt, Date.now()), json, everyMs ?? null); + await this.scheduledTasks.upsertTask({ + // HR7 — Plan 3j + plugin_id: ctx.pluginId, + name, + due_at: Math.max(dueAt, Date.now()), + payload: json, + every_ms: everyMs ?? null, + }); return { scheduled: true }; } @PluginMethod('scheduler.cancel', { permission: 'jobs:run' }) async schedulerCancel(params: Record, ctx: PluginRpcContext): Promise { - const r = this.db - .prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?') - .run(ctx.pluginId, str(params.name, 'name')); - return { cancelled: r.changes > 0 }; + const cancelled = await this.scheduledTasks.deleteByPluginAndName(ctx.pluginId, str(params.name, 'name')); // HR8 — Plan 3j + return { cancelled }; } /** Two users share a trip when both are owner-or-member of the same one. */ private async sharesATrip(actingUserId: number, targetUserId: number): Promise { - return !!this.db - .prepare( - `SELECT 1 FROM trips t - LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? - LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? - WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) - AND (t.user_id = ? OR m2.user_id IS NOT NULL) - LIMIT 1`, - ) - .get(actingUserId, targetUserId, actingUserId, targetUserId); + return await this.trips.sharesTripWith(actingUserId, targetUserId); // HR9 — Plan 3j } private async requireLlm(userId: number) { diff --git a/server/src/nest/plugins/host/rpc/meta.rpc.ts b/server/src/nest/plugins/host/rpc/meta.rpc.ts index a92cb28f8..777c92e50 100644 --- a/server/src/nest/plugins/host/rpc/meta.rpc.ts +++ b/server/src/nest/plugins/host/rpc/meta.rpc.ts @@ -1,9 +1,22 @@ +import { InjectRepository } from '@mikro-orm/nestjs'; import { PluginController, PluginMethod } from '../rpc-kit/decorators'; import { PluginGuards } from '../plugin-guards.service'; import { BadParams, ForbiddenResource } from '../rpc-errors'; import { num, str } from '../rpc-params'; import type { PluginRpcContext } from '../rpc-kit/types'; import { DatabaseService } from '../../../database/database.service'; +import { PluginEntityMetadata } from '../../../../db/entities/PluginEntityMetadata.entity'; +import type { PluginEntityMetadataRepository } from '../../../../db/repositories/PluginEntityMetadata.repository'; +import { Trips } from '../../../../db/entities/Trips.entity'; +import type { TripsRepository } from '../../../../db/repositories/Trips.repository'; +import { Places } from '../../../../db/entities/Places.entity'; +import type { PlacesRepository } from '../../../../db/repositories/Places.repository'; +import { Days } from '../../../../db/entities/Days.entity'; +import type { DaysRepository } from '../../../../db/repositories/Days.repository'; +import { Reservations } from '../../../../db/entities/Reservations.entity'; +import type { ReservationsRepository } from '../../../../db/repositories/Reservations.repository'; +import { DayAccommodations } from '../../../../db/entities/DayAccommodations.entity'; +import type { DayAccommodationsRepository } from '../../../../db/repositories/DayAccommodations.repository'; /** Core entities a plugin may attach its own db:meta to. */ const META_ENTITY_TYPES: ReadonlySet = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']); @@ -23,14 +36,6 @@ const EDIT_ACTION: Record = { accommodation: 'day_edit', }; -/** Each of these tables has a NOT NULL trip_id, so the gate resolves the owning trip. */ -const ENTITY_TABLE: Record = { - place: 'places', - day: 'days', - reservation: 'reservations', - accommodation: 'day_accommodations', -}; - /** * A plugin's OWN namespaced key/value store, attached to a core entity (#plugins). * @@ -46,17 +51,21 @@ export class MetaRpc { constructor( private readonly db: DatabaseService, private readonly guards: PluginGuards, + @InjectRepository(PluginEntityMetadata) private readonly meta: PluginEntityMetadataRepository, + @InjectRepository(Trips) private readonly trips: TripsRepository, + @InjectRepository(Places) private readonly places: PlacesRepository, + @InjectRepository(Days) private readonly days: DaysRepository, + @InjectRepository(Reservations) private readonly reservations: ReservationsRepository, + @InjectRepository(DayAccommodations) private readonly dayAccommodations: DayAccommodationsRepository, ) {} @PluginMethod('meta.get', { permission: 'db:meta' }) async get(params: Record, ctx: PluginRpcContext): Promise { const { entityType, entityId } = await this.resolveEntity(params, ctx, false); - const row = this.db - .prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .get(ctx.pluginId, entityType, entityId, str(params.key, 'key')) as { value: string } | undefined; - if (!row) return null; + const value = await this.meta.findValue(ctx.pluginId, entityType, entityId, str(params.key, 'key')); // MR1 — Plan 3j + if (value === null) return null; try { - return JSON.parse(row.value); + return JSON.parse(value); } catch { return null; } @@ -69,30 +78,19 @@ export class MetaRpc { if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`); const json = JSON.stringify(params.value ?? null); if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`); - const exists = this.db - .prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .get(ctx.pluginId, entityType, entityId, key); + const exists = (await this.meta.findValue(ctx.pluginId, entityType, entityId, key)) !== null; // MR2 — Plan 3j if (!exists) { - const { n } = this.db - .prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?') - .get(ctx.pluginId, entityType, entityId) as { n: number }; + const n = await this.meta.countForEntity(ctx.pluginId, entityType, entityId); // MR3 — Plan 3j if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`); } - this.db - .prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at) - VALUES (?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(plugin_id, entity_type, entity_id, key) - DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`) - .run(ctx.pluginId, entityType, entityId, key, json); + await this.meta.upsertValue(ctx.pluginId, entityType, entityId, key, json); // MR4 — Plan 3j return { key, value: params.value ?? null }; } @PluginMethod('meta.list', { permission: 'db:meta' }) async list(params: Record, ctx: PluginRpcContext): Promise { const { entityType, entityId } = await this.resolveEntity(params, ctx, false); - const rows = this.db - .prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key') - .all(ctx.pluginId, entityType, entityId) as Array<{ key: string; value: string }>; + const rows = await this.meta.listForEntity(ctx.pluginId, entityType, entityId); // MR5 — Plan 3j const out: Record = {}; for (const r of rows) { try { @@ -107,10 +105,8 @@ export class MetaRpc { @PluginMethod('meta.delete', { permission: 'db:meta' }) async delete(params: Record, ctx: PluginRpcContext): Promise { const { entityType, entityId } = await this.resolveEntity(params, ctx, true); - const res = this.db - .prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .run(ctx.pluginId, entityType, entityId, str(params.key, 'key')); - return { deleted: res.changes > 0 }; + const deleted = await this.meta.deleteValue(ctx.pluginId, entityType, entityId, str(params.key, 'key')); // MR6 — Plan 3j + return { deleted }; } /** @@ -139,12 +135,29 @@ export class MetaRpc { return { entityType, entityId }; } + /** + * MR8/MR9 (Plan 3j Task 5) — R12's "one method per target table, no + * dynamic identifier dispatch" precedent (`PlacesRepository.findTripId`/ + * `ReservationsRepository.findTripId`'s own docstrings): the legacy + * `` SELECT trip_id FROM ${table} WHERE id = ? `` string-interpolated a + * table name off a fixed 4-entry map (never request-controlled) — this + * dispatches over the SAME closed `entityType` union onto one typed read + * per table instead, so no identifier is ever interpolated into SQL here. + */ private async entityTrip(entityType: string, entityId: number): Promise { - if (entityType === 'trip') { - return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id; + switch (entityType) { + case 'trip': + return (await this.trips.existsById(entityId)) ? entityId : undefined; // MR8 — Plan 3j + case 'place': + return await this.places.findTripId(entityId); // MR9 — Plan 3j + case 'day': + return await this.days.findTripId(entityId); // MR9 — Plan 3j + case 'reservation': + return await this.reservations.findTripId(entityId); // MR9 — Plan 3j + case 'accommodation': + return await this.dayAccommodations.getTripId(entityId); // MR9 — Plan 3j + default: + return undefined; } - // The table name comes from a fixed map, never from the request. - const table = ENTITY_TABLE[entityType]; - return (this.db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id; } } diff --git a/server/src/nest/plugins/oauth/plugin-oauth.controller.ts b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts index ba7d0d033..b2d3fa020 100644 --- a/server/src/nest/plugins/oauth/plugin-oauth.controller.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import type { Request, Response } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginOAuthService } from './plugin-oauth.service'; -import { DatabaseService } from '../../database/database.service'; +import { Plugins } from '../../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../../db/repositories/Plugins.repository'; /** * Host-brokered outbound OAuth endpoints (#plugins). All are gated by JwtAuthGuard — @@ -17,11 +19,12 @@ import { DatabaseService } from '../../database/database.service'; export class PluginOAuthController { constructor( private readonly oauth: PluginOAuthService, - private readonly dbs: DatabaseService, + // POC1 (Plan 3j Task 5) — shared with plugin-user-settings.controller.ts's activeWithUserFields. + @InjectRepository(Plugins) private readonly pluginsRepo: PluginsRepository, ) {} private async isActive(id: string): Promise { - return !!this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id); + return await this.pluginsRepo.existsActive(id); } @Get(':id/status') diff --git a/server/src/nest/plugins/plugin-user-settings.controller.ts b/server/src/nest/plugins/plugin-user-settings.controller.ts index 36891ec9a..cebd62d22 100644 --- a/server/src/nest/plugins/plugin-user-settings.controller.ts +++ b/server/src/nest/plugins/plugin-user-settings.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; import { PluginsService, MissingRequiredSettingError } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; -import { DatabaseService } from '../database/database.service'; +import { Plugins } from '../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../db/repositories/Plugins.repository'; import { PluginUserSettingsUpdateDto } from './plugins.dto'; import type { PluginActionDescriptor, PluginActionResult } from '@trek/shared'; @@ -25,12 +27,12 @@ export class PluginUserSettingsController { constructor( private readonly plugins: PluginsService, private readonly runtime: PluginRuntimeService, - private readonly dbs: DatabaseService, + // PUC1 (Plan 3j Task 5) — shared with oauth/plugin-oauth.controller.ts's isActive. + @InjectRepository(Plugins) private readonly pluginsRepo: PluginsRepository, ) {} private async activeWithUserFields(id: string): Promise { - const row = this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id); - return !!row; + return await this.pluginsRepo.existsActive(id); } @Get(':id') diff --git a/server/src/nest/plugins/plugins-feed.controller.ts b/server/src/nest/plugins/plugins-feed.controller.ts index bab33eeb8..4663024a4 100644 --- a/server/src/nest/plugins/plugins-feed.controller.ts +++ b/server/src/nest/plugins/plugins-feed.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; -import { DatabaseService } from '../database/database.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Plugins } from '../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../db/repositories/Plugins.repository'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; @@ -30,14 +32,14 @@ interface ActivePlugin { @Controller('api/plugins') @UseGuards(JwtAuthGuard) export class PluginsFeedController { - constructor(private readonly dbs: DatabaseService) {} + constructor(@InjectRepository(Plugins) private readonly plugins: PluginsRepository) {} @Get() async list(): Promise<{ plugins: ActivePlugin[] }> { if (!pluginsEnabled()) return { plugins: [] }; - const rows = this.dbs.connection - .prepare("SELECT id, name, type, icon, capabilities, granted_permissions FROM plugins WHERE status = 'active' ORDER BY sort_order, name") - .all() as Array & { capabilities: string; granted_permissions: string }>; + // PFC1 — Plan 3j: this row goes to the CLIENT, not a plugin process — stays + // exactly snake_case, R-facade's wrapper is never applied here. + const rows = await this.plugins.findActiveFeedRows(); const plugins = rows.map(({ capabilities, granted_permissions, ...p }) => { const tripPage = p.type === 'trip-page' ? tripPageOf(capabilities) : undefined; const routeProfiles = routeProfilesOf(capabilities, granted_permissions); diff --git a/server/src/nest/plugins/plugins-runtime.module.ts b/server/src/nest/plugins/plugins-runtime.module.ts index 78494c521..7b64aa598 100644 --- a/server/src/nest/plugins/plugins-runtime.module.ts +++ b/server/src/nest/plugins/plugins-runtime.module.ts @@ -16,6 +16,12 @@ import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.ent import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity'; import { Settings } from '../../db/entities/Settings.entity'; import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity'; +import { Users } from '../../db/entities/Users.entity'; +import { Trips } from '../../db/entities/Trips.entity'; +import { Days } from '../../db/entities/Days.entity'; +import { Places } from '../../db/entities/Places.entity'; +import { Reservations } from '../../db/entities/Reservations.entity'; +import { DayAccommodations } from '../../db/entities/DayAccommodations.entity'; import { PluginsService } from './plugins.service'; import { PluginUserSettingsService } from './plugin-user-settings.service'; import { PluginRuntimeService } from './plugin-runtime.service'; @@ -87,6 +93,14 @@ import { SchedulingModule } from '../scheduling/scheduling.module'; PluginSettingsFields, PluginActions, PluginUserConfig, PluginEntityMetadata, PluginOauthTokens, PluginOauthState, PluginMetaMigrations, PluginCapabilityAudit, Settings, NotificationChannelPreferences, + // Plan 3j Task 5 — HostSurfaceRpc's (Users, Trips, PluginScheduledTasks + // above) and MetaRpc's (PluginEntityMetadata above, Trips, Places, Days, + // Reservations, DayAccommodations) own `@InjectRepository` params; both + // classes are constructed in THIS module's `providers`, so — same + // reasoning every entry above already documents — it owns their + // registration too, even though `TripsModule`/`PlacesModule`/etc are + // already imported (none of them export `MikroOrmModule`). + Users, Trips, Days, Places, Reservations, DayAccommodations, ]), // A leaf that hands the resource gates to the domain modules. It must not be this // module: the domains would then have to import this one back and close a cycle. diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index f606d0f58..61cf09f39 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -11,6 +11,7 @@ import { PluginOAuthModule } from './oauth/plugin-oauth.module'; import { PluginContributionsModule } from './contributions/plugin-contributions.module'; import { AppConfigModule } from '../app-config/app-config.module'; import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity'; +import { Plugins } from '../../db/entities/Plugins.entity'; /** * Plugin system (#plugins), composition root. @@ -34,9 +35,17 @@ import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.e * registers the same entity for ITS OWN providers but does not export the repository * token, so this module (the one that constructs the controller) needs its own entry, * same reasoning `PluginContributionsModule`'s own docstring gives for `JourneyEntries`. + * `Plugins` (Plan 3j Task 5, PFC1/PUC1) — `PluginsFeedController`'s own feed read and + * `PluginUserSettingsController`'s own active-plugin guard, same reasoning. */ @Module({ - imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule, MikroOrmModule.forFeature([PluginCapabilityAudit])], + imports: [ + AppConfigModule, + PluginsRuntimeModule, + PluginOAuthModule, + PluginContributionsModule, + MikroOrmModule.forFeature([PluginCapabilityAudit, Plugins]), + ], controllers: [ PluginsController, PluginsFeedController, diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index c27613b96..3e7239860 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -62,6 +62,7 @@ import { CollabRpc } from '../../src/nest/collab/collab.rpc'; import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc'; import { VacayRpc } from '../../src/nest/vacay/vacay.rpc'; import { JournalRpc } from '../../src/nest/journey/journal.rpc'; +import { DemoService } from '../../src/nest/common/demo.service'; import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; import { makeNotificationsService } from './notifications'; import { notificationsStub } from './notifications'; @@ -315,13 +316,25 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< new VacayRpc(vacay, guards), // The photo half needs storage plus the allowed-types setting and the EXIF // backfill; none of the tests on this harness write bytes, so they are stubs. - // Plan 3g Task 3 — the constructor-ripple fix: `JournalRpc`'s demo-mode - // gate (JR1) now injects `UsersRepository.getEmail`, not `DatabaseService`. - new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, usersRepo), + // SV8 (Plan 3i, R-survivors) — JournalRpc's demo-mode gate (addEntryPhoto) + // now injects DemoService, the same shared primitive mcp-test-controllers.ts + // wires into the 4 *.mcp.ts survivor sites. + new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, new DemoService(new RuntimeEnvService(), (await sharedTestOrm(dbs.connection)).em)), new CollectionsRpc(collections, guards), new DbRpc(new PluginUserSettingsService(pluginOrm.repo(PluginSettingsFields), pluginOrm.repo(PluginUserConfig))), - new MetaRpc(dbs, guards), - new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo), + // Plan 3j Task 5 — MetaRpc's own MR1–MR9 conversion: PluginEntityMetadata plus + // one typed trip-id read per entity table (R12's dispatch, no interpolation). + new MetaRpc( + dbs, guards, pluginOrm.repo(PluginEntityMetadata), await createTestTripsRepo(dbs.connection), + await createTestPlacesRepo(dbs.connection), await createTestDaysRepo(dbs.connection), + await createTestReservationsRepo(dbs.connection), await createTestDayAccommodationsRepo(dbs.connection), + ), + // Plan 3j Task 5 — HostSurfaceRpc's own HR1/HR5–HR9 conversion: the plugin-visible + // user row, the bilateral trip-sharing gate, and its own scheduler.set/cancel. + new HostSurfaceRpc( + dbs, realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo, + usersRepo, await createTestTripsRepo(dbs.connection), pluginOrm.repo(PluginScheduledTasks), + ), new PluginHooks(undefined as never), ]); return new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService); diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index 9e2ea8047..4601bca75 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -81,8 +81,12 @@ export function allRpcControllers(): object[] { new JournalRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), new CollectionsRpc(anyService(), anyService()), new DbRpc(anyService()), - new MetaRpc(anyService(), anyService()), - new HostSurfaceRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), + // Plan 3j Task 5 — MetaRpc/HostSurfaceRpc's own repository conversions (MR#/HR#). + new MetaRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), + new HostSurfaceRpc( + anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), + anyService(), anyService(), anyService(), + ), // Owns no wire method, but declares all 18 hook contracts, which requireTotalCoverage checks too. new PluginHooks(anyService()), ]; diff --git a/server/tests/unit/nest/roadtrip-router.test.ts b/server/tests/unit/nest/roadtrip-router.test.ts index f82ce5012..266b8c3f2 100644 --- a/server/tests/unit/nest/roadtrip-router.test.ts +++ b/server/tests/unit/nest/roadtrip-router.test.ts @@ -36,14 +36,12 @@ const osrm = () => ); function setup(settings = {}) { const hooks = { providersOf: vi.fn(() => ['scenic']), route: vi.fn() }; - const db = { - connection: { - prepare: () => ({ get: () => ({ capabilities: JSON.stringify({ routeProfiles: [{ id: 'car' }] }) }) }), - }, - }; + // RRT1/RRT2 (Plan 3j Task 3, confirmed at Task 5's own HEAD) — `declaredProfiles` + // now takes `PluginsRepository`, not a raw connection. + const plugins = { findCapabilities: vi.fn(async () => JSON.stringify({ routeProfiles: [{ id: 'car' }] })) }; return { hooks, - router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, db as never), + router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, plugins as never), }; } beforeEach(() => { diff --git a/server/tests/unit/plugins/addon-gates.rpc.test.ts b/server/tests/unit/plugins/addon-gates.rpc.test.ts index 80f559ca3..44f382dc1 100644 --- a/server/tests/unit/plugins/addon-gates.rpc.test.ts +++ b/server/tests/unit/plugins/addon-gates.rpc.test.ts @@ -76,9 +76,8 @@ function build(addonOn: boolean) { { put: async () => undefined, delete: async () => undefined } as never, { get: () => '*' } as never, { schedule: () => undefined } as never, - // Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository - // .getEmail`, not `DatabaseService.prepare`. - { getEmail: async () => 'u@example.test' } as never), + // SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive. + { isDemoUserId: async () => false } as never), new CollectionsRpc(spyService(calls, 'collections'), guards), new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')), ]); diff --git a/server/tests/unit/plugins/day-schedule.controller.test.ts b/server/tests/unit/plugins/day-schedule.controller.test.ts index 1c9cc9419..2e814fe9c 100644 --- a/server/tests/unit/plugins/day-schedule.controller.test.ts +++ b/server/tests/unit/plugins/day-schedule.controller.test.ts @@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import type { DaysRepository } from '../../../src/db/repositories/Days.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) { providersOf: vi.fn(() => providers), daySchedule: vi.fn(async (id: string) => invoke(id)), } as unknown as PluginHooks; - return { c: new DayScheduleController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime }; + // CT1 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip. + const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository; + return { c: new DayScheduleController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime }; } const item = (over: Record = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over }); diff --git a/server/tests/unit/plugins/day-tints.controller.test.ts b/server/tests/unit/plugins/day-tints.controller.test.ts index 84f2aa5ed..880ba77d7 100644 --- a/server/tests/unit/plugins/day-tints.controller.test.ts +++ b/server/tests/unit/plugins/day-tints.controller.test.ts @@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import type { DaysRepository } from '../../../src/db/repositories/Days.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) { providersOf: vi.fn(() => providers), dayTints: vi.fn(async (id: string) => invoke(id)), } as unknown as PluginHooks; - return { c: new DayTintsController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime }; + // CT2 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip. + const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository; + return { c: new DayTintsController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime }; } const tint = (over: Record = {}) => ({ dayId: 10, tone: 'success', ...over }); diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index 52606991f..218f6adab 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -74,6 +74,13 @@ import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.r import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope'; import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; +import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; +import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; +import type { DaysRepository } from '../../../src/db/repositories/Days.repository'; +import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository'; +import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository'; +import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository'; +import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository'; // Typed from the real method rather than from the always-true body below, so a case that // swaps in an implementation reading the action key (HOSTRPC-015) still type-checks. @@ -130,6 +137,117 @@ const pluginAuditRepo = { }, } as unknown as PluginCapabilityAuditRepository; +/** + * Plan 3j Task 5: `MetaRpc`/`HostSurfaceRpc`'s own repository conversions + * (MR#/HR#). Same reasoning as `pluginAuditRepo` above — this file's + * hand-trimmed `:memory:` schema (no top-level await here) means a real + * MikroORM repository isn't reachable, so every converted method is + * reproduced as real raw SQL against `mockDb`, wrapped in the repository's + * own method shape, rather than a canned stub — HOSTRPC-014/028/029/030 + * read the real table state back out through `mockDb.prepare(...)` + * directly, so this HAS to be the real table. + */ +type RawDb = { + prepare(sql: string): { get(...a: unknown[]): unknown; all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes: number } }; +}; +const raw = mockDb as unknown as RawDb; + +const tripsRepo = { + async sharesTripWith(userIdA: number, userIdB: number) { + return !!raw + .prepare( + `SELECT 1 FROM trips t + LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? + LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? + WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) + AND (t.user_id = ? OR m2.user_id IS NOT NULL) + LIMIT 1`, + ) + .get(userIdA, userIdB, userIdA, userIdB); + }, + async existsById(id: number) { + return !!raw.prepare('SELECT id FROM trips WHERE id = ?').get(id); + }, +} as unknown as TripsRepository; + +const placesRepo = { + async findTripId(id: number) { + return (raw.prepare('SELECT trip_id FROM places WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id; + }, +} as unknown as PlacesRepository; + +const daysRepo = { + async findTripId(id: number) { + return (raw.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id; + }, +} as unknown as DaysRepository; + +const reservationsRepo = { + async findTripId(id: number) { + return (raw.prepare('SELECT trip_id FROM reservations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id; + }, +} as unknown as ReservationsRepository; + +const dayAccommodationsRepo = { + async getTripId(id: number) { + return (raw.prepare('SELECT trip_id FROM day_accommodations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id; + }, +} as unknown as DayAccommodationsRepository; + +const metaRepo = { + async findValue(pluginId: string, entityType: string, entityId: number, key: string) { + const row = raw + .prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') + .get(pluginId, entityType, entityId, key) as { value: string } | undefined; + return row?.value ?? null; + }, + async countForEntity(pluginId: string, entityType: string, entityId: number) { + return ( + raw + .prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?') + .get(pluginId, entityType, entityId) as { n: number } + ).n; + }, + async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string) { + raw + .prepare( + `INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(plugin_id, entity_type, entity_id, key) + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ) + .run(pluginId, entityType, entityId, key, value); + }, + async listForEntity(pluginId: string, entityType: string, entityId: number) { + return raw + .prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key') + .all(pluginId, entityType, entityId) as Array<{ key: string; value: string }>; + }, + async deleteValue(pluginId: string, entityType: string, entityId: number, key: string) { + return raw.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?').run(pluginId, entityType, entityId, key).changes > 0; + }, +} as unknown as PluginEntityMetadataRepository; + +const scheduledTasksRepo = { + async existsForPluginAndName(pluginId: string, name: string) { + return !!raw.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(pluginId, name); + }, + async countForPlugin(pluginId: string) { + return (raw.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(pluginId) as { c: number }).c; + }, + async upsertTask(input: { plugin_id: string; name: string; due_at: number; payload: string; every_ms: number | null }) { + raw + .prepare( + `INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`, + ) + .run(input.plugin_id, input.name, input.due_at, input.payload, input.every_ms); + }, + async deleteByPluginAndName(pluginId: string, name: string) { + return raw.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(pluginId, name).changes > 0; + }, +} as unknown as PluginScheduledTasksRepository; + const dbs = new DatabaseService(mockDb); // Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now, // which needs a real EntityManager over the full migrated schema — this @@ -152,12 +270,21 @@ vi.spyOn(dbs, 'canAccessTrip').mockImplementation(async (tripId, userId) => const seededRoles: Record = { 5: 'trip_owner', 6: 'user', 9: 'user' }; const usersRepo = { getRole: vi.fn(async (id: number) => seededRoles[id] ?? null), + // HR1 (Plan 3j Task 5) — same "real SQL against mockDb" reasoning as the + // repository stubs above; HOSTRPC-019 asserts the exact returned shape. + async findPublicIdentity(id: number) { + return ( + (raw.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id) as + | { id: number; username: string; display_name: string | null; avatar: string | null } + | undefined) ?? null + ); + }, } as unknown as UsersRepository; const guards = new PluginGuards(dbs, permissions, addons, usersRepo); const registry = createTestPluginRegistry([ new DbRpc(userSettings), - new MetaRpc(dbs, guards), - new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo), + new MetaRpc(dbs, guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo), + new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo), ]); const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} }; diff --git a/server/tests/unit/plugins/journal-photo.rpc.test.ts b/server/tests/unit/plugins/journal-photo.rpc.test.ts index 891789d3e..7c7e723ba 100644 --- a/server/tests/unit/plugins/journal-photo.rpc.test.ts +++ b/server/tests/unit/plugins/journal-photo.rpc.test.ts @@ -5,19 +5,8 @@ * to link and no provider asset. These cases pin what the handler refuses before * it writes anything, and that a refused write leaves no object behind. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// vi.mock is hoisted above the imports, so the flag it reads has to be too. -// Only readEnv().demo is overridden: the module is shared, and replacing it -// wholesale takes defaultLanguage and the rest of the env with it. -const { demo } = vi.hoisted(() => ({ demo: { enabled: false } })); -vi.mock('../../../src/app-config', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, readEnv: () => ({ ...actual.readEnv(), demo }) }; -}); - +import { describe, it, expect, vi } from 'vitest'; import { JournalRpc } from '../../../src/nest/journey/journal.rpc'; -import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo'; const ACTOR = { actingUserId: 7 } as never; const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64'); @@ -25,8 +14,10 @@ const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64'); function build(overrides: { addPhoto?: unknown; allowed?: string; - demoEnabled?: boolean; - email?: string; + /** SV8 (Plan 3i, R-survivors) — JournalRpc now delegates the whole demo-mode + * check (env-enabled + email match) to `DemoService.isDemoUserId`, so the + * test double only needs to say whether that call refuses. */ + demoBlocked?: boolean; } = {}) { // Typed parameters so the assertions below can index mock.calls. const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined); @@ -36,27 +27,20 @@ function build(overrides: { ? () => ({ id: 5, photo_id: 42 }) : () => overrides.addPhoto); - demo.enabled = overrides.demoEnabled ?? false; - const rpc = new JournalRpc( { addPhoto } as never, { requireAddon: vi.fn() } as never, { put, delete: del } as never, { get: () => overrides.allowed ?? '*' } as never, { schedule } as never, - // Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository - // .getEmail`, not `DatabaseService.prepare`. - { getEmail: async () => overrides.email ?? 'user@example.test' } as never, + // SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive. + { isDemoUserId: async () => overrides.demoBlocked ?? false } as never, ); return { rpc, put, del, schedule, addPhoto }; } const input = (over: Record = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over }); -beforeEach(() => { - demo.enabled = false; -}); - describe('journal.addEntryPhoto', () => { it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => { const { rpc, put, schedule, addPhoto } = build(); @@ -129,7 +113,7 @@ describe('journal.addEntryPhoto', () => { }); it('JPHOTO-007: refuses to write bytes for a demo user, exactly as the REST upload does', async () => { - const { rpc, put } = build({ demoEnabled: true, email: DEMO_EMAIL_PRIMARY }); + const { rpc, put } = build({ demoBlocked: true }); await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow(/disabled in demo mode/); expect(put).not.toHaveBeenCalled(); diff --git a/server/tests/unit/plugins/place-details.controller.test.ts b/server/tests/unit/plugins/place-details.controller.test.ts index 6f07f51b2..4d48417e1 100644 --- a/server/tests/unit/plugins/place-details.controller.test.ts +++ b/server/tests/unit/plugins/place-details.controller.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -23,7 +24,9 @@ function controller(over: Partial = {}) { placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])), ...over, } as unknown as PluginHooks; - return { c: new PlaceDetailsController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: (placeId: number) => placeTrip(placeId) }) } } as unknown as DatabaseService), runtime }; + // CT7 (Plan 3j Task 5) — the place's owning trip id is now Places.repository.ts#findTripId. + const places = { findTripId: vi.fn(async (placeId: number) => placeTrip(placeId)?.trip_id) } as unknown as PlacesRepository; + return { c: new PlaceDetailsController(runtime, { canAccessTrip } as unknown as DatabaseService, places), runtime }; } describe('PlaceDetailsController', () => { diff --git a/server/tests/unit/plugins/plugin-oauth.controller.test.ts b/server/tests/unit/plugins/plugin-oauth.controller.test.ts index 8383dfb50..ba765261a 100644 --- a/server/tests/unit/plugins/plugin-oauth.controller.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.controller.test.ts @@ -11,12 +11,10 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({ getMock: vi.fn(() => ({ 1: 1 })), // plugin is active by default })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } })); -import { db as dbConn } from '../../../src/db/database'; -import { DatabaseService } from '../../../src/nest/database/database.service'; import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller'; import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; +import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -32,7 +30,9 @@ function ctrl(over: Partial = {}) { disconnect: vi.fn(async () => undefined), ...over, } as unknown as PluginOAuthService; - return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc }; + // POC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive. + const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository; + return { c: new PluginOAuthController(svc, plugins), svc }; } describe('PluginOAuthController', () => { diff --git a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts index 06cc7d1dd..8cc13e433 100644 --- a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts @@ -10,13 +10,11 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({ getMock: vi.fn(() => ({ 1: 1 })), // active by default })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } })); -import { db as dbConn } from '../../../src/db/database'; -import { DatabaseService } from '../../../src/nest/database/database.service'; import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import type { PluginsService } from '../../../src/nest/plugins/plugins.service'; +import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -28,7 +26,9 @@ function ctrl() { } as unknown as PluginsService; // The controller now also takes the runtime (for settings-page actions). const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService; - return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime }; + // PUC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive. + const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository; + return { c: new PluginUserSettingsController(svc, runtime, plugins), svc, runtime }; } describe('PluginUserSettingsController', () => { diff --git a/server/tests/unit/plugins/plugins-feed.controller.test.ts b/server/tests/unit/plugins/plugins-feed.controller.test.ts index a555c7c53..563d66d36 100644 --- a/server/tests/unit/plugins/plugins-feed.controller.test.ts +++ b/server/tests/unit/plugins/plugins-feed.controller.test.ts @@ -12,12 +12,10 @@ const { rows, pluginsEnabled } = vi.hoisted(() => ({ rows: { value: [] as Array> }, pluginsEnabled: vi.fn(() => true), })); -vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } })); -import { db as dbConn } from '../../../src/db/database'; -import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller'; +import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; const row = (over: Record = {}) => ({ id: 'p1', name: 'P', type: 'integration', icon: null, @@ -26,7 +24,9 @@ const row = (over: Record = {}) => ({ }); describe('PluginsFeedController', () => { - const c = new PluginsFeedController(new DatabaseService(dbConn)); + // PFC1 (Plan 3j Task 5) — the feed read is now Plugins.repository.ts#findActiveFeedRows. + const plugins = { findActiveFeedRows: vi.fn(async () => rows.value) } as unknown as PluginsRepository; + const c = new PluginsFeedController(plugins); beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; }); it('returns an empty feed when the runtime is disabled', async () => { diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index c4f02a60b..e9d8b69cd 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -67,6 +67,17 @@ async function makeService(): Promise { ); } +/** + * PFC1 (Plan 3j Task 5) — PluginsFeedController's own feed read, now + * Plugins.repository.ts#findActiveFeedRows, resolved over the SAME `testDb` + * handle every raw-SQL fixture line above writes to (same `sharedTestOrm` + * reasoning as `makeService` above). + */ +async function makeFeedController(): Promise { + const orm = await sharedTestOrm(testDb); + return new PluginsFeedController(orm.repo(Plugins)); +} + beforeEach(() => { testDb.exec('DELETE FROM plugins'); testDb.exec('DELETE FROM plugin_settings_fields'); @@ -247,7 +258,7 @@ describe('PluginsFeedController (client feed)', () => { it('returns active plugins when enabled, nothing when disabled', async () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run(); - const feed = new PluginsFeedController(new DatabaseService(dbConn)); + const feed = await makeFeedController(); process.env.TREK_PLUGINS_ENABLED = 'true'; const active = await feed.list(); @@ -261,7 +272,7 @@ describe('PluginsFeedController (client feed)', () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = await new PluginsFeedController(new DatabaseService(dbConn)).list(); + const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero'); expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar'); }); @@ -269,14 +280,14 @@ describe('PluginsFeedController (client feed)', () => { it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); + expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail'); }); it('exposes settingsUi only when the capability is exactly true', async () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run(); testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = await new PluginsFeedController(new DatabaseService(dbConn)).list(); + const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true); expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined(); }); @@ -284,7 +295,7 @@ describe('PluginsFeedController (client feed)', () => { it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => { testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail'); + expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail'); }); it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', async () => { @@ -294,7 +305,7 @@ describe('PluginsFeedController (client feed)', () => { // the capability is meaningless off a trip-page and must not leak onto widgets testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run(); process.env.TREK_PLUGINS_ENABLED = 'true'; - const out = await new PluginsFeedController(new DatabaseService(dbConn)).list(); + const out = await (await makeFeedController()).list(); expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 }); expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined(); expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();