mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +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,
|
||||
|
||||
Reference in New Issue
Block a user