PSA/docs/AI_coding_standards.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

36 KiB

Note to AI editor Claude / GPT-4 / O1 / etc

  • If you need to see any additional files before you are sure you have enough context, ask the user to provide the file to the context before continuing.
  • If you would like to search for the contents to files, offer to use the run command and grep command to search for the contents.
  • Do not proceed to updating files until you have enough context to do so.
  • When working in the billing domain, prefer the renamed terminology (contract lines, contracts) and alias any remaining helper imports that include plan/bundle to the new schema names in your edits.
  • Default invoice template selections must flow through the invoice_template_assignments table. Do not add new usages of invoice_templates.is_default or companies.invoice_template_id; those fields are legacy-only until they are removed.

Failure Handling Philosophy

  • Fail fast when assumptions are violated instead of silently attempting fallbacks.
  • Throw exceptions with actionable, descriptive messages to surface what went wrong.
  • Validate assumptions as early as possible and reject inputs that do not meet strict criteria.

UI coding standards

Prefer radix components over other libraries

UI Components

IMPORTANT: All interactive elements (buttons, inputs, selects, etc.) MUST have unique id attributes for the reflection UI system. See Component ID Guidelines section for naming conventions.

Theme & Dark Mode

For full theming guidelines, CSS variable usage, and provider architecture, see Theming Documentation.

Key rules:

  • Use CSS variable tokens (rgb(var(--color-text-700)), bg-primary-50) — never hardcode hex/rgb values
  • Use useAppTheme from @alga-psa/ui/hooks — not useTheme from next-themes directly
  • Adapt dynamic entity colors with adaptColorsForDarkMode() from @alga-psa/ui/lib/colorUtils
  • Test all UI changes in both light and dark themes

Container backgrounds — always use CSS variables:

// Good — adapts to dark mode via globals.css
<div className="bg-[rgb(var(--color-card))]">
<div className="bg-[rgb(var(--color-background))]">

// Bad — stays white in dark mode, text becomes invisible
<div className="bg-white">
<div className="bg-gray-50">

Selection/highlight states — always add dark: variants:

// Good — readable in both themes
<button className={selected
  ? 'bg-blue-50 border-blue-500 dark:bg-blue-500/20 dark:border-blue-400'
  : 'border-[rgb(var(--color-border-200))]'
}>

// Bad — light bg becomes unreadable on dark background
<button className={selected ? 'bg-blue-50 border-blue-500' : ''}>

Semantic status colors — use the theme-aware CSS variable system:

// Good — uses badge/status CSS variables that adapt per theme
'border-[rgb(var(--badge-success-border))] bg-[rgb(var(--badge-success-bg))] text-[rgb(var(--badge-success-text))]'

// Good — opacity approach for translucent tints on dark backgrounds
'dark:bg-[rgb(var(--color-primary-400)/0.30)]'

// Bad — hardcoded colors that don't adapt
'bg-green-100 text-green-800'
'bg-red-50 text-red-600'

Common mistakes to avoid:

  • bg-white on cards/panels — use bg-[rgb(var(--color-card))]
  • bg-blue-50, bg-purple-100, etc. without dark: variant — add dark:bg-blue-500/20 or similar
  • text-gray-700 on elements that may appear on dark backgrounds — use text-[rgb(var(--color-text-700))]
  • Hardcoded status colors (text-red-600, text-green-600) — use CSS variable tokens or add dark: variants (dark:text-red-400, dark:text-green-400)

