grm-shared-library
v1.1.216
Published
Common code for GRM — shared types, enums, interfaces, DTOs and constants for the Clarion / Upesy backend NestJS microservices.
Readme
grm-shared-library
Common code for GRM — shared types, enums, interfaces, DTOs and constants for the Clarion / Upesy backend NestJS microservices.
Calendar dates
One definition of what a date is, because there used to be four and they had drifted apart.
import {
ISO_CALENDAR_DATE_PATTERN, // the one regex - never write another
isCalendarDate, // a real YYYY-MM-DD day; rejects 2026-02-30
toCalendarDate, // Date | string -> YYYY-MM-DD | undefined
isPlausibleBirthDate, // not future, within 120 years, NO minimum age
calculateAge, isMinor, // whole years, UTC throughout
AGE_OF_MAJORITY_YEARS, // 18 - for COPY ONLY, never a validator
IsCalendarDate, IsBirthDate // class-validator decorators over the above
} from 'grm-shared-library';Why it refuses things
A calendar date has no time, no zone and no offset: somebody born on 17 April 1990 was born on that day in Nairobi, in Kampala and in London alike. Every bug this module exists to prevent comes from treating it as a moment.
The old conversion did new Date(freeText) and then .toISOString(). Under the
production podsownTZ=Africa/Nairobi`:
| input | old result | |
|---|---|---|
| 04/17/1990 | 1990-04-16 | a day early |
| new Date(1990,3,17) | 1990-04-16 | a day early - and this is what a date picker hands you |
| 17/04/1990 | undefined | the East African spelling, refused |
| 1990-04-17 | 1990-04-17 | correct |
toCalendarDate now accepts exactly three things and refuses everything else:
YYYY-MM-DD- a full ISO-8601 instant in UTC (
1990-04-17T00:00:00.000Z) - a
Date, read in UTC - so build one withDate.UTC, nevernew Date(y,m,d)
An offset timestamp is refused too: 1990-04-17T00:00:00+03:00 is 16 April
in UTC, and only the sender knows which day they meant.
Free text is refused rather than guessed at. 04/17/1990 and 17/04/1990 are
the same eight characters in two orders, this platform serves Kenya, Uganda and
Tanzania where the second is the common spelling, and there is no reading of
that string that is safe to infer. A refused date surfaces as a field somebody
is asked to collect; a drifted one is a permanently wrong birthday at a partner
that cannot delete a member, on the record a paramedic uses to size a drug dose.
No minimum age
isPlausibleBirthDate refuses a future date and one beyond a human lifespan,
and imposes no lower bound. Dependants on a family emergency-cover plan are
children by design - that is the whole point of registering them with a
responder - so a minimum-age rule would refuse exactly the people the feature
exists for. AGE_OF_MAJORITY_YEARS is there for wording, not for gating.
A decorator alone guarantees nothing
@IsCalendarDate / @IsBirthDate are real validators, but on this platform
they are not enforcement on their own: clarion-authentication registers no
global ValidationPipe, the spreadsheet importer builds plain objects a pipe
never sees, and internal service-to-service calls bypass HTTP entirely. Any
service that OWNS a date must re-check it in the service layer, the way
UserService.updateCountry re-checks isSupportedCountryCode.
Postgres round trip
A type: date column returns a string through this stack, not a Date:
pg parses OID 1082 into a local-midnight Date and TypeORM reads it back with
local components, so the two cancel out. It is correct, but correct by
cancellation - never pass a value from a date column into new Date().
Country & Currency (multi-country foundation)
Platform-level primitives for country-aware emergency response, newsfeed, notifications, billing, maps and admin. This is the shared source of truth so services stop hard-coding Kenya-specific values.
Supported countries (ISO 3166-1 alpha-2):
| Code | Country | Calling code | Currency | Symbol | Decimals | Timezone | Locale |
|------|----------|--------------|----------|--------|----------|------------------------|---------|
| KE | Kenya | +254 | KES | KSh | 2 | Africa/Nairobi | en-KE |
| UG | Uganda | +256 | UGX | USh | 0 | Africa/Kampala | en-UG |
| TZ | Tanzania | +255 | TZS | TSh | 0 | Africa/Dar_es_Salaam | en-TZ |
Supported currencies (ISO 4217): KES, UGX, TZS.
Importing
Everything is re-exported from the package root:
import {
CountryCode,
CurrencyCode,
CallingCode,
SupportedCountryConfig,
SupportedCurrencyConfig,
SUPPORTED_COUNTRIES, // readonly SupportedCountryConfig[]
SUPPORTED_COUNTRIES_BY_CODE, // Record<CountryCode, SupportedCountryConfig>
SUPPORTED_CURRENCIES,
SUPPORTED_CURRENCIES_BY_CODE,
DEFAULT_COUNTRY_CODE, // CountryCode.KE
DEFAULT_CURRENCY_CODE, // CurrencyCode.KES
CountryScopeDto, // optional { countryCode?, currencyCode? } request scope
getSupportedCountryConfig,
getSupportedCurrencyConfig,
getCurrencyForCountry,
getCallingCodeForCountry,
getCountryByCallingCode,
isSupportedCountryCode,
isSupportedCurrencyCode,
} from 'grm-shared-library';
getCurrencyForCountry(CountryCode.UG); // CurrencyCode.UGX
getCallingCodeForCountry(CountryCode.TZ); // '+255'
isSupportedCountryCode('US'); // falseBackward compatibility
Kenya (KE / KES) is the default market. Wherever a country or currency is
not explicit, fall back to DEFAULT_COUNTRY_CODE / DEFAULT_CURRENCY_CODE /
DEFAULT_TIMEZONE / DEFAULT_LOCALE. Existing Kenyan users, packages, posts,
incidents, OTPs, notifications and billing flows are unaffected.
Scope
This package provides the country/currency primitives only. Country detection (GPS / IP / phone-number inference) and country-aware business logic (phone login, newsfeed filtering, multi-currency billing, SMS routing, map filtering) are delivered in later phases and should build on top of these types.
Tests
npm test # compiles src/modules/country and runs the country/currency suiteOrganization notification settings
Clarion is multi-tenant, so what a customer sees in a notification belongs to the
organization that serves them. modules/notification carries the contract for
that, shared by clarion-notification (owner), the gateway (proxy) and
clarion-authentication (emitter):
organizationIdis the key. The four auth event payloads (CreateOTPPayloadDto,CreateUserRegisteredPayloadDto,PasswordResetPayloadDto,AccountDeletionRequestPayloadDto) gained an optionalorganizationId, andEmailSendRequest/SmsSendRequestcarry one.appIdstays on every payload, but only as the fallback for an emitter that has not been upgraded. The notification service validates withwhitelist: true, so it must be on a version that declares the field or the field is stripped before anything reads it.- Branding resolves field by field -
NotificationBrandingSource: the organization's profile, then its record in clarion-organization, then the legacy per-app defaults, then the platform.UpsertOrganizationNotificationBrandingDtoturns""intonull, and null means inherit again, not blank. - Email senders are verified per provider (
EmailSenderProviderVerification). A provider only sends from an address it hasVERIFIED; otherwise that provider uses the platform sender and keeps the organization's display name and reply-to. - SMS senders are requested, then approved (
NotificationSenderStatus). OnlyACTIVEsenders are used; everything else falls back to the country-aware default.validateSmsSenderId(type, value)is the one definition of a valid sender. - Providers describe themselves (
SmsProviderDescriptor), like tracking providers do.SmsProviderNameonly reserves a code; the catalogue says what is usable. - Every send result records
senderandsenderSource(ORGANIZATION|PLATFORM_DEFAULT), so "why was this Upesy-branded?" is a query.
clarion-billing → clarion-notification (HTTP)
BillingEmailNotificationDto, BillingSmsNotificationDto, BillingPushNotificationDto
and their result types are the contract for POST /billing/notifications/email | sms |
push — the synchronous path clarion-billing uses when it wants to know a message left.
clarion-billing's NotificationClientService types its requests from these; do not
redeclare the fields there.
BillingEmailTemplateis a closed set. The route renders a template by name on another service's say-so, so an open string would let a caller render any template. A new value needs copy defaults in clarion-notification (api/billing).- Attachments go by reference (
documents[].signedUrl), because billing PDFs are private and their signed URLs expire within the hour.signedUrlmust behttps, file names must be bare names, and a subject may not contain a line break. - Every list and string is bounded;
billing-notification.spec.tspins the limits.
Values that end up inside email CSS or headers are validated as such: colours are
#RRGGBB only, logos are https only, and a sender display name may not contain a
line break. notification-sender.spec.ts pins each of those.
Roles and permissions
This package is the authority on both. clarion-authentication seeds them into its database on boot; every other service reads them from here.
| File | Holds |
|---|---|
| role/enums/role.enum.ts | Roles - the role names. |
| role/constants/roles-by-scope.ts | ORGANIZATION_ROLES / CONTROL_CENTRE_ROLES / MOBILE_ROLES - each role's scope. |
| role/data/roles.data.ts | ROLES_DATA - the permissions each role is seeded with. |
| permission/enums/permission-actions.enum.ts | PermissionActions - every action, as verb:resource. |
| permission/data/permissions.data.ts | PERMISSIONS_DATA - the catalogue role management shows, grouped by PermissionsModule. |
WHAT a role may do and WHERE it may do it are separate. Permissions answer the
first and are checked by the gateway's PermissionGuard (ANY-of). Scope answers the
second and comes only from which list in roles-by-scope.ts the role is in: the
gateway (AccessScopeService), clarion-authentication (AuthorizationService) and
clarion-socket (tracking rooms) all read those lists, and downstream services confine
a caller to the scope the gateway built. A role in no list gets no scope at all and
every tenant-scoped call it makes fails closed.
That is how one job exists at two scopes. Organization:Response-Units-Admin and
Control-Centre:Response-Units-Admin are seeded from the same permission list
(RESPONSE_UNITS_ADMIN_PERMISSIONS) - all of the Response Unit and Tracking
modules, plus read:control-centre and read:organization, which their own screens
call - and differ only in the list they are in.
The Tracking permission module
clarion-response-tracking's routes used to be guarded by the Response Unit actions.
Every mobile user holds read:response-unit, so every app user could read their
organization's tracking provider connections, and nobody could be given tracking
without being given the response units. They now have their own:
| Action | Covers |
|---|---|
| create / read / update / delete:tracking-provider-connection | The link to a tracking platform (Telox, Wialon). update also covers test, enable, disable, reconnect. |
| create / read / update / delete:tracking-device-binding | Which provider unit is which response unit. read also covers the unmapped-units worklist; update, resolving / reopening an entry on it. |
| read:tracking-position | Where units are now - the incident map and live tracking. |
| read:tracking-history | Where a unit has been. Separate on purpose: a movement history is a record of somebody's working day. |
| read:tracking-operations | The tracking operations dashboard. |
| manage:tracking-operations | Reconciling the tracking service's copy of the response units (the dry run included). |
Who holds them: Owner, Admin, Control-Centre:Admin and both Response-Units-Admins -
all twelve (everybody who could administer tracking before the split still can);
Organization:User - the five read actions; Control-Centre:User and Responder -
position and history; Mobile-User - none.
The Notification permission module
An organization's notification settings (clarion-notification) decide what its
customers see: how emails are branded, the address they come from, and the sender
ID its SMS arrive under. They have actions of their own rather than reusing
update:organization, because a wrong logo or sender goes out on every OTP and alert.
| Action | Covers |
|---|---|
| read:notification-settings | Reading the settings, the effective (resolved) branding and the SMS provider catalogue. |
| manage:notification-settings | Saving branding; adding, verifying and removing email senders; requesting SMS senders; sending a test email. |
| approve:notification-sender | Moving an SMS sender to ACTIVE / SUSPENDED / REJECTED. |
Owner and Admin hold the first two. No role holds approve:notification-sender -
only Super-Admin (*) passes it, and roles.spec.ts pins that. Activating a sender
asserts that it is registered with the provider and the telco; an organization able to
approve its own could send SMS under any name it typed.
Adding a role: every step fails silently on its own
Here - the
Rolesvalue, a scope list, and aROLES_DATAentry.roles.spec.tsfails if a scoped role is unseeded, a seeded role is not in the enum, or a role holds an action the catalogue does not list.The same spec holds the rule for actions: every
PermissionActionsvalue must be inPERMISSIONS_DATA, under exactly one module. An action outside the catalogue still works - the gateway matches on the string - but role management can neither show nor assign it, which is how the fourzoneactions went unnoticed for so long (they now have aZonemodule). Role and module names are also pinned to 50 characters, the width of the columns clarion-authentication stores them in.clarion-authentication -
users.rolesis a Postgres ENUM array, so the label needs anALTER TYPE "users_roles_enum" ADD VALUE IF NOT EXISTSmigration. Without it the role is seeded, offered by the admin, and rejected at the INSERT.Every service that reads the role lists - clarion-api-gateway, clarion-authentication, clarion-socket - must INSTALL the new version (the lockfile decides, not the caret) and redeploy. The gateway also validates
roleswith@IsEnum(Roles).clarion-socket names the organization roles admitted to tracking rooms one by one; an organization role that should see the fleet has to be added there.
clarion-admin shows menu entries by role NAME (
requiresAnyRoleinsidebar-data.ts), fromclarion-shared-types' mirror ofRoles.
Deploy clarion-authentication first and let it reseed. The gateway caches role permissions for five minutes, so a gateway that starts REQUIRING a new action should go out after that window - or the roles that were just granted it are refused until their cache entry expires.
