Skip to main content

Locations Module

Overview

A Location represents a physical business site (store, warehouse, branch office). Every location belongs to a Business and has one linked Address. Most transactional data in the system (sales, orders, cash register sessions, inventory) is scoped to a specific location.


Domain Concepts

ConceptDescription
LocationPhysical site belonging to a business
AddressLinked postal/civic address (1-to-1 relationship)
Freeze / UnfreezeLifecycle states that block a location from transactional activity
TimezoneIANA timezone identifier for the location (default: America/Guatemala)
Google PlaceOptional Google Places ID + geographic point for map integration
Tax NumberVAT / tax registration number for the site

Architecture

The module follows Hexagonal Architecture (Ports & Adapters):

interfaces/         ← HTTP layer (controller, DTOs, query objects)
application/ ← Use cases (LocationsService)
domain/ ← Port interface (ILocationsRepository) + constants
infrastructure/ ← Kysely adapter (LocationsRepository)

Dependency flow: interfaces → application → domain ← infrastructure

Authorization

The controller is protected by:

  • AuthGuard (global) — validates Firebase ID token
  • RolesGuard (class-level) — enforces RBAC/ABAC via CASL
  • @PermissionResource(PolicyResource.Location) (class-level) — declares the resource
  • @PermissionAction(PolicyAction.*) (method-level) — declares the required action per endpoint

Dependency Injection

The service depends on the ILocationsRepository port (symbol: LOCATIONS_REPOSITORY) rather than the concrete LocationsRepository. The module wires the implementation via useExisting:

{
provide: LOCATIONS_REPOSITORY,
useExisting: LocationsRepository,
}

This makes the service testable by swapping in a mock without touching NestJS module wiring.


Database Schema

location (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL REFERENCES business(id),
address_id UUID REFERENCES address(id),
name TEXT,
tax_number TEXT,
google_place_id TEXT,
google_place_point POINT,
contact JSONB,
timezone TEXT NOT NULL DEFAULT 'America/Guatemala',
available_to_sell BOOLEAN NOT NULL DEFAULT TRUE,
available_to_buy BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
frozen_at TIMESTAMPTZ,
frozen_by TEXT,
frozen_reason TEXT,
frozen_session_id UUID,
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT,
updated_at TIMESTAMPTZ
)

Kysely uses camelCase column access (businessId, frozenAt, etc.) due to the --camel-case codegen flag.


Freeze / Unfreeze Lifecycle

A location can be frozen to prevent it from being used for transactions (e.g., during an audit, at end of day, or following an incident).

ACTIVE ──freeze──▶ FROZEN
FROZEN ─unfreeze─▶ ACTIVE

Freeze records:

  • frozenAt — timestamp
  • frozenBy — UUID of the user who froze it
  • frozenReason — mandatory reason string
  • frozenSessionId — optional cash register session UUID

Unfreeze clears all four fields. At this time, no separate unfrozenBy audit field exists on the schema (tracked as a follow-up).

Business rules

  • Freezing an already-frozen location returns 409 Conflict.
  • Unfreezing a non-frozen location returns 409 Conflict.
  • Location must exist and belong to the given businessId or a 404 Not Found is returned.

API Endpoints

Base path: /locations

MethodPathDescription
POST/locationsCreate a location (with address)
GET/locationsList locations (paginated, filterable by businessId)
GET/locations/:idGet a location by ID (includes address)
PATCH/locations/:idUpdate a location (address upserted automatically)
PATCH/locations/:id/freezeFreeze a location
PATCH/locations/:id/unfreezeUnfreeze a location
DELETE/locations/:idDelete a location

All endpoints require Bearer authentication (flowpos-id-token cookie or Authorization header). All endpoints are guarded by RolesGuard with PermissionResource: Location.

Query parameters (GET /locations)