Loading States for Remote Content

  • When embedding remote experiences (extension iframes, external dashboards, etc.), always surface a branded loading state until the surface reports it is ready.
  • Wrap the remote surface in a relative container and gate its visibility with an isLoading flag driven by the onLoad/onError lifecycle events.
  • Reuse the shared overlay styles defined in server/src/app/globals.css (extension-loading-overlay, extension-loading-indicator, extension-loading-text, extension-loading-subtext) to maintain consistent visuals.
  • Use the LoadingIndicator component with layout="stacked" for the primary status message and reserve the subtext paragraph for short explanations (<40 characters) so the layout stays balanced.
  • Example pattern:
    <div className="relative h-full" aria-busy={isLoading}>
      {isLoading && (
        <div className="extension-loading-overlay" role="status">
          <LoadingIndicator
            layout="stacked"
            className="extension-loading-indicator"
            text="Starting extension"
            textClassName="extension-loading-text"
            spinnerProps={{ size: 'sm', color: 'border-primary-400' }}
          />
          <p className="extension-loading-subtext">Connecting to the runtime workspace&hellip;</p>
        </div>
      )}
    
      <iframe
        onLoad={() => setIsLoading(false)}
        onError={() => {
          setHasError(true);
          setIsLoading(false);
        }}
        className={isLoading ? 'opacity-0' : 'opacity-100'}
      />
    </div>
    

Dialog Component Usage

When implementing dialogs in the application, follow these guidelines:

  1. Use Custom Dialog Component

    • Always use the custom Dialog component from '@alga-psa/ui/components/Dialog'
    • Never import Dialog directly from '@radix-ui/react-dialog'
    // Good
    import { Dialog, DialogContent } from '@alga-psa/ui/components/Dialog';
    
    // Bad
    import * as Dialog from '@radix-ui/react-dialog';
    
  2. Dialog Structure — sticky footer pattern

    Action buttons (Save/Cancel/Delete/Next/Back/etc.) belong in the footer prop, NOT inside DialogContent. The Dialog renders footer in a sticky border-t strip below the scrollable body, so buttons stay pinned when the body is tall.

    const footer = (
      <div className="flex justify-end space-x-2">
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button
          type="button"
          onClick={() => (document.getElementById('my-form') as HTMLFormElement | null)?.requestSubmit()}
        >Save</Button>
      </div>
    );
    
    return (
      <Dialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="Dialog Title"
        className="max-w-lg"
        footer={footer}
      >
        <DialogContent>
          <form id="my-form" onSubmit={handleSubmit}>
            {/* fields */}
          </form>
        </DialogContent>
      </Dialog>
    );
    

    Critical: Because footer is rendered OUTSIDE the <form>, a type="submit" button in the footer will not trigger the form's onSubmit. Give the form a unique id and switch the Save button to type="button" + onClick={() => form.requestSubmit()}. requestSubmit() still runs native HTML validation and fires onSubmit. Enter-in-field still submits because the form's own submit event handles that.

    For dialogs without a form (e.g. confirmation dialogs, pickers), put plain onClick handlers on the footer buttons.

  3. DialogFooter is deprecated

    • Do not use DialogFooter in new code — it places buttons inside the scrollable body. Use the footer prop.
    • Existing DialogFooter usages should be migrated when touched.
  4. Wizards and multi-step dialogs

    • Put Next/Back/Finish/Skip buttons in the footer prop.
    • Make footer a computed value (or undefined) so it changes per step: e.g. footer={step === 'upload' ? undefined : stepFooter}.
  5. Props and Features

    • isOpen: Boolean to control dialog visibility
    • onClose: Callback function when dialog should close
    • title: Dialog title shown in the draggable header
    • className: Use responsive Tailwind classes (max-w-sm, max-w-md, max-w-lg, max-w-xl, max-w-2xl)
    • footer: ReactNode rendered in the sticky footer strip
    • draggable: Defaults to true, set to false to disable dragging
    • hideCloseButton: Set to true to hide the X close button
    • allowOverflow: Set to true for dialogs that host dropdowns/popovers
  6. Width Guidelines

    • Use responsive max-width classes instead of fixed pixel widths
    • Common sizes:
      • max-w-sm (384px) - Very small dialogs
      • max-w-md (448px) - Small dialogs (confirmations, simple forms)
      • max-w-lg (512px) - Medium dialogs (standard forms)
      • max-w-xl (576px) - Large dialogs (complex forms)
      • max-w-2xl (672px) - Extra large dialogs (multi-section forms)
  7. Spacing and Padding

    • The Dialog body has built-in padding (px-6 pt-3 pb-6) and handles scrolling itself (flex-1 min-h-0 overflow-y-auto). Do NOT add max-h-[80vh] or overflow-y-auto hacks to DialogContent — they fight the flex layout and break footer stickiness.
    • For forms with focus rings, add mt-2 to the first form element container to prevent cut-off.
  8. Handling Close Events

    • The Dialog's onClose is called with boolean false when the X button is clicked
    • Handle both MouseEvent and boolean types if needed:
    const handleClose = (e?: React.MouseEvent | boolean) => {
      if (typeof e === 'boolean' && !e) {
        // Handle close from Dialog's X button
      }
      // Your close logic
    };
    
  9. Confirmation Dialogs

    • For simple confirmations, use the ConfirmationDialog component
    • For custom confirmations with unsaved changes:
    const hasChanges = () => {
      // Only return true if user has actually entered data
      return formField.trim() !== '' || otherField !== initialValue;
    };
    

