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
| Piece | Projects (copy from) | Clients (what you create) |
|---|---|---|
| Server feature | packages/app-server/src/modules/project/ | packages/app-server/src/modules/client/ |
| Migration | packages/app-server/src/database/typeorm/core/migrations/ | a new generated file there |
| Server tests | packages/app-server/test/integration/project/ | packages/app-server/test/integration/client/ |
| Web feature | packages/app-front/src/modules/project/ | packages/app-front/src/modules/client/ |
| Web page | packages/app-front/src/pages/projects/ProjectsPage.tsx | packages/app-front/src/pages/clients/ClientsPage.tsx |
Part 1: the server
1. Copy the Projects module
cp -R packages/app-server/src/modules/project packages/app-server/src/modules/clientRename 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:
@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 WorkspaceRelatedEntityadds theworkspaceIdcolumn, 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 thecoreschema. Ids are uuids, dates aretimestamptz. deletedAtenables 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:
"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:
@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.
npx nx run app-server:database:migrate:generate --name=add-clientsThis 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:
npx nx run app-server:database:migrateUseful companions:
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.
@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:
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 aworkspaceId. Unknown fields are refused by the global validation pipe.dtos/client.dto.ts: what the API returns (@ObjectType('Client')), filled byutils/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?".
WorkspaceAuthGuardrequires a signed-in person with an active workspace; otherwise the answer isUNAUTHENTICATED, and the web app sends the person to sign in. - a permission guard answers "may this member do this?".
WorkspacePermissionGuard('org:clients:delete')answersFORBIDDENto a member without that permission.NoPermissionGuarddeclares 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.
@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:
@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:
WORKSPACE_PERMISSIONSinpackages/app-shared/src/constants/WorkspacePermissions.ts:'org:clients:create','org:clients:update','org:clients:delete'.WORKSPACE_PERMISSIONS_BY_ROLEinpackages/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'tomember.
Then rebuild the shared package, which the other packages read from its build output:
npx nx build app-shared --skip-nx-cacheNothing changes in the Clerk dashboard. See Authentication.
10. Server translations
The error messages you wrote with msg need a French version:
npx nx run app-server:lingui:extractTranslate the new entries in packages/app-server/src/engine/core-modules/i18n/locales/fr.po, then:
npx nx run app-server:lingui:compile11. The GraphQL schema file
npx nx run app-server:graphql:schemaThis 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
Add the path to
APP_PATHSinpackages/app-shared/src/constants/AppPaths.ts:clients: '/clients'. Rebuild the shared package.Add a lazy route in
packages/app-front/src/modules/app/components/AppRouter.tsx, next to the Projects route:tsxconst ClientsPage = lazy(() => import('~/pages/clients/ClientsPage').then((module) => ({ default: module.ClientsPage, })), ); // inside the AppLayout route <Route path={APP_PATHS.clients} element={<ClientsPage />} />Add an entry to
NAVIGATION_ITEMSinpackages/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/icononly.
13. Copy the Projects module
cp -R packages/app-front/src/modules/project packages/app-front/src/modules/clientRename 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/:
// 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:
npx nx run app-front:graphql:generateIt 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:
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:
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:
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:
npx nx run app-front:lingui:extractTranslate the new entries in packages/app-front/src/locales/fr.po (the French uses the formal "vous"), then:
npx nx run app-front:lingui:compilePart 3: tests
Copy the Projects tests and adapt them:
| Test | File | What it proves |
|---|---|---|
| Service unit test | packages/app-server/src/modules/client/services/__tests__/client.service.spec.ts | the service's decisions, with a mocked repository |
| API tests | packages/app-server/test/integration/client/clients.integration-spec.ts | real 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 test | packages/app-front/src/pages/clients/__tests__/ClientsPage.test.tsx | the 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
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.tsshows 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.