ParameterTypeRequiredDescription
businessIdUUIDNoFilter by business
searchstringNoSearches name, googlePlaceId, taxNumber, address.lineOne, address.lineTwo
pagenumberNoPage number (default: 1)
sizenumberNoPage size (0 = all)
orderBystringNoColumn: name, googlePlaceId, taxNumber
orderasc|descNoSort direction

Request / Response Examples

Create a location

POST /locations
Authorization: Bearer <token>
Content-Type: application/json

{
"name": "ANTIGUA STORE",
"businessId": "a1b2c3d4-...",
"createdBy": "user-uuid",
"taxNumber": "17195594",
"timezone": "America/Guatemala",
"availableToSell": true,
"availableToBuy": true,
"isActive": true,
"contact": { "phone": "+50212345678" },
"address": {
"lineOne": "5ta Calle",
"municipality": "Antigua Guatemala",
"department": "Sacatepéquez",
"countryId": "GT",
"businessId": "a1b2c3d4-...",
"createdBy": "user-uuid"
}
}

Freeze a location

PATCH /locations/{id}/freeze?businessId={businessId}
Authorization: Bearer <token>
Content-Type: application/json

{
"reason": "End of day closure",
"frozenBy": "user-uuid",
"sessionId": "session-uuid"
}

Unfreeze a location

PATCH /locations/{id}/unfreeze?businessId={businessId}
Authorization: Bearer <token>
Content-Type: application/json

{
"reason": "Reopening for business"
}

Swagger / OpenAPI

All DTOs include @ApiProperty / @ApiPropertyOptional decorators with examples and descriptions. The Swagger UI at /api-docs shows full request/response schemas for this module.

Key DTO classes:

  • CreateLocationDTO — full location + nested CreateAddressDTO
  • UpdateLocationDTO — extends PartialType(CreateLocationDTO) (Swagger-aware)
  • FreezeLocationDTO — reason + frozenBy + optional sessionId
  • UnfreezeLocationDTO — optional reason

Timezone Validation

Timezone values must be valid IANA identifiers (e.g., America/Guatemala, Europe/London). The shared validator lives in:

  • Global regex validator: packages/global/validators/timezone.validator.ts
  • DTO decorator: apps/backend/src/locations/interfaces/dtos/validators/timezone.validator.ts (@IsIANATimezone())

The default timezone is America/Guatemala (applied in the service if not provided).


Design Decisions

  1. Address created atomically with the locationPOST /locations accepts an inline address object; the service creates the address first, then links it. If address creation fails, the location is not created. No two-step API is exposed.

  2. Address upserted on updatePATCH /locations/:id will create a new address or update the existing one depending on whether addressId is provided. Both fields are optional — omitting address leaves the existing address untouched.

  3. Hard deleteDELETE /locations/:id performs a hard delete. Soft-delete is handled at the business logic level (setting isActive = false) rather than row-level deletion for most use cases.

  4. sortableLocationKeys in the domain layer — The sortable column set is defined in domain/locations-repository.domain.ts so that both the repository (infrastructure) and the query object (interfaces) can reference it without creating an upward dependency from infrastructure → interfaces.


Bruno API Collection

Located at: api-client/flowpos/collections/locations/

FileRequest
list-locations.ymlList Locations
create-location.ymlCreate Location
create-location-2.ymlCreate Location (ZACAPA)
create-location-3.ymlCreate Location (TOTONICAPAN)
get-location-by-id.ymlGet Location by ID
update-location.ymlUpdate Location
freeze-location.ymlFreeze Location
unfreeze-location.ymlUnfreeze Location
delete-location.ymlDelete Location

Known Follow-ups

  • unfrozenBy audit field — The unfreeze operation does not record who unfroze the location. Adding a unfrozen_by + unfrozen_at column to the schema would complete the audit trail symmetrically.
  • Kysely in domain portfindFirst and findMany accept ExpressionBuilder<DB, "location"> lambdas, which couples the domain port to Kysely. A future improvement would use a typed predicate type to decouple the domain from the query builder.