Skip to content

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/:

typescript
// 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:

typescript
// packages/shared-types/src/index.ts
export * from './promotion.js';

2. Create Database Schema

Add a Drizzle table definition:

typescript
// 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:

typescript
// apps/hq-server/src/db/schema/index.ts
export * from './promotions.js';

3. Generate Migration

bash
pnpm --filter hq-server exec node --import tsx node_modules/drizzle-kit/bin.cjs generate

If the store also needs this table:

bash
pnpm --filter store-server exec node --import tsx node_modules/drizzle-kit/bin.cjs generate

4. Register Permissions

Add permission definitions to the central registry:

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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:

json
// 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"
  }
}
json
// 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

vue
<!-- 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

typescript
// 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:

typescript
// 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

typescript
// 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:

  1. Add entity type to PULL_ENTITIES in apps/store-server/src/sync/pull-service.ts
  2. Add a case in getTableForEntity() for the new type
  3. Add the entity type to the HQ sync routes pull handler
  4. If stores create data locally, add to sync_outbox and push handler

Checklist

  • [ ] Shared types in packages/shared-types/
  • [ ] Database schema with Drizzle
  • [ ] Migration generated
  • [ ] Permissions registered in permission-registry.ts
  • [ ] API routes with authenticate hook and requirePermission
  • [ ] Routes registered in app.ts
  • [ ] Backend adapter command mappings
  • [ ] i18n keys in both en.json and es.json
  • [ ] Vue view using PrimeVue components
  • [ ] Route added to router.ts with permission meta
  • [ ] Navigation item in layout
  • [ ] Tests covering CRUD, auth, and permissions
  • [ ] Sync support (if applicable)