npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/organization

Peer 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'],
  },
})