Skip to content
Add a feature

Add a feature

A step-by-step walkthrough for adding a feature to Neatship by copying the Projects reference feature: database table, migration, service, API, web page, permissions, translations and tests.

This guide walks through adding a new feature, Clients, from the database to the screen. You will copy the Projects feature, which is the reference: small, complete and following every rule. At the end, workspace members can list, create and rename clients, only admins can delete them, and tests prove that no workspace sees another workspace's clients.

You can do each step yourself or hand the whole guide to your AI assistant (see Working with AI assistants). Either way, the checks at the end tell you whether it's right.

Before you start, yarn setup must have run and Postgres must be running: the migration step compares your code with your development database.

The pieces, at a glance

PieceProjects (copy from)Clients (what you create)
Server featurepackages/app-server/src/modules/project/packages/app-server/src/modules/client/
Migrationpackages/app-server/src/database/typeorm/core/migrations/a new generated file there
Server testspackages/app-server/test/integration/project/packages/app-server/test/integration/client/
Web featurepackages/app-front/src/modules/project/packages/app-front/src/modules/client/
Web pagepackages/app-front/src/pages/projects/ProjectsPage.tsxpackages/app-front/src/pages/clients/ClientsPage.tsx

Part 1: the server

1. Copy the Projects module

bash
cp -R packages/app-server/src/modules/project packages/app-server/src/modules/client

Rename every file and every name inside: project becomes client, Project becomes Client, PROJECT_ becomes CLIENT_. For example project.service.ts becomes client.service.ts and exports ClientService. Remove what Clients don't need (Projects have an archive status; a client may not).

2. The entity: your table

An entity is a class that describes one database table. Open client.entity.ts:

ts
@Index('IDX_CLIENT_WORKSPACE_ID', ['workspaceId'])
@Index(CLIENT_NAME_UNIQUE_INDEX_NAME, ['workspaceId', 'name'], {
  unique: true,
  where: '"deletedAt" IS NULL',
})
@Entity({ name: 'client', schema: 'core' })
export class ClientEntity extends WorkspaceRelatedEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'text' })
  name: string;

  @CreateDateColumn({ type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamptz' })
  updatedAt: Date;

  @DeleteDateColumn({ type: 'timestamptz', nullable: true })
  deletedAt: Date | null;
}

What matters:

  • extends WorkspaceRelatedEntity adds the workspaceId column, its link to the workspace, and the automatic cleanup when a workspace is deleted. Every table that belongs to a customer workspace must extend it.
  • The table name is singular camelCase (client), in the core schema. Ids are uuids, dates are timestamptz.
  • deletedAt enables soft delete: a deleted row is hidden, not erased.
  • The unique index is partial (WHERE "deletedAt" IS NULL), so the name of a deleted client can be used again.
  • Index and constraint names are explicit and uppercase: IDX_..., UQ_..., CHK_....

Then tell the lint rule that ClientEntity belongs to a workspace. In packages/app-server/.oxlintrc.json, add it to the entities of app/prefer-workspace-scoped-repository:

json
"app/prefer-workspace-scoped-repository": [
  "error",
  {
    "entities": ["ProjectEntity", "WorkspaceMemberEntity", "ClientEntity"]
  }
]

Keep the existing exemptions list as it is. From now on, lint refuses any raw @InjectRepository(ClientEntity).

3. The module

client.module.ts registers the table and the workspace-scoped repository:

ts
@Module({
  imports: [TypeOrmModule.forFeature([ClientEntity])],
  providers: [
    provideWorkspaceScopedRepository(ClientEntity),
    ClientService,
    ClientResolver,
  ],
})
export class ClientModule {}

Add ClientModule to the imports of packages/app-server/src/modules/modules.module.ts, next to ProjectModule.

4. Generate the migration

A migration is a file of SQL steps that creates or changes tables. Never write it from scratch: generate it from the difference between your entities and your development database.

bash
npx nx run app-server:database:migrate:generate --name=add-clients

This builds the server, then writes packages/app-server/src/database/typeorm/core/migrations/<timestamp>-add-clients.ts. Open it and read it: it should create the client table, its indexes and its foreign key, and its down method should undo exactly that.

Apply it to your development database:

bash
npx nx run app-server:database:migrate

Useful companions:

bash
npx nx run app-server:database:migrate:revert   # undo the last migration
npx nx run app-server:database:migrate:check    # fails if an entity change has no migration (CI runs it)

Warning: The generator compares CHECK constraints by name only. If you change the expression of a CHECK constraint, give it a new name, or the change goes unnoticed.

5. The service: every rule of the feature

services/client.service.ts holds the logic. The resolver stays thin and calls it.

ts
@Injectable()
export class ClientService {
  constructor(
    @InjectWorkspaceScopedRepository(ClientEntity)
    private readonly clientRepository: WorkspaceScopedRepository<ClientEntity>,
  ) {}

  async findClientOrThrow(workspaceId: string, clientId: string): Promise<ClientEntity> {
    const client = await this.clientRepository.findOne(workspaceId, {
      where: { id: clientId },
    });

    if (!isDefined(client)) {
      throw new ClientException(`Client ${clientId} not found`, 'CLIENT_NOT_FOUND');
    }

    return client;
  }
}

The workspace-scoped repository takes the workspaceId as first argument of every method (find, findOne, count, insert, update, softDelete, restore, delete, upsert) and adds it to the query itself. A client of another workspace is simply not found. You can't forget the filter, and a caller can't override it.

The workspaceId always comes from the signed-in member, never from what the browser sends.

A duplicate name is detected by the unique index when writing, not by reading first: two requests at the same moment would both pass a read. See refuseDuplicateName in project.service.ts.

6. Errors with translated messages

exceptions/client.exception.ts lists the error codes of the feature, each with a message for people:

ts
export const CLIENT_EXCEPTION_CODES = ['CLIENT_NOT_FOUND', 'CLIENT_NAME_ALREADY_USED'] as const;

export type ClientExceptionCode = (typeof CLIENT_EXCEPTION_CODES)[number];

const getClientExceptionUserFriendlyMessage = (code: ClientExceptionCode): MessageDescriptor => {
  switch (code) {
    case 'CLIENT_NOT_FOUND':
      return msg`This client doesn't exist or was deleted.`;
    case 'CLIENT_NAME_ALREADY_USED':
      return msg`A client with this name already exists. Choose another name.`;
    default:
      return assertUnreachable(code);
  }
};

assertUnreachable makes the build fail if you add a code without its message.

Two more files turn each code into a GraphQL error the web app understands: utils/client-graphql-api-exception-handler.util.ts (for example CLIENT_NOT_FOUND becomes NotFoundError, a duplicate name becomes ConflictError) and filters/client-graphql-api-exception.filter.ts, applied to the resolver with @UseFilters.

The web app then receives extensions.code (NOT_FOUND), extensions.subCode (CLIENT_NOT_FOUND) and extensions.userFriendlyMessage, already translated into the language of the request. Screens show userFriendlyMessage. The technical message ("Client … not found") stays in the error's message field, for developers. An error the server didn't expect (a crash, a database failure) becomes a generic "Internal Server Error", with its details added outside production only.

7. Inputs and outputs

  • dtos/create-client.input.ts: what the browser may send, with validation rules (@IsString(), @IsNotEmpty(), @MaxLength(...)). Never a workspaceId. Unknown fields are refused by the global validation pipe.
  • dtos/client.dto.ts: what the API returns (@ObjectType('Client')), filled by utils/to-client-dto.util.ts. The entity itself is never returned, so a new column never leaks into the API by accident.

8. The resolver and its two guards

The resolver declares the GraphQL queries and mutations. Every one of them needs two guards:

  • an authentication guard answers "who is calling?". WorkspaceAuthGuard requires a signed-in person with an active workspace; otherwise the answer is UNAUTHENTICATED, and the web app sends the person to sign in.
  • a permission guard answers "may this member do this?". WorkspacePermissionGuard('org:clients:delete') answers FORBIDDEN to a member without that permission. NoPermissionGuard declares on purpose that every member may do it.

They are separate because they fail differently and are checked for different reasons. The app/graphql-resolvers-should-be-guarded lint rule refuses an endpoint that lacks either one, so "open by accident" can't ship.

ts
@Resolver(() => ClientDto)
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ClientGraphqlApiExceptionFilter)
export class ClientResolver {
  constructor(private readonly clientService: ClientService) {}

  @Query(() => [ClientDto])
  @UseGuards(NoPermissionGuard)
  async clients(@AuthWorkspace() workspace: WorkspaceEntity): Promise<ClientDto[]> {
    const clients = await this.clientService.findClients(workspace.id);

    return clients.map(toClientDto);
  }

