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
| Concept | Description |
|---|---|
| Location | Physical site belonging to a business |
| Address | Linked postal/civic address (1-to-1 relationship) |
| Freeze / Unfreeze | Lifecycle states that block a location from transactional activity |
| Timezone | IANA timezone identifier for the location (default: America/Guatemala) |
| Google Place | Optional Google Places ID + geographic point for map integration |
| Tax Number | VAT / 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 tokenRolesGuard(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— timestampfrozenBy— UUID of the user who froze itfrozenReason— mandatory reason stringfrozenSessionId— 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
businessIdor a404 Not Foundis returned.
API Endpoints
Base path: /locations
| Method | Path | Description |
|---|---|---|
POST | /locations | Create a location (with address) |
GET | /locations | List locations (paginated, filterable by businessId) |
GET | /locations/:id | Get a location by ID (includes address) |
PATCH | /locations/:id | Update a location (address upserted automatically) |
PATCH | /locations/:id/freeze | Freeze a location |
PATCH | /locations/:id/unfreeze | Unfreeze a location |
DELETE | /locations/:id | Delete 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)
| Parameter | Type | Required | Description |
|---|---|---|---|
businessId | UUID | No | Filter by business |
search | string | No | Searches name, googlePlaceId, taxNumber, address.lineOne, address.lineTwo |
page | number | No | Page number (default: 1) |
size | number | No | Page size (0 = all) |
orderBy | string | No | Column: name, googlePlaceId, taxNumber |
order | asc|desc | No | Sort 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 + nestedCreateAddressDTOUpdateLocationDTO— extendsPartialType(CreateLocationDTO)(Swagger-aware)FreezeLocationDTO— reason + frozenBy + optional sessionIdUnfreezeLocationDTO— 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
-
Address created atomically with the location —
POST /locationsaccepts an inlineaddressobject; the service creates the address first, then links it. If address creation fails, the location is not created. No two-step API is exposed. -
Address upserted on update —
PATCH /locations/:idwill create a new address or update the existing one depending on whetheraddressIdis provided. Both fields are optional — omittingaddressleaves the existing address untouched. -
Hard delete —
DELETE /locations/:idperforms a hard delete. Soft-delete is handled at the business logic level (settingisActive = false) rather than row-level deletion for most use cases. -
sortableLocationKeysin the domain layer — The sortable column set is defined indomain/locations-repository.domain.tsso 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/
| File | Request |
|---|---|
list-locations.yml | List Locations |
create-location.yml | Create Location |
create-location-2.yml | Create Location (ZACAPA) |
create-location-3.yml | Create Location (TOTONICAPAN) |
get-location-by-id.yml | Get Location by ID |
update-location.yml | Update Location |
freeze-location.yml | Freeze Location |
unfreeze-location.yml | Unfreeze Location |
delete-location.yml | Delete Location |
Known Follow-ups
unfrozenByaudit field — The unfreeze operation does not record who unfroze the location. Adding aunfrozen_by+unfrozen_atcolumn to the schema would complete the audit trail symmetrically.- Kysely in domain port —
findFirstandfindManyacceptExpressionBuilder<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.