@devoven/organization
v0.1.5
Published
Organization management module for NestJS — hexagonal architecture
Readme
@devoven/organization
Organization and team management for multi-tenant NestJS applications. Provides organizations, membership, role assignment, and invitation workflows out of the box.
Installation
npm install @devoven/organization
# or
pnpm add @devoven/organizationPeer Dependencies
Install the standard NestJS validation stack if your app does not already have it:
npm install class-validator class-transformer@nestjs/common, @nestjs/core, rxjs, and reflect-metadata are expected to already be present in any NestJS application.
Quick Start
import { OrganizationModule } from '@devoven/organization';
@Module({
imports: [
OrganizationModule.register({
roles: ['ADMIN', 'MEMBER'],
userIdExtractor: (req: any) => req.user.id,
}),
],
})
export class AppModule {}OWNER is always included automatically. The roles array defines the additional roles available in your application. userIdExtractor is called on every controller request to resolve the acting user's ID from the request object.
Module Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| roles | string[] | — (required) | Custom role names. OWNER is always available and must not be listed here |
| userIdExtractor | (req: unknown) => string | — (required) | Function that extracts the current user's ID from the NestJS request |
| organizationRepository | Class or instance | InMemoryOrganizationRepository | Persistence adapter for organizations |
| memberRepository | Class or instance | InMemoryMemberRepository | Persistence adapter for members |
| invitationRepository | Class or instance | InMemoryInvitationRepository | Persistence adapter for invitations |
| invitationTtlMs | number | 604800000 (7 days) | Invitation expiry in milliseconds |
| controller | boolean | true | Mount all four controllers. Set to false for programmatic use only |
| routeGuards | RouteGuardsOptions | See defaults below | Per-route role overrides for OrganizationRoleGuard |
Default route guard roles
| Route key | Allowed roles |
|-----------|---------------|
| getOrganization | ['*'] (any member) |
| updateOrganization | ['OWNER'] |
| deleteOrganization | ['OWNER'] |
| restoreOrganization | ['OWNER'] |
| transferOwnership | ['OWNER'] |
| addMember | ['OWNER'] |
| changeMemberRole | ['OWNER'] |
| removeMember | ['*'] (any member) |
| getMembers | ['*'] (any member) |
| inviteMember | ['OWNER'] |
| listInvitations | ['OWNER'] |
| revokeInvitation | ['OWNER'] |
'*' means any authenticated member of the organization. 'OWNER' means only the organization owner. You can override any key in routeGuards to use your custom role names.
Async Registration
import { OrganizationModule } from '@devoven/organization';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
OrganizationModule.registerAsync({
useFactory: (config: ConfigService) => ({
roles: config.get<string[]>('ORG_ROLES'),
userIdExtractor: (req: any) => req.user.id,
invitationTtlMs: config.get<number>('INVITATION_TTL_MS'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}REST API
Organizations — POST /organizations
Create an organization. The requesting user becomes the OWNER automatically.
Request body:
{
"name": "Acme Corp",
"slug": "acme-corp"
}Response: 201 Created
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corp",
"slug": "acme-corp",
"ownerId": "user-uuid",
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}Organizations — GET /organizations
List all organizations the requesting user belongs to.
Response: 200 OK — array of organization objects (same shape as above).
Organizations — GET /organizations/:id
Get a single organization by ID.
Response: 200 OK — organization object. 404 Not Found if the organization does not exist.
Organizations — PATCH /organizations/:id
Update an organization's name and/or slug. Both fields are optional.
Request body:
{
"name": "Acme Corporation",
"slug": "acme-corporation"
}Response: 200 OK — updated organization object.
Organizations — DELETE /organizations/:id
Soft-delete an organization. The organization is marked as deleted but not removed from the database.
Response: 200 OK
{ "success": true }Organizations — POST /organizations/:id/restore
Restore a soft-deleted organization.
Response: 200 OK
{ "success": true }Organizations — POST /organizations/:id/transfer-ownership
Transfer ownership to another member. The current owner is optionally demoted to a specified role.
Request body:
{
"newOwnerId": "target-user-uuid",
"demoteToRole": "ADMIN"
}demoteToRole is optional. If omitted, the previous owner's role after transfer is implementation-defined by the use case.
Response: 200 OK
{ "success": true }Members — POST /organizations/:orgId/members
Directly add a user to an organization with a specified role.
Request body:
{
"userId": "target-user-uuid",
"role": "MEMBER"
}Response: 201 Created
{
"id": "member-uuid",
"userId": "target-user-uuid",
"role": "MEMBER",
"joinedAt": "2025-01-15T10:30:00.000Z",
"invitedBy": "requester-user-uuid"
}Members — GET /organizations/:orgId/members
List all members of an organization.
Response: 200 OK — array of member objects (same shape as above).
Members — DELETE /organizations/:orgId/members/:userId
Remove a member from an organization.
Response: 200 OK
{ "success": true }Members — PATCH /organizations/:orgId/members/:userId/role
Change a member's role.
Request body:
{
"role": "ADMIN"
}Response: 200 OK — updated member object.
Invitations — POST /organizations/:orgId/invitations
Invite a user by email. The OWNER role cannot be used in an invitation.
Request body:
{
"email": "[email protected]",
"role": "MEMBER"
}Response: 201 Created
{
"id": "invitation-uuid",
"email": "[email protected]",
"role": "MEMBER",
"invitedBy": "owner-user-uuid",
"expiresAt": "2025-01-22T10:30:00.000Z",
"createdAt": "2025-01-15T10:30:00.000Z"
}Invitations — GET /organizations/:orgId/invitations
List all invitations for an organization.
Response: 200 OK — array of invitation objects.
Invitations — DELETE /organizations/:orgId/invitations/:id
Revoke a pending invitation.
Response: 200 OK
{ "success": true }Accept invitation — POST /invitations/accept
Accept an invitation using the token from the invitation record. The requesting user becomes a member of the organization.
Request body:
{
"token": "invitation-token-uuid"
}Response: 201 Created — member object for the newly joined user.
Guards and Decorators
OrganizationRoleGuard
OrganizationRoleGuard is applied automatically to all controller routes. It reads the route key set by @RouteGuardKey(), looks up the required roles from the routeGuards configuration, and checks the requesting user's membership and role in the organization.
The guard resolves the organization ID from req.params.orgId or req.params.id. The user ID is resolved via userIdExtractor. If the user has no membership record, the request is denied.
A role value of '*' in the required roles list allows any authenticated member through. OWNER always passes regardless of the listed roles.
The guard and the decorator are exported from the module so you can apply them on your own routes:
import { OrganizationRoleGuard, RouteGuardKey } from '@devoven/organization';
@UseGuards(OrganizationRoleGuard)
@RouteGuardKey('updateOrganization')
@Patch(':id/settings')
async updateSettings(@Param('id') id: string) { ... }@RouteGuardKey(key)
Sets the metadata key read by OrganizationRoleGuard. The key must match a property of RouteGuardsOptions.
import { RouteGuardKey } from '@devoven/organization';
@RouteGuardKey('inviteMember')
@Post('invite')
async invite() { ... }Architecture
Port / Token Mapping
| DI Token | Interface | Default Implementation | Purpose |
|----------|-----------|------------------------|---------|
| 'CreateOrganizationPort' | CreateOrganizationPort | CreateOrganizationUseCase | Create an org and add the creator as OWNER |
| 'GetOrganizationPort' | GetOrganizationPort | GetOrganizationUseCase | Fetch a single org by ID or slug |
| 'ListOrganizationsPort' | ListOrganizationPort | ListOrganizationsUseCase | List orgs the user is a member of |
| 'UpdateOrganizationPort' | UpdateOrganizationPort | UpdateOrganizationUseCase | Update name and/or slug |
| 'DeleteOrganizationPort' | DeleteOrganizationPort | DeleteOrganizationUseCase | Soft-delete an org |
| 'RestoreOrganizationPort' | RestoreOrganizationPort | RestoreOrganizationUseCase | Restore a soft-deleted org |
| 'TransferOwnershipPort' | TransferOwnershipPort | TransferOwnershipUseCase | Change the org's owner |
| 'AddMemberPort' | AddMemberPort | AddMemberUseCase | Directly add a user as a member |
| 'RemoveMemberPort' | RemoveMemberPort | RemoveMemberUseCase | Remove a member |
| 'ListMembersPort' | ListMembersPort | ListMembersUseCase | List all members of an org |
| 'ChangeMemberRolePort' | ChangeMemberRolePort | ChangeMemberRoleUseCase | Assign a different role to a member |
| 'InviteMemberPort' | InviteMemberPort | InviteMemberUseCase | Create an invitation for an email address |
| 'ListInvitationsPort' | ListInvitationsPort | ListInvitationsUseCase | List all invitations for an org |
| 'RevokeInvitationPort' | RevokeInvitationPort | RevokeInvitationUseCase | Revoke a pending invitation |
| 'AcceptInvitationPort' | AcceptInvitationPort | AcceptInvitationUseCase | Accept an invitation and create a membership |
| 'OrganizationRepositoryPort' | OrganizationRepositoryPort | InMemoryOrganizationRepository | Persistence for organizations |
| 'MemberRepositoryPort' | MemberRepositoryPort | InMemoryMemberRepository | Persistence for members |
| 'InvitationRepositoryPort' | InvitationRepositoryPort | InMemoryInvitationRepository | Persistence for invitations |
All 18 tokens are exported from the module.
Custom Adapters
Custom organization repository
import { Injectable } from '@nestjs/common';
import {
OrganizationRepositoryPort,
Organization,
Slug,
} from '@devoven/organization';
@Injectable()
export class PrismaOrganizationRepository implements OrganizationRepositoryPort {
constructor(private readonly prisma: PrismaService) {}
async save(org: Organization): Promise<void> { /* ... */ }
async findById(id: string): Promise<Organization | null> { /* ... */ }
async findBySlug(slug: string): Promise<Organization | null> { /* ... */ }
async findByUserId(userId: string): Promise<Organization[]> { /* ... */ }
async delete(id: string): Promise<void> { /* ... */ }
}Pass it to register:
OrganizationModule.register({
roles: ['ADMIN', 'MEMBER'],
userIdExtractor: (req: any) => req.user.id,
organizationRepository: PrismaOrganizationRepository,
})MemberRepositoryPort
interface MemberRepositoryPort {
save(member: Member): Promise<void>;
findById(id: string): Promise<Member | null>;
findByOrgAndUser(organizationId: string, userId: string): Promise<Member | null>;
findByOrganization(organizationId: string): Promise<Member[]>;
findByUser(userId: string): Promise<Member[]>;
delete(id: string): Promise<void>;
deleteByOrgAndUser(organizationId: string, userId: string): Promise<void>;
}Pass your implementation to register as memberRepository.
InvitationRepositoryPort
interface InvitationRepositoryPort {
save(invitation: Invitation): Promise<void>;
findById(id: string): Promise<Invitation | null>;
findByToken(token: string): Promise<Invitation | null>;
findByOrganization(organizationId: string): Promise<Invitation[]>;
findByEmail(email: string): Promise<Invitation[]>;
findByOrganizationAndEmail(organizationId: string, email: string): Promise<Invitation | null>;
}Pass your implementation to register as invitationRepository.
Domain reconstitution methods
When building custom repository implementations, use the reconstitute static methods to rebuild domain objects from database rows. These bypass the creation-time validation and ID generation that create applies.
import { Organization, Member, Invitation, Slug, OrganizationRole } from '@devoven/organization';
// Wrap a role string — use OrganizationRole.owner() for OWNER rows,
// OrganizationRole.create(value, allowedRoles) for all other roles.
const role = row.role === 'OWNER'
? OrganizationRole.owner()
: OrganizationRole.create(row.role, allowedRoles);
// Rebuild an organization from a database row
const org = Organization.reconstitute({
id: row.id,
name: row.name,
slug: Slug.create(row.slug), // validates and wraps the slug string
ownerId: row.ownerId,
deletedAt: row.deletedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
// Rebuild a member
const member = Member.reconstitute({
id: row.id,
organizationId: row.organizationId,
userId: row.userId,
role,
joinedAt: row.joinedAt,
invitedBy: row.invitedBy,
});
// Rebuild an invitation
const invitation = Invitation.reconstitute({
id: row.id,
organizationId: row.organizationId,
email: row.email,
role,
token: row.token,
invitedBy: row.invitedBy,
expiresAt: row.expiresAt,
acceptedAt: row.acceptedAt,
revokedAt: row.revokedAt,
createdAt: row.createdAt,
});Slug.create(value) validates that the value is 3–63 characters and matches ^[a-z0-9]+(?:-[a-z0-9]+)*$. It throws if validation fails, so call it inside a try/catch or validate the slug before storing it.
Customising route guards
Override individual route guard policies without changing the defaults for others:
OrganizationModule.register({
roles: ['ADMIN', 'MEMBER'],
userIdExtractor: (req: any) => req.user.id,
routeGuards: {
updateOrganization: ['OWNER', 'ADMIN'],
inviteMember: ['OWNER', 'ADMIN'],
},
})