  @Mutation(() => ClientDto)
  @UseGuards(WorkspacePermissionGuard('org:clients:delete'))
  async deleteClient(
    @AuthWorkspace() workspace: WorkspaceEntity,
    @Args('id', { type: () => ID }, ParseUUIDPipe) clientId: string,
  ): Promise<ClientDto> {
    return toClientDto(await this.clientService.deleteClient(workspace.id, clientId));
  }
}

ParseUUIDPipe answers BAD_USER_INPUT for a malformed id before the database sees it.

An action someone could repeat to flood your server or a paid service (creating records, sending an email, calling Stripe) also gets a rate limit, written above its @UseGuards(), like createProject in the reference feature:

ts
@Mutation(() => ClientDto)
@RateLimit({ scope: 'user', maxRequests: 30, windowInMilliseconds: 60_000 })
@UseGuards(WorkspacePermissionGuard('org:clients:create'))
async createClient(...)

Beyond the limit, the web app shows "Too many attempts. Wait a moment, then try again." The rules and the other scopes (workspace, ip) are in docs/conventions/09-security.md §5.

9. Permissions

Add the new permissions in two places:

  1. WORKSPACE_PERMISSIONS in packages/app-shared/src/constants/WorkspacePermissions.ts: 'org:clients:create', 'org:clients:update', 'org:clients:delete'.
  2. WORKSPACE_PERMISSIONS_BY_ROLE in packages/app-server/src/engine/core-modules/auth/constants/workspace-permissions-by-role.constant.ts: admins already get every permission; add 'org:clients:create' and 'org:clients:update' to member.

Then rebuild the shared package, which the other packages read from its build output:

bash
npx nx build app-shared --skip-nx-cache

Nothing changes in the Clerk dashboard. See Authentication.

10. Server translations

The error messages you wrote with msg need a French version:

bash
npx nx run app-server:lingui:extract

Translate the new entries in packages/app-server/src/engine/core-modules/i18n/locales/fr.po, then:

bash
npx nx run app-server:lingui:compile

11. The GraphQL schema file

bash
npx nx run app-server:graphql:schema

This rewrites packages/app-server/generated/schema.graphql, the description of your whole API. Commit it: the web app generates its types from it, and CI fails if it's out of date.

Part 2: the web app

12. The route and the navigation

  1. Add the path to APP_PATHS in packages/app-shared/src/constants/AppPaths.ts: clients: '/clients'. Rebuild the shared package.

  2. Add a lazy route in packages/app-front/src/modules/app/components/AppRouter.tsx, next to the Projects route:

    tsx
    const ClientsPage = lazy(() =>
      import('~/pages/clients/ClientsPage').then((module) => ({
        default: module.ClientsPage,
      })),
    );
    
    // inside the AppLayout route
    <Route path={APP_PATHS.clients} element={<ClientsPage />} />
  3. Add an entry to NAVIGATION_ITEMS in packages/app-front/src/modules/navigation/constants/NavigationItems.ts. The sidebar, the command menu (Cmd/Ctrl + K) and the keyboard shortcut all read this list:

    ts
    {
      id: 'clients',
      label: msg`Clients`,
      Icon: IconBriefcase,
      path: APP_PATHS.clients,
      goToHotkey: 'g c',
    },

    Icons come from app-ui/icon only.

13. Copy the Projects module

bash
cp -R packages/app-front/src/modules/project packages/app-front/src/modules/client

Rename files and names as on the server: ProjectList.tsx becomes ClientList.tsx, useProjects.ts becomes useClients.ts, and so on.

14. GraphQL operations and generated types

Each request to the API is written once, in its own file under graphql/:

ts
// src/modules/client/graphql/fragments/clientFields.ts
export const CLIENT_FIELDS_FRAGMENT = gql`
  fragment ClientFields on Client {
    id
    name
    createdAt
    updatedAt
  }
`;

// src/modules/client/graphql/queries/getClients.ts
export const GET_CLIENTS = gql`
  query GetClients {
    clients {
      ...ClientFields
    }
  }
  ${CLIENT_FIELDS_FRAGMENT}
`;

A fragment is a named list of fields. Every query and mutation spreads it, so every screen asks for the same fields. Write one file per mutation too (createClient.ts, updateClient.ts, deleteClient.ts).

Then generate the TypeScript types:

bash
npx nx run app-front:graphql:generate

