Adding a New Module
This guide walks through adding a new feature module to the POS system end-to-end. We will use a hypothetical "Promotions" module as the example.
1. Define Shared Types
Create or update types in packages/shared-types/src/:
// packages/shared-types/src/promotion.ts
export interface Promotion {
id: string;
name: string;
discountPercent: number; // basis points (e.g., 1000 = 10%)
startDate: string;
endDate: string;
isActive: boolean;
updatedAt: string;
syncVersion: number;
}Export from the package index:
// packages/shared-types/src/index.ts
export * from './promotion.js';2. Create Database Schema
Add a Drizzle table definition:
// apps/hq-server/src/db/schema/promotions.ts
import { pgTable, uuid, varchar, integer, boolean, timestamp } from 'drizzle-orm/pg-core';
export const promotions = pgTable('promotions', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
discountPercent: integer('discount_percent').notNull(), // basis points
startDate: timestamp('start_date', { withTimezone: true }).notNull(),
endDate: timestamp('end_date', { withTimezone: true }).notNull(),
isActive: boolean('is_active').notNull().default(true),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
syncVersion: integer('sync_version').notNull().default(1),
});Export from the schema index:
// apps/hq-server/src/db/schema/index.ts
export * from './promotions.js';3. Generate Migration
pnpm --filter hq-server exec node --import tsx node_modules/drizzle-kit/bin.cjs generateIf the store also needs this table:
pnpm --filter store-server exec node --import tsx node_modules/drizzle-kit/bin.cjs generate4. Register Permissions
Add permission definitions to the central registry:
// apps/hq-server/src/lib/permission-registry.ts
registerPermissions([
// ... existing permissions ...
// Promotions module
{ code: 'promotions.view', module: 'promotions', name: 'View Promotions', description: 'View promotion list' },
{ code: 'promotions.create', module: 'promotions', name: 'Create Promotions', description: 'Create new promotions' },
{ code: 'promotions.edit', module: 'promotions', name: 'Edit Promotions', description: 'Edit promotion details' },
{ code: 'promotions.delete', module: 'promotions', name: 'Delete Promotions', description: 'Delete promotions' },
]);After adding permissions, re-run the database seed to insert them into the permissions table.
5. Create API Route Module
// apps/hq-server/src/modules/promotions/routes.ts
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../../db/connection.js';
import { promotions } from '../../db/schema/promotions.js';
import { eq } from 'drizzle-orm';
import { requirePermission } from '../../lib/auth-guard.js';
const createPromotionSchema = z.object({
name: z.string().min(1),
discountPercent: z.number().int().min(0).max(10000), // 0-100% in basis points
startDate: z.string(),
endDate: z.string(),
});
export async function promotionRoutes(app: FastifyInstance) {
// All routes require authentication
app.addHook('onRequest', app.authenticate);
// List promotions
app.get('/', { preHandler: [requirePermission('promotions.view')] }, async () => {
const all = await db.select().from(promotions);
return { success: true, data: all };
});
// Create promotion
app.post('/', { preHandler: [requirePermission('promotions.create')] }, async (request) => {
const body = createPromotionSchema.parse(request.body);
const [promo] = await db.insert(promotions).values({
...body,
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
}).returning();
return { success: true, data: promo };
});
// Update promotion
app.put('/:id', { preHandler: [requirePermission('promotions.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string };
const body = createPromotionSchema.partial().parse(request.body);
const updates: Record<string, unknown> = { ...body, updatedAt: new Date() };
if (body.startDate) updates.startDate = new Date(body.startDate);
if (body.endDate) updates.endDate = new Date(body.endDate);
const [promo] = await db.update(promotions)
.set(updates)
.where(eq(promotions.id, id))
.returning();
if (!promo) return reply.status(404).send({ success: false, error: 'Not found' });
return { success: true, data: promo };
});
// Delete promotion
app.delete('/:id', { preHandler: [requirePermission('promotions.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string };
const [promo] = await db.update(promotions)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(promotions.id, id))
.returning();
if (!promo) return reply.status(404).send({ success: false, error: 'Not found' });
return { success: true, data: promo };
});
}6. Register Routes in app.ts
// apps/hq-server/src/app.ts
import { promotionRoutes } from './modules/promotions/routes.js';
// Inside buildApp():
await app.register(promotionRoutes, { prefix: '/api/v1/promotions' });7. Add Backend Adapter Commands
Add command-to-endpoint mappings in the web adapter:
// packages/backend-adapter/src/index.ts
// In commandToEndpoint():
'get_promotions': '/api/v1/promotions',
'get_promotion': '/api/v1/promotions',
'create_promotion': '/api/v1/promotions',
'update_promotion': '/api/v1/promotions',
'delete_promotion': '/api/v1/promotions',The commandToMethod function will automatically map get_* to GET, create_* to POST, update_* to PUT, and delete_* to DELETE.
8. Add i18n Keys
Add keys to both language files:
// packages/i18n/src/en.json
{
"promotions": {
"title": "Promotions",
"create": "Create Promotion",
"name": "Name",
"discount": "Discount",
"startDate": "Start Date",
"endDate": "End Date",
"noPromotions": "No promotions found"
}
}// packages/i18n/src/es.json
{
"promotions": {
"title": "Promociones",
"create": "Crear Promoci\u00f3n",
"name": "Nombre",
"discount": "Descuento",
"startDate": "Fecha de Inicio",
"endDate": "Fecha de Fin",
"noPromotions": "No se encontraron promociones"
}
}Both files must have matching keys. The i18n validation step in the deploy script will catch mismatches.
9. Create Vue View
<!-- apps/web-client/src/views/hq/PromotionsView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { getAdapter } from '@pos/backend-adapter';
import type { Promotion } from '@pos/shared-types';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';
const { t } = useI18n();
const promotions = ref<Promotion[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
const result = await getAdapter().execute<Promotion[]>('get_promotions');
if (result.success && result.data) {
promotions.value = result.data;
}
loading.value = false;
}
onMounted(load);
</script>
<template>
<div class="p-4">
<h1 class="text-2xl font-bold mb-4">{{ t('promotions.title') }}</h1>
<DataTable :value="promotions" :loading="loading">
<Column field="name" :header="t('promotions.name')" />
<Column field="discountPercent" :header="t('promotions.discount')">
<template #body="{ data }">
{{ (data.discountPercent / 100).toFixed(2) }}%
</template>
</Column>
</DataTable>
</div>
</template>10. Add Route
// apps/web-client/src/router.ts
// Inside hqRoutes children:
{
path: 'promotions',
name: 'hq-promotions',
component: () => import('./views/hq/PromotionsView.vue'),
meta: { permissions: ['promotions.view'] },
},11. Add Navigation Item
Add the link to the HQ layout sidebar:
// apps/web-client/src/layouts/HqLayout.vue
// In the navigation items array:
{ label: t('promotions.title'), icon: 'pi pi-tag', to: '/hq/promotions' },12. Write Tests
// apps/hq-server/src/__tests__/promotions.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildTestApp, getAuthToken } from './setup.js';
describe('Promotions Routes', () => {
let app: FastifyInstance;
let token: string;
beforeAll(async () => {
app = await buildTestApp();
token = await getAuthToken(app);
});
afterAll(async () => {
await app.close();
});
it('should create a promotion', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/v1/promotions',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Summer Sale',
discountPercent: 1500, // 15%
startDate: '2024-06-01T00:00:00Z',
endDate: '2024-08-31T23:59:59Z',
},
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.success).toBe(true);
expect(body.data.name).toBe('Summer Sale');
expect(body.data.discountPercent).toBe(1500);
});
it('should list promotions', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/v1/promotions',
headers: { authorization: `Bearer ${token}` },
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.success).toBe(true);
expect(body.data.length).toBeGreaterThan(0);
});
it('should return 401 without auth', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/v1/promotions',
});
expect(response.statusCode).toBe(401);
});
});13. Add to Sync (If Needed)
If the new entity needs to sync to stores:
- Add entity type to
PULL_ENTITIESinapps/store-server/src/sync/pull-service.ts - Add a case in
getTableForEntity()for the new type - Add the entity type to the HQ sync routes pull handler
- If stores create data locally, add to
sync_outboxand push handler
Checklist
- [ ] Shared types in
packages/shared-types/ - [ ] Database schema with Drizzle
- [ ] Migration generated
- [ ] Permissions registered in
permission-registry.ts - [ ] API routes with
authenticatehook andrequirePermission - [ ] Routes registered in
app.ts - [ ] Backend adapter command mappings
- [ ] i18n keys in both
en.jsonandes.json - [ ] Vue view using PrimeVue components
- [ ] Route added to
router.tswith permission meta - [ ] Navigation item in layout
- [ ] Tests covering CRUD, auth, and permissions
- [ ] Sync support (if applicable)