feat(03-01): add ModuleRegistry NestJS module with CRUD and activation endpoints

- ModuleRegistryService with findAll, findBySlug, findActiveForTenant, activate/deactivate, seedModule
- ModuleRegistryController with GET /modules, GET /modules/active, POST activate/deactivate
- ModuleGuard + @UseModule() decorator for tenant-scoped module access control
- ActivateModuleDto with UUID validation
- Registered ModuleRegistryModule in AppModule imports
This commit is contained in:
2026-06-19 12:36:33 +02:00
parent 8c24c1e267
commit fa15d3527a
6 changed files with 393 additions and 0 deletions
@@ -0,0 +1,188 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
/**
* Service managing the module registry and per-tenant activations.
*
* Modules are registered centrally; tenants activate/deactivate them
* independently via TenantModuleActivation records (per D-07, D-08).
*/
@Injectable()
export class ModuleRegistryService {
constructor(private readonly prisma: PrismaService) {}
/**
* Returns all registered modules.
*/
async findAll() {
return this.prisma.module.findMany({
orderBy: { name: 'asc' },
});
}
/**
* Finds a module by its unique slug.
*/
async findBySlug(slug: string) {
return this.prisma.module.findUnique({
where: { slug },
});
}
/**
* Returns all active modules for a given tenant.
*/
async findActiveForTenant(tenantId: string) {
const activations = await this.prisma.tenantModuleActivation.findMany({
where: {
tenantId,
isActive: true,
},
include: {
module: true,
},
});
return activations.map((a) => a.module);
}
/**
* Activates a module for a tenant (upsert: creates or re-activates).
* Per D-08: dynamic activation without restart.
*/
async activateForTenant(tenantId: string, moduleId: string) {
// Verify module exists
const moduleExists = await this.prisma.module.findUnique({
where: { id: moduleId },
});
if (!moduleExists) {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
return this.prisma.tenantModuleActivation.upsert({
where: {
tenantId_moduleId: {
tenantId,
moduleId,
},
},
update: {
isActive: true,
activatedAt: new Date(),
},
create: {
tenantId,
moduleId,
isActive: true,
},
include: {
module: true,
},
});
}
/**
* Deactivates a module for a tenant (soft-delete: sets isActive=false).
* Does not remove the activation record, preserving audit trail.
*/
async deactivateForTenant(tenantId: string, moduleId: string) {
// Verify module exists
const moduleExists = await this.prisma.module.findUnique({
where: { id: moduleId },
});
if (!moduleExists) {
throw new NotFoundException(`Module with id '${moduleId}' not found`);
}
// Check if activation record exists
const activation = await this.prisma.tenantModuleActivation.findUnique({
where: {
tenantId_moduleId: {
tenantId,
moduleId,
},
},
});
if (!activation) {
throw new NotFoundException(
`Module '${moduleId}' is not activated for this tenant`,
);
}
return this.prisma.tenantModuleActivation.update({
where: {
tenantId_moduleId: {
tenantId,
moduleId,
},
},
data: {
isActive: false,
},
include: {
module: true,
},
});
}
/**
* Checks whether a module (by slug) is active for a given tenant.
* Used by ModuleGuard to gate access to module-specific endpoints.
*/
async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
const module = await this.prisma.module.findUnique({
where: { slug: moduleSlug },
});
if (!module) {
return false;
}
const activation = await this.prisma.tenantModuleActivation.findUnique({
where: {
tenantId_moduleId: {
tenantId,
moduleId: module.id,
},
},
});
return activation?.isActive === true;
}
/**
* Registers or updates a module in the registry by slug (upsert).
* Used during application startup to seed built-in modules.
*/
async seedModule(manifest: {
slug: string;
name: string;
version: string;
category: string;
description: Record<string, string>;
icon?: string;
isSystem?: boolean;
}) {
return this.prisma.module.upsert({
where: { slug: manifest.slug },
update: {
name: manifest.name,
version: manifest.version,
category: manifest.category,
description: manifest.description,
icon: manifest.icon ?? null,
isSystem: manifest.isSystem ?? false,
},
create: {
slug: manifest.slug,
name: manifest.name,
version: manifest.version,
category: manifest.category,
description: manifest.description,
icon: manifest.icon ?? null,
isSystem: manifest.isSystem ?? false,
},
});
}
}