It reads packages/app-server/generated/schema.graphql and your operations (no running server needed) and rewrites packages/app-front/src/generated/graphql.ts, with GetClientsDocument, ClientFieldsFragment and the other types. A typo in a field name now fails the type check instead of the screen. Commit the file.

15. Hooks

Read hooks use the generated document:

ts
export const useClients = () => {
  const { data, loading, error, refetch } = useQuery(GetClientsDocument, {
    // The list shows its own error state.
    context: SKIP_ERROR_TOAST_CONTEXT,
  });

  return {
    clients: data?.clients ?? [],
    isLoading: loading && !isDefined(data),
    error,
    refetch,
  };
};

Write hooks follow useCreateProject.ts and useDeleteProject.ts:

  • they never throw: they use errorPolicy: 'all' and return { client, error } for the screen to show;
  • creating reloads the lists on screen, after a success only (refetchQueries);
  • deleting removes the record from the cache (cache.evict), which removes it from every list with no extra request;
  • renaming needs nothing: the API returns the record with its id, and the cache updates it everywhere.

16. The page and its components

packages/app-front/src/pages/clients/ClientsPage.tsx stays thin: a header, a list, a dialog. Copy ProjectsPage.tsx:

tsx
export const ClientsPage = () => {
  const { t } = useLingui();

  return (
    <>
      <PageHeader title={t`Clients`} Icon={IconBriefcase} actions={<NewClientButton />} />
      <PageBody>
        <ClientList />
      </PageBody>
      <CreateClientDialog />
    </>
  );
};

The logic lives in src/modules/client/components/. ClientList shows every state: loading, an error with "Try again", empty, and the rows. Styles come from themeCssVariables (see Design and branding).

17. Permissions in the interface

Hide or disable what the member can't do, with a short reason:

tsx
const canDeleteClient = useHasPermission('org:clients:delete');

This is a courtesy for the person. The real check is the server's permission guard, on every request.

18. Translations

Every word on screen goes through Lingui: t in components, msg in constants (see Translations). Then:

bash
npx nx run app-front:lingui:extract

Translate the new entries in packages/app-front/src/locales/fr.po (the French uses the formal "vous"), then:

bash
npx nx run app-front:lingui:compile

Part 3: tests

Copy the Projects tests and adapt them:

TestFileWhat it proves
Service unit testpackages/app-server/src/modules/client/services/__tests__/client.service.spec.tsthe service's decisions, with a mocked repository
API testspackages/app-server/test/integration/client/clients.integration-spec.tsreal GraphQL requests on the real app and a real database: the normal case, another workspace (always NOT_FOUND), a member deleting (FORBIDDEN), signed out (UNAUTHENTICATED)
Page testpackages/app-front/src/pages/clients/__tests__/ClientsPage.test.tsxthe list, creating (validation, success), a name already used shown on the field, delete hidden for a member

The API tests sign in as seeded people with makeGraphqlApiRequest(app, { query, userEmail }) (see project-permissions.integration-spec.ts). Bob belongs to Globex only: use him to prove that Acme's clients look like they don't exist.

The page test renders with renderWithProviders(ui, { apolloMocks, session: getMockSession({ role: 'member' }) }). Type each mocked answer with the generated types, and assert on what a person sees (text, roles), never on which hook was called. Add a getMockClient helper next to packages/app-front/src/testing/mock-data/getMockProject.ts.

To see clients in your local app right away, add sample rows in packages/app-server/src/database/seeds/ (like seed-projects.constant.ts) and run npx nx run app-server:database:seed.

Part 4: the checks

bash
yarn verify                                     # the definition of done: lint, types, unit tests, generated files, translations, migration
npx nx run app-server:test:integration          # API tests (resets the test database first)

The feature is done when yarn verify ends with "All checks passed" and the API tests pass. An AI assistant runs both by itself (see Working with AI assistants).

Then open http://localhost:5180, press g then c, and try the feature as an admin. To try it as a member in mock mode, send requests with the x-mock-user-email: alan@example.com header, or sign in with a second Clerk account that has the member role.

Optional touches

  • A "New client" command in the command menu: see the project commands in packages/app-front/src/modules/command-menu/hooks/useCommandMenuCommands.ts.
  • Instant opening from a list: packages/app-front/src/modules/apollo/utils/createApolloCache.ts shows how a project already loaded by a list opens at once in the side panel.
  • Background work for the feature (emails, syncs): see Background jobs and webhooks.