DataTable Action Menus

When implementing action menus in DataTable components, follow these guidelines:

  1. Component Structure

    • Use Radix UI's DropdownMenu components from '@alga-psa/ui/components/DropdownMenu':
      import {
        DropdownMenu,
        DropdownMenuTrigger,
        DropdownMenuContent,
        DropdownMenuItem,
      } from '@alga-psa/ui/components/DropdownMenu';
      
  2. Trigger Button Implementation

    • Use the Button component from '@alga-psa/ui/components/Button'
    • Import MoreVertical icon from 'lucide-react'
    <DropdownMenuTrigger asChild>
      <Button
        id="contract-line-actions-menu"  // Follow pattern: {object}-actions-menu
        variant="ghost"
        className="h-8 w-8 p-0"
        onClick={(e) => e.stopPropagation()}
      >
        <span className="sr-only">Open menu</span>
        <MoreVertical className="h-4 w-4" />
      </Button>
    </DropdownMenuTrigger>
    
  3. ID Naming Convention Follow the component ID guidelines with these specific patterns:

    • Menu trigger: {object}-actions-menu
    • Menu items: {action}-{object}-menu-item Example:
    <Button id="contract-line-actions-menu">
    <DropdownMenuItem id="edit-contract-line-menu-item">
    
  4. Event Handling

    • Always use stopPropagation() to prevent row selection when clicking menu items
    • Handle async operations with proper error management
    onClick={(e) => {
      e.stopPropagation();
      handleAction();
    }}
    
  5. Styling Guidelines

    • Use theme-aware styling for destructive actions:
      // For destructive actions (delete, remove)
      <DropdownMenuItem
        className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
      >
        Delete
      </DropdownMenuItem>
      
    • Position dropdown content:
      <DropdownMenuContent align="end">
      
  6. Menu Content Organization

    • Order items by frequency of use
    • Place destructive actions last
    • Use clear, concise action names Example structure:
    <DropdownMenuContent align="end">
      <DropdownMenuItem>Edit</DropdownMenuItem>
      <DropdownMenuItem className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400">
        Delete
      </DropdownMenuItem>
    </DropdownMenuContent>
    
  7. Accessibility

    • Include sr-only text for screen readers
    • Ensure keyboard navigation works properly
    • Maintain focus states for all interactive elements

Lucide icons can (and should) be used from the lucide package.

Server Action Authentication

Recommended Pattern: Use the withAuth wrapper from @alga-psa/auth for all server actions that need authentication:

import { withAuth, hasPermission } from '@alga-psa/auth';
import { createTenantKnex } from '@alga-psa/db';

export const myAction = withAuth(async (user, { tenant }, arg1: string): Promise<Result> => {
  const { knex } = await createTenantKnex();

  if (!await hasPermission(user, 'resource', 'action')) {
    throw new Error('Permission denied');
  }

  return knex('table').where({ tenant }).select('*');
});

Why withAuth?

  • Sets tenant context via AsyncLocalStorage (works with Turbopack)
  • Handles authentication checks consistently
  • Provides typed user (IUserWithRoles) and tenant context
  • Eliminates 15-20 lines of boilerplate per action

Available wrappers:

  • withAuth(action) - Requires authentication, throws if not authenticated
  • withOptionalAuth(action) - Allows unauthenticated access (user/ctx may be null)
  • withAuthCheck(action) - Auth check only, no tenant context (for non-DB actions)

