mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories
This commit is contained in:
@@ -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<Days> {
|
||||
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<number[]> {
|
||||
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<number | undefined> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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<PluginEntityMetadata> {
|
||||
@@ -6,4 +7,65 @@ export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityM
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
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<string | null> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const changed = await this.nativeDelete({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key });
|
||||
return changed > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PluginSchedul
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const changed = await this.nativeDelete({ plugin_id: pluginId, name });
|
||||
return changed > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Plugins> {
|
||||
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<PluginFeedRow[]> {
|
||||
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<boolean> {
|
||||
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`). */
|
||||
|
||||
@@ -215,6 +215,52 @@ export class TripsRepository extends TrekRepository<Trips> {
|
||||
.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<boolean> {
|
||||
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<boolean> {
|
||||
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)
|
||||
|
||||
@@ -1509,6 +1509,22 @@ export class UsersRepository extends TrekRepository<Users> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number> = new Set(dayRows.map((d) => d.id));
|
||||
const dayIds = await this.days.listIdsByTrip(tripId); // CT1 — Plan 3j
|
||||
const tripDayIds: ReadonlySet<number> = new Set(dayIds);
|
||||
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<DayScheduleItem[]> => {
|
||||
|
||||
@@ -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<number> = new Set(dayRows.map((d) => d.id));
|
||||
const dayIds = await this.days.listIdsByTrip(tripId); // CT2 — Plan 3j
|
||||
const tripDayIds: ReadonlySet<number> = new Set(dayIds);
|
||||
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<DayTint[]> => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<boolean> {
|
||||
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) {
|
||||
|
||||
@@ -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<string> = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']);
|
||||
@@ -23,14 +36,6 @@ const EDIT_ACTION: Record<string, string> = {
|
||||
accommodation: 'day_edit',
|
||||
};
|
||||
|
||||
/** Each of these tables has a NOT NULL trip_id, so the gate resolves the owning trip. */
|
||||
const ENTITY_TABLE: Record<string, string> = {
|
||||
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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown> = {};
|
||||
for (const r of rows) {
|
||||
try {
|
||||
@@ -107,10 +105,8 @@ export class MetaRpc {
|
||||
@PluginMethod('meta.delete', { permission: 'db:meta' })
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<number | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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')
|
||||
|
||||
@@ -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<boolean> {
|
||||
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')
|
||||
|
||||
@@ -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<Omit<ActivePlugin, 'slot' | 'tripPage'> & { 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
];
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')),
|
||||
]);
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over });
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
|
||||
|
||||
|
||||
@@ -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<number, string> = { 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 () => {} };
|
||||
|
||||
@@ -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<typeof import('../../../src/app-config')>();
|
||||
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<string, unknown> = {}) => ({ 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();
|
||||
|
||||
@@ -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<PluginHooks> = {}) {
|
||||
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', () => {
|
||||
|
||||
@@ -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<PluginOAuthService> = {}) {
|
||||
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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -12,12 +12,10 @@ const { rows, pluginsEnabled } = vi.hoisted(() => ({
|
||||
rows: { value: [] as Array<Record<string, unknown>> },
|
||||
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<string, unknown> = {}) => ({
|
||||
id: 'p1', name: 'P', type: 'integration', icon: null,
|
||||
@@ -26,7 +24,9 @@ const row = (over: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -67,6 +67,17 @@ async function makeService(): Promise<PluginsService> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PluginsFeedController> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user