Skip to content

Commit 8e5667b

Browse files
JSONForms address field: country-filtered states, defaults, BC geocoder search (#42)
* feat(forms): add JSONForms address field with country-filtered states - New Address control (@repo/react) dispatched on options.format:'address': searchable country combobox filters the states/provinces dropdown; per-country state/postal labels (curated ISO2 map); postal field hidden when the country has no postal system; changing country clears province. Degrades to plain text inputs without a GeoDataProvider. Editable + read-only display renderers + shared model/normalize helpers. - GeoDataProvider hooks-port keeps the library API-agnostic; each web app supplies its own geo fetchers and mounts the provider once at the root. - Public geo API (/v1/geo/countries, /v1/geo/countries/:id/states) added to both BFFs, reading geo reference data; integer :id validated -> 400. - Form builder: 'address' palette entry + object-schema serialization. - Postal codes validated against the country's regex on submit (422); drafts remain unvalidated. Address stored inline in submission JSONB. * feat(forms): address field default country / state in the builder inspector - Inspector: an address field gains a geo-powered defaults editor (default country + default state/province, reusing the same country-filtered dropdowns and per-country labels as the field). Changing the default country clears the default province. - Model/codec: ControlNode carries defaultCountry/defaultProvince, serialized into the address property's JSON-Schema `default` and parsed back. - Renderer: the address control seeds itself from schema.default when the field is still empty, so citizens see the pre-filled values (they can change them). The write is deferred a macrotask (a handleChange during the mount commit is dropped before JsonForms finishes init) and the seed flag flips inside the timer so the seed survives StrictMode's double-invoke. * feat(forms): BC address geocoder autocomplete for the address field - New "Search for your address" typeahead under the Country selector, shown only when the selected country/province is a server-supported geocoder region (v1: Canada / British Columbia, matched by ISO codes). As-you-type it queries the region's geocoder and fills Address line 1 + City on select (the geocoder returns no postal code, so postal stays manual). - ISO-scoped, extensible geocoder proxy on both BFFs: GET /v1/geo/address-search and /address-search/regions (@public). A provider registry keyed by "<COUNTRY>:<PROVINCE>" (CA:BC -> BC OLS geocoder) registers a region only when its credentials are set; the API key stays server-side and never reaches the browser. Upstream errors degrade to [] (typing never 5xxs). The states read now exposes iso2 so the client can match regions. - Env: optional BC_GEOCODER_API_KEY + BC_GEOCODER_URL (both apis); the field is hidden entirely when the geocoder is unconfigured. - GeoData port gains optional searchAddresses + useAddressSearchRegions; both web apps wire them, with react-select(-async-paginate) added to optimizeDeps for the lazy form route. * style(platform-web): console sidebar nav active-state accent border Remove the link underline and add a left accent border to the console sidebar nav items — a transparent border by default, bcgov-blue when active. * fix(forms): enforce required validation on the address field A required address only forced the object to EXIST, not its sub-fields — and the control writes empty strings, so blank addresses passed. Now a required address serializes the object's own required set (country, address line 1, city, state/province, postal code — address line 2 stays optional) with minLength:1, so empties are rejected by Ajv on both the client (JSONForms submit gating) and the server (submit validation). JSONForms attributes the sub-field (child-path) errors below the object control, not on it, so the address field showed no message — the citizen saw a disabled Submit with no reason. The control now derives requiredness from the schema and renders its own per-field asterisk + "This field is required" message (shown in validation-visible modes). * fix(forms): show the selected address label, not raw JSON, in the search field The address-search option value encodes the whole suggestion as JSON so a pick can both fill the form and be mapped back. AsyncSelect's controlled-value effect, with no resolver, was displaying that JSON value as the selected label. Add a resolveValue that decodes the value back to the suggestion's human `label` for display (via a shared parseSuggestion helper). * feat(forms): default a new address field to Canada / British Columbia createField('address') now seeds defaultCountry='Canada' and defaultProvince='British Columbia', so a freshly-added address field pre-fills those in the inspector's defaults editor and in the builder preview. Authors can still change or clear them. * feat(forms): show field defaults in the builder canvas card The canvas field-card preview renders the control readonly, so the address control's default-seeding effect (which skips readonly) never populated it — the card showed an empty country/province. Seed the preview data from each property's JSON-Schema `default`, so the address card reflects its Canada / British Columbia default (and any future defaulted field shows its default). * chore(charts): wire BC_GEOCODER_URL / BC_GEOCODER_API_KEY for the address search Add BC_GEOCODER_URL (public BC OLS endpoint, non-sensitive) to the env ConfigMap of both API charts, and document BC_GEOCODER_API_KEY as an optional key in each app's existingSecret (values comments + charts README secret table and create-secret example). Absent key → the address-search field is hidden. The CI deploy's `helm dependency build` picks these up into the umbrella chart.
1 parent 1c83560 commit 8e5667b

55 files changed

Lines changed: 3230 additions & 12 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/citizen-portal-api/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,10 @@ OUTBOX_RELAY_MAX_ATTEMPTS=5
6565
# Public web origins the notification emails deep-link into (composed server-side from config).
6666
CITIZEN_WEB_URL=http://localhost:3000
6767
PLATFORM_WEB_URL=http://localhost:3001
68+
69+
# --- Address geocoder (feature 154) -------------------------------------------------------------
70+
# BC OLS Physical Address Geocoder. Leave the key unset to disable the "Search for your address"
71+
# field (the CA/BC region is only registered when a key is present). Obtain a key from the BC API
72+
# portal (https://api.gov.bc.ca/devportal/api-directory). Secret — server-side only.
73+
BC_GEOCODER_URL=https://geocoder.api.gov.bc.ca
74+
# BC_GEOCODER_API_KEY=

apps/citizen-portal-api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { OutboxRelayModule } from './notifications/outbox-relay.module';
2222
import { NotificationsModule } from './modules/notifications/notifications.module';
2323
import { ApplicationsModule } from './modules/applications/applications.module';
2424
import { CatalogModule } from './modules/catalog/catalog.module';
25+
import { GeoModule } from './modules/geo/geo.module';
2526
import { ServiceAgreementsModule } from './modules/service-agreements/service-agreements.module';
2627

2728
@Module({
@@ -107,6 +108,7 @@ import { ServiceAgreementsModule } from './modules/service-agreements/service-ag
107108
HealthModule.forRoot({ readiness: [DatabaseHealthIndicator] }),
108109
// Feature modules live under src/modules/<feature>/.
109110
CatalogModule,
111+
GeoModule,
110112
ApplicationsModule,
111113
NotificationsModule,
112114
ServiceAgreementsModule,

apps/citizen-portal-api/src/config/env.schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export const envSchema = z.object({
7272
OUTBOX_RELAY_INTERVAL_MS: z.coerce.number().int().min(250).default(5000),
7373
OUTBOX_RELAY_BATCH_SIZE: z.coerce.number().int().min(1).max(100).default(10),
7474
OUTBOX_RELAY_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(20).default(5),
75+
76+
// --- Address geocoder (feature 154) ------------------------------------------------------------
77+
// BC OLS Physical Address Geocoder. OPTIONAL: when the API key is unset the CA/BC address-search
78+
// region is not registered, so the web control hides the "Search for your address" field. The key
79+
// is a secret — server-side only, attached to the upstream request, never sent to the browser.
80+
BC_GEOCODER_URL: z.url().default('https://geocoder.api.gov.bc.ca'),
81+
BC_GEOCODER_API_KEY: z.string().optional(),
7582
});
7683

7784
export type Env = z.infer<typeof envSchema>;

apps/citizen-portal-api/src/modules/applications/services/applications.service.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
33
import { UnprocessableEntityException } from '@nestjs/common';
44
import {
55
type Database,
6+
countries,
67
documentReferences,
78
documentVersions,
89
documents,
@@ -14,7 +15,7 @@ import {
1415
workspaces,
1516
} from '@repo/database';
1617
import { InjectDatabase } from '@repo/nestjs/database';
17-
import { and, desc, eq } from 'drizzle-orm';
18+
import { and, desc, eq, inArray } from 'drizzle-orm';
1819
import {
1920
type ApplicationDetail,
2021
type MyApplication,
@@ -29,7 +30,11 @@ import {
2930
import type { Env } from '../../../config/env.schema';
3031
import { enqueueNotification } from '../../../notifications/enqueue';
3132
import { staffSubmissionContent, submissionReceivedContent } from '../util/notification-content';
32-
import { validateSubmission } from '../util/validate';
33+
import {
34+
collectAddressPostals,
35+
validateAddressPostals,
36+
validateSubmission,
37+
} from '../util/validate';
3338
import { ConsentService } from './consent.service';
3439

3540
const FORM_KINDS = new Set(['basic-form', 'multi-stage-form']);
@@ -255,6 +260,29 @@ export class ApplicationsService {
255260
return this.toDto(sub, this.expectRow(updated[0]));
256261
}
257262

263+
/**
264+
* Resolve each address field's postal code against the entered country's regex (from geo reference
265+
* data). Returns error strings (empty = all valid). Countries with no known regex impose no
266+
* constraint; no address fields → no DB read.
267+
*/
268+
private async validateAddressPostals(
269+
kind: string,
270+
structure: Record<string, unknown>,
271+
data: Record<string, unknown>,
272+
): Promise<string[]> {
273+
const entries = collectAddressPostals(kind, structure, data);
274+
if (entries.length === 0) {
275+
return [];
276+
}
277+
const names = [...new Set(entries.map((entry) => entry.country))];
278+
const rows = await this.db
279+
.select({ name: countries.name, regex: countries.postalCodeRegex })
280+
.from(countries)
281+
.where(inArray(countries.name, names));
282+
const regexByCountry = new Map(rows.map((row) => [row.name, row.regex]));
283+
return validateAddressPostals(entries, (country) => regexByCountry.get(country) ?? null);
284+
}
285+
258286
/**
259287
* Submit the application: validate the answers against the form schema (422 on failure), then
260288
* persist the final answers and advance draft → pending. Validation runs only here — drafts are
@@ -275,6 +303,15 @@ export class ApplicationsService {
275303
errors: result.errors,
276304
});
277305
}
306+
// Address fields (feature 153): postal codes are validated against the entered country's regex
307+
// (resolved from geo reference data — country-dependent, so it can't live in the static schema).
308+
const postalErrors = await this.validateAddressPostals(form.kind, form.structure, data);
309+
if (postalErrors.length > 0) {
310+
throw new UnprocessableEntityException({
311+
message: 'The application has validation errors',
312+
errors: postalErrors,
313+
});
314+
}
278315
// Consent gate: every required service agreement must be approved (against its current version).
279316
await this.consent.assertSubmittableForForm(userId, sub.documentVersionId);
280317
// Pre-resolve the citizen's contact email (read BEFORE the tx) for the notification seed.

apps/citizen-portal-api/src/modules/applications/util/validate.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function validateAgainst(schema: Record<string, unknown>, data: unknown): string
2222

2323
interface MultiStagePage {
2424
schema?: Record<string, unknown>;
25+
uischema?: Record<string, unknown>;
2526
}
2627
interface MultiStageStage {
2728
pages?: MultiStagePage[];
@@ -46,3 +47,103 @@ export function validateSubmission(
4647
const errors = schemas.flatMap((schema) => validateAgainst(schema, data));
4748
return { valid: errors.length === 0, errors };
4849
}
50+
51+
// ── Address postal-code validation (feature 153) ─────────────────────────────────────────────────
52+
//
53+
// The address control (`options.format: 'address'`) stores an object under its property key. The
54+
// postal-code format is country-dependent and only known at submit time, so it can't live in the
55+
// static JSON Schema — we resolve the entered country's regex (from geo reference data) at submit.
56+
57+
/** A postal code to validate: the field key, the entered country name, and the entered postal. */
58+
export interface AddressPostalEntry {
59+
key: string;
60+
country: string;
61+
postal: string;
62+
}
63+
64+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
65+
value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
66+
67+
const asText = (value: unknown): string => (typeof value === 'string' ? value : '');
68+
69+
const ADDRESS_SCOPE = /^#\/properties\/(.+)$/;
70+
71+
/** Recursively collect the property keys of every address control in a uischema tree. */
72+
function addressKeysFromUischema(uischema: Record<string, unknown> | undefined): string[] {
73+
if (!uischema) {
74+
return [];
75+
}
76+
const keys: string[] = [];
77+
const options = asRecord(uischema.options);
78+
if (uischema.type === 'Control' && options?.format === 'address') {
79+
const key = ADDRESS_SCOPE.exec(asText(uischema.scope))?.[1];
80+
if (key !== undefined) {
81+
keys.push(key);
82+
}
83+
}
84+
const elements = Array.isArray(uischema.elements) ? uischema.elements : [];
85+
for (const child of elements) {
86+
keys.push(...addressKeysFromUischema(asRecord(child)));
87+
}
88+
return keys;
89+
}
90+
91+
/**
92+
* Find every address field in the form + the citizen's entered country/postal for it. Only entries
93+
* with BOTH a country and a non-empty postal are returned (empty postal is allowed — postal
94+
* requiredness is not enforced here). Basic forms carry one uischema; multi-stage forms carry one per
95+
* page.
96+
*/
97+
export function collectAddressPostals(
98+
kind: string,
99+
structure: Record<string, unknown>,
100+
data: unknown,
101+
): AddressPostalEntry[] {
102+
const uischemas: Array<Record<string, unknown> | undefined> =
103+
kind === 'multi-stage-form'
104+
? ((structure['stages'] as MultiStageStage[] | undefined) ?? []).flatMap((stage) =>
105+
(stage.pages ?? []).map((page) => page.uischema),
106+
)
107+
: [structure['uischema'] as Record<string, unknown> | undefined];
108+
const record = asRecord(data) ?? {};
109+
const keys = [...new Set(uischemas.flatMap(addressKeysFromUischema))];
110+
return keys.flatMap((key): AddressPostalEntry[] => {
111+
const address = asRecord(record[key]);
112+
if (!address) {
113+
return [];
114+
}
115+
const country = asText(address.country);
116+
const postal = asText(address.postal_code);
117+
return country && postal ? [{ key, country, postal }] : [];
118+
});
119+
}
120+
121+
/**
122+
* Validate each collected postal against a per-country regex resolver (backed by geo reference data
123+
* in the service). A country with no known regex (or an unknown country) imposes no constraint. A
124+
* malformed regex is treated as "no constraint" (never throws). Returns human-readable error strings.
125+
*/
126+
export function validateAddressPostals(
127+
entries: AddressPostalEntry[],
128+
regexFor: (country: string) => string | null | undefined,
129+
): string[] {
130+
const errors: string[] = [];
131+
for (const entry of entries) {
132+
const pattern = regexFor(entry.country);
133+
if (!pattern) {
134+
continue;
135+
}
136+
let re: RegExp;
137+
try {
138+
re = new RegExp(pattern);
139+
} catch {
140+
continue;
141+
}
142+
if (!re.test(entry.postal)) {
143+
errors.push(
144+
`${entry.key}: "${entry.postal}" is not a valid postal code for ${entry.country}`,
145+
);
146+
}
147+
}
148+
return errors;
149+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Controller, Get, Param, Query } from '@nestjs/common';
2+
import { ApiTags } from '@nestjs/swagger';
3+
import { Public } from '@repo/nestjs/auth';
4+
import { ZodSerializerDto } from 'nestjs-zod';
5+
6+
import {
7+
AddressSearchQueryDto,
8+
AddressSearchRegionListDto,
9+
AddressSuggestionListDto,
10+
GeoCountryListDto,
11+
GeoStateListDto,
12+
} from '../dtos/geo.dtos';
13+
import { GeoService } from '../services/geo.service';
14+
import { GeocoderService } from '../services/geocoder.service';
15+
16+
/**
17+
* `/v1/geo` — PUBLIC reference data for the address form field (feature 153). Countries and their
18+
* states/provinces. Every route is `@Public` (no user data, no writes).
19+
*/
20+
@ApiTags('Geo')
21+
@Controller({ path: 'geo', version: '1' })
22+
export class GeoV1Controller {
23+
constructor(
24+
private readonly geo: GeoService,
25+
private readonly geocoder: GeocoderService,
26+
) {}
27+
28+
@Public()
29+
@Get('countries')
30+
@ZodSerializerDto(GeoCountryListDto)
31+
async listCountries() {
32+
return { items: await this.geo.listCountries() };
33+
}
34+
35+
@Public()
36+
@Get('countries/:id/states')
37+
@ZodSerializerDto(GeoStateListDto)
38+
async listStates(@Param('id') id: string) {
39+
return { items: await this.geo.listStates(id) };
40+
}
41+
42+
// The (country, province) ISO2 pairs the server can run address search for (feature 154). Empty
43+
// when no geocoder is configured → the web control hides the "Search for your address" field.
44+
@Public()
45+
@Get('address-search/regions')
46+
@ZodSerializerDto(AddressSearchRegionListDto)
47+
addressSearchRegions() {
48+
return { items: this.geocoder.regions() };
49+
}
50+
51+
@Public()
52+
@Get('address-search')
53+
@ZodSerializerDto(AddressSuggestionListDto)
54+
async addressSearch(@Query() query: AddressSearchQueryDto) {
55+
return { items: await this.geocoder.search(query.country, query.province, query.q) };
56+
}
57+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createZodDto } from 'nestjs-zod';
2+
import { z } from 'zod';
3+
4+
/**
5+
* DTOs for the public geo reference-data endpoints (feature 153). Read-only projections of
6+
* `geo.countries` / `geo.states` (feature 152) — just the fields the address form field needs.
7+
* Response DTOs serialize handler output via @ZodSerializerDto.
8+
*/
9+
10+
/**
11+
* A country option for the address field's country combobox. `iso2` drives the per-country label
12+
* lookup; `hasStates` tells the client to show a states dropdown vs a free-text province input;
13+
* `hasPostal` hides the postal field for countries with no postal system.
14+
*/
15+
export const geoCountrySchema = z.object({
16+
id: z.number().int(),
17+
name: z.string(),
18+
iso2: z.string().nullable(),
19+
hasStates: z.boolean(),
20+
hasPostal: z.boolean(),
21+
});
22+
export type GeoCountry = z.infer<typeof geoCountrySchema>;
23+
export class GeoCountryListDto extends createZodDto(
24+
z.object({ items: z.array(geoCountrySchema) }),
25+
) {}
26+
27+
/** A state / province / region under a country. `type` is the upstream subdivision type, if known.
28+
* `iso2` is the ISO 3166-2 subdivision code (e.g. `BC`), used to match address-search regions. */
29+
export const geoStateSchema = z.object({
30+
id: z.number().int(),
31+
name: z.string(),
32+
type: z.string().nullable(),
33+
iso2: z.string().nullable(),
34+
});
35+
export type GeoState = z.infer<typeof geoStateSchema>;
36+
export class GeoStateListDto extends createZodDto(z.object({ items: z.array(geoStateSchema) })) {}
37+
38+
// ── Address search (feature 154) ─────────────────────────────────────────────────────────────────
39+
40+
/** A (country, province) ISO2 pair the server can run address search for. */
41+
export const addressSearchRegionSchema = z.object({
42+
country: z.string(),
43+
province: z.string(),
44+
});
45+
export type AddressSearchRegion = z.infer<typeof addressSearchRegionSchema>;
46+
export class AddressSearchRegionListDto extends createZodDto(
47+
z.object({ items: z.array(addressSearchRegionSchema) }),
48+
) {}
49+
50+
/** A normalized address suggestion (no postal code — the BC geocoder returns none). */
51+
export const addressSuggestionSchema = z.object({
52+
label: z.string(),
53+
streetAddress: z.string(),
54+
city: z.string(),
55+
provinceCode: z.string(),
56+
});
57+
export type AddressSuggestion = z.infer<typeof addressSuggestionSchema>;
58+
export class AddressSuggestionListDto extends createZodDto(
59+
z.object({ items: z.array(addressSuggestionSchema) }),
60+
) {}
61+
62+
/** `GET /v1/geo/address-search` query — ISO2 country + province + the typed text. */
63+
export const addressSearchQuerySchema = z.object({
64+
country: z.string().trim().min(2).max(3),
65+
province: z.string().trim().min(1).max(3),
66+
q: z.string().trim().min(1).max(100),
67+
});
68+
export type AddressSearchQuery = z.infer<typeof addressSearchQuerySchema>;
69+
export class AddressSearchQueryDto extends createZodDto(addressSearchQuerySchema) {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { GeoV1Controller } from './controllers/geo-v1.controller';
4+
import { GeoService } from './services/geo.service';
5+
import { GeocoderService } from './services/geocoder.service';
6+
7+
/**
8+
* Public geo reference data (feature 153): countries + states/provinces for the address form field.
9+
* Read-only, workspace-free. Imported by AppModule.
10+
*/
11+
@Module({
12+
controllers: [GeoV1Controller],
13+
providers: [GeoService, GeocoderService],
14+
})
15+
export class GeoModule {}

0 commit comments

Comments
 (0)