Legacy Pattern (avoid in new code):

// OLD PATTERN - do not use in new code
import { getCurrentUser } from '@alga-psa/users/actions';

export async function myAction(): Promise<Result> {
  const currentUser = await getCurrentUser();
  if (!currentUser) throw new Error('Not authenticated');
  if (!currentUser.tenant) throw new Error('No tenant');
  const { knex, tenant } = await createTenantKnex(currentUser.tenant);
  // ... more boilerplate validation ...
}

Server Communication

We use server actions that are located in the /server/src/lib/actions folder and package-specific actions in packages/*/src/actions/.

Package Build System

The monorepo uses a hybrid build strategy. Some @alga-psa/* packages are pre-built (webpack resolves from dist/), others are source-transpiled (webpack compiles from src/). See Package Build System for full details.

Key rules for AI editors:

  • Do NOT change tsconfig paths — TypeScript always resolves types from src/ regardless of build mode.
  • npm run dev automatically builds all pre-built packages via npx nx build-deps server before starting the dev server. No manual rebuild needed after pulling.
  • After editing source in a pre-built package during a running dev session, rebuild it: cd packages/<pkg> && npx tsup (HMR only applies to source-transpiled packages).
  • When creating a new tsup config, use the shared preset at packages/build-tools/tsup-preset.ts:
    import { defineConfig } from 'tsup';
    import { makeConfig } from '../build-tools/tsup-preset';
    export default defineConfig(makeConfig({ jsxEnabled: true }));
    
  • Pre-built packages (resolve from dist/): types, core, validation, event-schemas, clients, sla, assets, tags.
  • Source-transpiled packages (resolve from src/): formatting, ui, billing, tickets, projects, scheduling, documents, auth, integrations, notifications, users, and composition layers.

ee folder

The ee folder contains the server code for the enterprise edition of the application. It is a parallel structure containing its own migrations that are overlaid on top of the base server migrations. ee specific database changes should be made in the migrations in the ee folder.

Database

server migrations are stored in the /server/migrations folder. seeds are stored in the /server/seeds folder. information about the database can be found in the /server/src/lib/db folder.

Migrations and seeds are using the Knex.js library.

Always use commands like "cd server && npx knex migrate:make --knexfile knexfile.cjs --env migration" to create a new migration. Do the same for seeds.

The knexfile is located in the /server/knexfile.cjs file and is used to configure the database connection.

Use createTenantKnex() from the /server/src/lib/db/index.ts file to create a database connection and return the tenant as a string.

Correct Usage Pattern:

// CORRECT: Destructure both knex and tenant
const { knex, tenant } = await createTenantKnex();

// Example query
const documents = await knex('documents')
  .where('tenant', tenant)
  .select('*');

Transaction Pattern:

import { withTransaction } from '@alga-psa/db';

// CORRECT: Pass knex as first parameter
const { knex } = await createTenantKnex();

await withTransaction(knex, async (trx) => {
  // Use trx for all operations within the transaction
  await trx('documents').insert({...});
  await trx('document_associations').insert({...});
});

Common Mistakes:

// ❌ WRONG: Missing destructuring
const knex = await createTenantKnex();

// ❌ WRONG: Missing knex parameter
await withTransaction(async (trx) => {...});

// ❌ WRONG: Don't use getConnection()
const knex = await getConnection();

Migrations should have a .cjs extension and should be located in the /server/migrations folder.

Run migrations with the migration environment (env) flag.

Every query should filter on the tenant column (including joins) to ensure compatibility with citusdb.

Local EE migrations

  • Do not physically copy EE migrations into server/migrations/ locally.
  • Use the temp-dir overlay runner which points Knex at a merged directory via MIGRATIONS_DIR.
  • Commands:
    • From repo root: npm -w server run migrate:ee
    • From server/: npm run migrate:ee
  • Details and rollback guidance: see docs/migrations/local-ee-migrations.md.

JSON/JSONB Column Handling with Knex

When working with PostgreSQL JSON and JSONB columns in Knex.js, follow these guidelines:

  1. JSONB Column Behavior

    • PostgreSQL JSONB columns automatically serialize/deserialize JSON data
    • Knex automatically handles the conversion between JavaScript objects/arrays and JSON strings
    • When you store data in a JSONB column, PostgreSQL converts it to binary JSON format
    • When you retrieve data from a JSONB column, PostgreSQL returns it as parsed JavaScript objects/arrays
  2. Storage Pattern

    // Store arrays/objects as JSON strings for JSONB columns
    await knex('table_name')
      .insert({
        json_column: JSON.stringify(arrayOrObject)
      });
    
  3. Retrieval Pattern

    // JSONB columns are automatically parsed - no need to JSON.parse()
    const result = await knex('table_name')
      .select('json_column')
      .first();
    
    // result.json_column is already a JavaScript object/array
    const parsedData = result.json_column || [];  // Use directly
    
  4. Common Mistake to Avoid

    // WRONG - Don't JSON.parse() data from JSONB columns
    const data = JSON.parse(result.json_column);  // This will fail!
    
    // CORRECT - JSONB data is already parsed
    const data = result.json_column || [];
    
  5. Complete Example

    // Storing an array in JSONB
    const labelFilters = ['INBOX', 'SENT'];
    await knex('google_email_provider_config')
      .insert({
        label_filters: JSON.stringify(labelFilters)  // Store as JSON string
      });
    
    // Retrieving from JSONB
    const config = await knex('google_email_provider_config')
      .select('label_filters')
      .first();
    
    // Use directly - already parsed by PostgreSQL/Knex
    const filters = config.label_filters || [];  // No JSON.parse() needed
    
  6. Error Symptoms

    • If you see SyntaxError: Unexpected token when calling JSON.parse() on JSONB data, you're trying to parse already-parsed data
    • If you see invalid input syntax for type json when inserting, you may be passing objects instead of JSON strings
  7. Migration Pattern

    -- Define JSONB column with default
    table.jsonb('json_column').defaultTo('[]');
    

CitusDB Compatibility

  1. CitusDB UPDATE Restrictions

    • CitusDB does not allow column references with any functions (even type casts) in UPDATE queries
    • This includes IMMUTABLE functions and type casts
    • Solution: Select values first, then update with parameterized queries Example:
    // Bad - Will fail in CitusDB
    await knex.raw(`
      UPDATE table_name 
      SET new_date = old_date::date
      WHERE id = 1
    `);
    
    // Good - Select and update separately
    const records = await knex('table_name')
      .select('id', 'old_date', 'tenant')
      .where(...);
    
    for (const record of records) {
      await knex('table_name')
        .where('id', record.id)
        .andWhere('tenant', record.tenant)p
        .update({
          new_date: knex.raw('?::date', [record.old_date])
        });
    }
    
  2. Date/Time Handling in CitusDB

    • Always use parameterized values for type casting
    • Include tenant in WHERE clauses for updates
    • Handle NULL values with separate updates Example:
    // First get the records
    const records = await knex('table_name')
      .select('id', 'date_column', 'tenant')
      .whereNotNull('date_column');
    
    // Then update with parameterized values
    for (const record of records) {
      await knex('table_name')
        .where('id', record.id)
        .andWhere('tenant', record.tenant)
        .update({
          new_date: knex.raw('?::date', [record.date_column])
        });
    }
    
  3. Tenant Column Requirements

    • Always include tenant column in WHERE clauses
    • Include tenant in JOIN conditions
    • Add tenant to unique constraints and indexes Example:
    CREATE UNIQUE INDEX my_unique_index
    ON my_table (tenant, column1, column2);
    

    Tenant Column in New Tables:

    • Always name the column tenant (not tenant_id)
    • Always use UUID data type for tenant columns
    • IMPORTANT: Always include tenant in the primary key for all tables
    • Set NOT NULL constraint on tenant columns
    • Add foreign key reference to tenants table when appropriate

    Example for creating a new table with proper tenant column:

    -- Create table with tenant column
    CREATE TABLE my_new_table (
      entry_id uuid NOT NULL,
      tenant uuid NOT NULL,
      -- other columns
      CONSTRAINT my_new_table_pkey PRIMARY KEY (entry_id, tenant),
      CONSTRAINT my_new_table_tenant_foreign FOREIGN KEY (tenant) 
      REFERENCES tenants(tenant)
    );
    

    For existing tables that need tenant in primary key:

    -- Modify existing table to include tenant in primary key
    ALTER TABLE existing_table DROP CONSTRAINT existing_table_pkey;
    ALTER TABLE existing_table ADD CONSTRAINT existing_table_pkey 
    PRIMARY KEY (id, tenant);
    
  4. Tenant Context in Distributed Queries

    • Connection-specific tenant context (app.current_tenant) does not propagate to all shards
    • Queries without shard key (tenant) are broadcast to all shards
    • Each shard connection needs its own tenant context
    • Security policies checking app.current_tenant will fail on shards without context Example of potential issues:
    // This could fail if broadcast to all shards
    const results = await knex('some_table')
      .select('*')
      .where('some_column', 'value');
    
    // Always include tenant to avoid broadcast
    const results = await knex('some_table')
      .select('*')
      .where('tenant', currentTenant)
      .andWhere('some_column', 'value');
    
  5. GUID Handling in CitusDB

    • Use UUIDs for GUIDs
    • Use gen_random_uuid() function for generating new UUIDs Example:
      INSERT INTO my_table (id, tenant, ...)
      VALUES (gen_random_uuid(), 'tenant_value', ...);
    
  6. Schema Changes on Distributed Tables

    • Standard ALTER TABLE commands from the coordinator may fail even when data is valid
    • Use run_command_on_shards() to apply schema changes directly to each shard
    • After applying to shards, you must also sync the coordinator's catalog metadata
    • This is especially important for NOT NULL constraints

    Example workflow for adding NOT NULL constraint:

    -- Step 1: Verify and update any NULL values on each shard
    SELECT run_command_on_shards('table_name',
      'UPDATE %s SET column = ''default_value'' WHERE column IS NULL');
    
    -- Step 2: Check for remaining NULLs
    SELECT run_command_on_shards('table_name',
      'SELECT COUNT(*) FROM %s WHERE column IS NULL');
    
    -- Step 3: Apply NOT NULL constraint on each shard
    SELECT run_command_on_shards('table_name',
      'ALTER TABLE %s ALTER COLUMN column SET NOT NULL');
    
    -- Step 4: Sync the coordinator's system catalog
    -- This is CRITICAL - without this, the coordinator still thinks the column is nullable
    UPDATE pg_attribute
    SET attnotnull = true
    WHERE attrelid = 'table_name'::regclass
      AND attname = 'column';
    
    -- Step 5: Now standard ALTER TABLE commands work from the coordinator
    ALTER TABLE table_name
    ALTER COLUMN column SET NOT NULL,
    ALTER COLUMN column SET DEFAULT 'default_value';
    

    Important notes:

    • The %s placeholder in run_command_on_shards() is automatically replaced with each shard table name (e.g., table_name_111544)
    • The pg_attribute update in Step 4 is essential to sync coordinator metadata with shard state
    • Without Step 4, ALTER TABLE from the coordinator will continue to fail with "contains null values"
    • After completing Steps 3 and 4, standard DDL commands can be used normally

Foreign Key Constraints

  • Foreign keys from reference tables to distributed tables are not supported.
  • ON DELETE SET NULL is not supported and should be handled at the application level.

Tenants

We use row level security and store the tenant in the tenants table. Most tables require the tenant to be specified in the tenant column when inserting.

Dates and times in the database:

Dates and times should use the ISO8601String type in the types.d.tsx file. In the database, we should use the postgres timestamp type.

Date Handling Standards

  1. Use Centralized Date Utilities

    • Always use toPlainDate from server/src/lib/utils/dateTimeUtils for date conversions
    • Never use Temporal.PlainDate.from directly in components
    • Example:
      // Good
      import { toPlainDate } from 'server/src/lib/utils/dateTimeUtils';
      const date = toPlainDate(someDate);
      
      // Bad
      import { Temporal } from '@js-temporal/polyfill';
      const date = Temporal.PlainDate.from(someDate);
      
  2. Date Type Handling

    • Use ISO8601String type for dates in interfaces and API responses
    • Keep Temporal.PlainDate objects for internal state when date arithmetic is needed
    • Convert to strings when sending to API or database Example:
    // Component state
    const [startDate, setStartDate] = useState<Temporal.PlainDate | null>(null);
    
    // API call
    await createPeriod({
      start_date: startDate?.toString() || '',
      end_date: endDate?.toString() || ''
    });
    
  3. Date Comparisons

    • Use Temporal.PlainDate.compare for date comparisons
    • Ensure dates are in the correct format before comparison Example:
    if (Temporal.PlainDate.compare(startDate, endDate) >= 0) {
      setError('Start date must be before end date');
    }
    
  4. Date Display

    • Use toLocaleString() for displaying dates to users
    • Format dates consistently across the application Example:
    render: (date: ISO8601String) => toPlainDate(date).toLocaleString()
    

Internationalization (i18n)

The application uses react-i18next for internationalization. Both the client portal and MSP portal are internationalized with feature-based namespace splitting and lazy loading.

See docs/architecture/i18n.md for the full architecture guide.

Configuration:

  • Supported locales: en, fr, es, de, nl, it, pl (+ xx, yy pseudo-locales for QA)
  • 27 namespace files per language, ~9,959 keys total
  • Translation files: server/public/locales/{locale}/{namespace}.json
  • Central config: packages/core/src/lib/i18n/config.ts
  • Feature flag: msp-i18n-enabled gates MSP translation rollout

Namespace Usage Guidelines

Use common namespace for:

  • Shared components used across the entire app (both portals)
  • Generic UI elements (buttons, forms, dialogs, status labels, validation)

Use client-portal namespace for:

  • Client portal UI chrome (nav, dashboard, auth, profile)

Use features/* namespaces for:

  • Feature areas shared by both portals (no duplication)
  • features/tickets, features/projects, features/billing, features/documents, features/appointments

Use msp/core for:

  • MSP portal shell (nav, sidebar, header) — loads on every MSP route

Use msp/<feature> for:

  • MSP-specific feature pages (settings, clients, assets, time-entry, etc.)
  • Each namespace loads only on its relevant route(s)
'use client';

import { useTranslation } from '@alga-psa/ui/lib/i18n/client';

// MSP feature component
export function TimeEntryForm() {
  const { t } = useTranslation('msp/time-entry');

  return (
    <div>
      <h1>{t('page.title')}</h1>
      <button>{t('actions.save')}</button>
    </div>
  );
}

// Shared feature component (used by both portals)
export function TicketList() {
  const { t } = useTranslation('features/tickets');

  return (
    <div>
      <h1>{t('list.title')}</h1>
      <button>{t('actions.create')}</button>
    </div>
  );
}

// Shared component used everywhere
export function ClientLocations({ clientId }: Props) {
  const { t } = useTranslation('common');

  return (
    <div>
      <h3>{t('clients.locations.listTitle')}</h3>
      <Button>{t('clients.locations.buttons.add')}</Button>
    </div>
  );
}

Basic Usage Patterns

With interpolation:

const { t } = useTranslation('features/tickets');
<p>{t('pagination.showing', { from: 1, to: 10, total: 100 })}</p>

With fallback values:

<span>{t('tickets.messages.error', 'An error occurred')}</span>

Formatting utilities (locale-aware):

import { useFormatters } from '@alga-psa/ui/lib/i18n/client';

const { formatDate, formatNumber, formatCurrency, formatRelativeTime } = useFormatters();
<p>{formatCurrency(99.99, 'USD')}</p>
<p>{formatDate(entry.date, { month: 'short', day: 'numeric' })}</p>
<p>{formatRelativeTime(comment.created_at)}</p>

Best Practices

  1. All user-facing text must use translation keys — both client portal and MSP portal
  2. Choose the correct namespace — see the guide above or ROUTE_NAMESPACES in packages/core/src/lib/i18n/config.ts
  3. Never hardcode user-facing text:
    // Bad
    <button>Save Location</button>
    
    // Good
    <button>{t('clients.locations.buttons.save')}</button>
    
  4. Use hierarchical keys: tickets.messages.loadingError not ticketsLoadingError
  5. Use useFormatters() instead of hardcoded date/number/currency formatting
  6. Use interpolation, not string concatenation: {{variable}} in translation strings
  7. Test with pseudo-locale xx to catch missed extractions (all strings should show 11111)
  8. Run validation after adding keys: node scripts/validate-translations.cjs
  9. Register routes in ROUTE_NAMESPACES when adding new pages that use translations

Testing Standards

All tests should follow the conventions outlined in docs/testing-standards.md.

Quick Reference:

  • Unit tests: server/src/test/unit/ - Isolated tests with mocked dependencies
  • Integration tests: server/src/test/integration/ - Multi-component tests with real database
  • Infrastructure tests: server/src/test/infrastructure/ - Complete business workflows
  • E2E tests: server/src/test/e2e/ - API endpoints and full user flows

Naming conventions:

  • Unit: <feature>.test.ts or <ComponentName>.test.tsx
  • Integration: <feature>Integration.test.ts
  • Infrastructure: <feature>.test.ts or <feature>_<aspect>.test.ts (when split)
  • E2E: <feature>.e2e.test.ts

Key principles:

  • Tests are centralized in server/src/test/, not colocated with source code
  • Mirror source structure using subdirectories within test directories
  • Split large test suites by concern using underscore notation (e.g., billing_tax.test.ts)
  • Use TestContext helpers for infrastructure tests
  • Use setupE2ETestEnvironment() for E2E tests

See the full Testing Standards document for complete guidelines, templates, and the decision tree for test placement.

Time Entry Work Item Types

They can be:

  • Ticket
  • Project task

There is a work_item_type column in the time_entries table that can be used to determine the type of work item. There is also a work_item_id column that can be used to reference the work item.

You will need to join against either the tickets or project_tasks table to get the details of the work item, including the company_id.

Component ID Guidelines (from the UI reflection system)

  1. Use Kebab Case (Dashes, Not Underscores)

    • Hard Rule: Always use this-style-of-id rather than this_style_of_id
    • Examples:
      • add-ticket-button
      • quick-add-ticket-dialog
      • my-form-field
  2. Make Each ID Uniquely Identifying

    • Each ID should uniquely identify a single UI element within its scope
    • Avoid short, ambiguous names like button1 or dialog2
    • Include both the type of element and its purpose
    • Good: add-employee-button
    • Bad: button1
  3. Keep IDs Human-Readable

    • IDs will be used in test scripts, automation harnesses, and debugging logs
    • A quick glance should communicate an element's function or meaning
    • Good: delete-user-dialog
    • Bad: dlg-du-1
  4. Avoid Encoding Variable Data

    • Do not include dynamic, user-generated content (like user IDs or timestamps)
    • Store variable data in another attribute (e.g., data-user-id="123")
    • Maintain variable data in the component's internal data
  5. Match UI Terminology

    • Keep IDs consistent with visible labels or component names
    • Example: If UI shows "Quick Add Ticket" dialog, use quick-add-ticket-dialog
  6. Keep It Short but Descriptive

    • Balance length and clarity
    • Prefer: submit-application-button
    • Avoid: submit-this-application-to-the-server-now-button
  7. Maintain Consistency

    • Use common patterns across the codebase
    • Apply same principles to all component types
    • Enable predictable ID patterns for automated tooling
  8. Example Patterns

    • Buttons: {action}-{object}-button
      • add-ticket-button
      • delete-user-button
      • save-form-button
    • Dialogs: {purpose}-{object}-dialog
      • quick-add-ticket-dialog
      • confirmation-dialog
      • edit-profile-dialog
    • Form Fields: {object}-{field}-field or {object}-input
      • ticket-title-field
      • ticket-description-field
      • user-email-input
    • Data Grids: {object}-grid or {object}-{purpose}-grid
      • tickets-grid
      • users-report-grid