Files
TREK/server/src/nest/admin/admin.controller.ts
T
Maurice d4a1601855 Give the packing templates and the feature flags back to their domains
Twelve admin packing-template routes sat on AdminController and reached
PackingService through eleven pass-through methods on AdminService that did
nothing but forward and cast. PackingService already owns all three template
tables, so the routes move to AdminPackingTemplatesController next to it and the
pass-through block is gone. AdminService no longer injects PackingService at all.

The three places flags were raw app_settings SQL on AdminService, sitting beside
bag-tracking and collab-features which it already delegated to AddonsService.
They move to AddonsService, which owns every other flag, and AdminController now
calls it directly rather than through delegating methods.

The toggle ROUTES stay on AdminController, which is a departure from the plan and
worth stating: an admin-gated controller in AddonsModule makes that module import
AuthModule, and PluginGuardsModule imports AddonsModule precisely because it is a
leaf — Nest refused to build the graph. Same for the addon CRUD, whose PUT
cascades into PluginRuntimeService. Both notes are in the code.

Paths, response shapes, the {error,status} envelope, the create-201 split and
every audit action are unchanged.
2026-08-09 20:54:39 +02:00

339 lines
15 KiB
TypeScript

import { Body, Controller, Delete, Get, HttpCode, HttpException, NotFoundException, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { readEnv } from '../../app-config';
import { AdminService } from './admin.service';
import {
AdminUserCreateDto,
AdminUserUpdateDto,
AdminPermissionsDto,
AdminInviteCreateDto,
AdminFeatureToggleDto,
AdminTemplateNameDto,
AdminOidcUpdateDto,
AdminAddonUpdateDto,
AdminCollabFeaturesDto,
AdminNotificationPreferencesDto,
AdminDefaultUserSettingsDto,
AdminTestNotificationDto,
} from './admin.dto';
import { PluginRuntimeService } from '../plugins/plugin-runtime.service';
import { AddonsService } from '../addons/addons.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { User } from '../../types';
/** Throw the legacy {error,status} envelope when a service call reports failure. */
function ok<T>(result: T): Exclude<T, { error: string }> {
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
const r = result as unknown as { error: string; status?: number };
throw new HttpException({ error: r.error }, r.status ?? 400);
}
return result as Exclude<T, { error: string }>;
}
/**
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
* sessions, JWT rotation, default user settings).
*
* Byte-identical to the legacy Express route (server/src/routes/admin.ts):
* admin-gated, the {error,status} envelopes, the audit-log writes, the MCP
* session invalidation on addon/collab changes, create-201 vs the rest 200, and
* the dev-only test-notification endpoint (404 outside development).
*
* Bodies validate via admin.dto.ts (@trek/shared schemas through the global
* ZodValidationPipe) — malformed bodies now get the pipe's standard
* { error: 'field: message; …' } envelope instead of the old inline typeof
* checks (the sanctioned ratchet behavior); valid bodies behave identically,
* and every bespoke service-level 400 is untouched.
*/
@Controller('api/admin')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminController {
constructor(
private readonly admin: AdminService,
private readonly addons: AddonsService,
private readonly pluginRuntime: PluginRuntimeService,
private readonly audit: AuditService,
private readonly notifications: NotificationsService,
) {}
// ── Users ──
@Get('users')
listUsers() { return { users: this.admin.listUsers() }; }
@Post('users')
@HttpCode(201)
createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = ok(this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
}
@Delete('users/:id')
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.deleteUser(id, user.id));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
}
@Delete('users/:id/passkeys')
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserPasskeys(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
return { success: true, deleted: result.deleted };
}
// ── Stats / permissions / audit ──
@Get('stats')
stats() { return this.admin.getStats(); }
@Get('permissions')
permissions() { return this.admin.getPermissions(); }
@Put('permissions')
savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
}
@Get('audit-log')
auditLog(@Query() query: { limit?: string; offset?: string }) { return this.admin.getAuditLog(query); }
// ── OIDC ──
@Get('oidc')
getOidc() { return this.admin.getOidcSettings(); }
@Put('oidc')
updateOidc(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
const result = this.admin.updateOidcSettings(body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status || 400);
}
this.audit.writeAudit({ userId: user.id, action: 'admin.oidc_update', ip: getClientIp(req), details: { issuer_set: !!body.issuer } });
return { success: true };
}
@Post('save-demo-baseline')
@HttpCode(200)
saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.saveDemoBaseline();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
// ── GitHub / version ──
@Get('github-releases')
async githubReleases(@Query('per_page') perPage = '10', @Query('page') page = '1') {
return this.admin.getGithubReleases(String(perPage), String(page));
}
@Get('version-check')
async versionCheck() { return this.admin.checkVersion(); }
// ── Admin notification preferences ──
@Get('notification-preferences')
getNotificationPrefs(@CurrentUser() user: User) { return this.admin.getPreferencesMatrix(user.id, user.role); }
@Put('notification-preferences')
setNotificationPrefs(@CurrentUser() user: User, @Body() body: AdminNotificationPreferencesDto) {
this.admin.setAdminPreferences(user.id, body);
return this.admin.getPreferencesMatrix(user.id, user.role);
}
// ── Invites ──
@Get('invites')
listInvites() { return { invites: this.admin.listInvites() }; }
// Trips an admin can optionally bind a registration invite to (#1402).
@Get('invites/trips')
listInviteTrips() { return { trips: this.admin.listTripsForInvite() }; }
@Post('invites')
@HttpCode(201)
createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
const result = this.admin.createInvite(user.id, body);
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
return { invite: result.invite };
}
@Delete('invites/:id')
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.deleteInvite(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
// ── Feature toggles ──
// ── Feature toggles ──
// Straight to AddonsService, which owns every one of these flags. They used to go
// through eleven-plus pass-through methods on AdminService, and the three places
// flags were raw app_settings SQL there even though the other flags lived in the
// addons domain. The routes stay HERE rather than moving next to the service: an
// admin-gated controller in AddonsModule would make that module import AuthModule,
// and PluginGuardsModule imports AddonsModule precisely because it is a leaf — the
// edge would close a cycle.
@Get('bag-tracking')
getBagTracking() { return this.addons.getBagTracking(); }
@Put('bag-tracking')
updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updateBagTracking(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-photos')
getPlacesPhotos() { return this.addons.getPlacesPhotos(); }
@Put('places-photos')
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesPhotos(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-autocomplete')
getPlacesAutocomplete() { return this.addons.getPlacesAutocomplete(); }
@Put('places-autocomplete')
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesAutocomplete(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('places-details')
getPlacesDetails() { return this.addons.getPlacesDetails(); }
@Put('places-details')
updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = this.addons.updatePlacesDetails(body.enabled);
this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
return result;
}
@Get('collab-features')
getCollabFeatures() { return this.addons.getCollabFeatures(); }
@Put('collab-features')
updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
const { features, changed } = this.addons.updateCollabFeatures(body);
// Collab flags gate MCP registration, but a no-op save must not tear down
// every live MCP session (#1414).
if (changed) this.admin.invalidateMcpSessions();
this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
return features;
}
// ── Addons ──
@Get('addons')
listAddons() { return { addons: this.admin.listAddons() }; }
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateAddon(id, body));
this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
// Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
// saves and photo-provider toggles used to kill every session (#1414).
if (result.mcpAffected) this.admin.invalidateMcpSessions();
// Disabling an addon cascades to plugins that require it: stop them (and any
// plugin that transitively depends on them) so a plugin never runs against a
// disabled addon (#plugins dependencies).
if (result.addon && result.addon.enabled === false && (body as { enabled?: boolean })?.enabled === false) {
await this.pluginRuntime.deactivateForDisabledAddon(id);
}
return { addon: result.addon };
}
// ── MCP tokens / OAuth sessions ──
@Get('mcp-tokens')
listMcpTokens() { return { tokens: this.admin.listMcpTokens() }; }
@Delete('mcp-tokens/:id')
deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.deleteMcpToken(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@Get('oauth-sessions')
listOAuthSessions() { return { sessions: this.admin.listOAuthSessions() }; }
@Delete('oauth-sessions/:id')
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.admin.revokeOAuthSession(id));
this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
// ── JWT rotation ──
@Post('rotate-jwt-secret')
@HttpCode(200)
rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.rotateJwtSecret();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
// ── Default user settings ──
@Get('default-user-settings')
getDefaultUserSettings() { return this.admin.getAdminUserDefaults(); }
@Put('default-user-settings')
setDefaultUserSettings(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) {
try {
this.admin.setAdminUserDefaults(body as unknown as Record<string, unknown>);
this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record<string, unknown> });
return this.admin.getAdminUserDefaults();
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
}
// ── Dev-only: test notification (404 outside development, mirroring the conditional mount) ──
@Post('dev/test-notification')
@HttpCode(200)
async devTestNotification(@CurrentUser() user: User, @Body() body: AdminTestNotificationDto) {
if (!readEnv().app.isDevelopment) {
throw new NotFoundException();
}
try {
await this.notifications.send({
event: body.event ?? 'trip_reminder',
actorId: user.id,
scope: body.scope ?? 'user',
targetId: body.targetId ?? user.id,
params: { actor: user.email, ...(body.params ?? {}) },
inApp: body.inApp,
} as unknown as Parameters<NotificationsService['send']>[0]);
return { success: true };
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
}
}