diff --git a/PLAN_3770.md b/PLAN_3770.md new file mode 100644 index 000000000..b32272e1c --- /dev/null +++ b/PLAN_3770.md @@ -0,0 +1,113 @@ +# Plan — Issue #3770: Improve MeshCore Region/Scope Management + +> STATUS: IMPLEMENTED. All 4 capabilities delivered. New global table +> `meshcore_saved_regions` (migration 108) + repository + routes + 4 UI +> touchpoints. Full vitest suite green (excluding 4 pre-existing +> environmental spiderfier suites that fail on the base tree too). + + +## Goal +A user-maintained list of saved MeshCore regions, plus four UI touchpoints to use it: +1. **Save** a region reported by a repeater (from the discover-regions UI). +2. **Add / Delete** entries manually in a management list. +3. **Channel settings**: pick a saved region to set the channel scope (instead of typing). +4. **Per-message**: pick a saved region to override the scope for one message. + +## Data model + +### New table: `meshcore_saved_regions` — GLOBAL (no `sourceId`) +Rationale: a scope is a transport code derived purely from a region *name* +(`sha256("#region")[:16]`). It is not tied to a source/node — the same physical +mesh region applies across every MeshCore source. This mirrors `channel_database` +and `automations`, which are global-by-design. The saved list is a convenience +catalog of names; uniqueness is on the (normalized) name. + +Columns (all 3 backends, following `automations` style): +| column | type | notes | +|---|---|---| +| `id` | INTEGER PK autoincrement (SQLite/PG); MySQL `int` auto_increment | | +| `name` | TEXT / varchar(64) | region name, stored WITHOUT leading `#`, normalized (lowercase letters/digits/hyphen) | +| `note` | TEXT nullable | optional user note / where it came from | +| `createdAt` | INTEGER / bigint | | +| `updatedAt` | INTEGER / bigint | | + +Unique index on `name` (case-insensitive handled in repo by normalizing before insert/lookup). + +### Migration `108_meshcore_saved_regions` +- `src/server/migrations/108_meshcore_saved_regions.ts`: `CREATE TABLE IF NOT EXISTS` for all three backends (idempotent), unique index on name. +- Register in `src/db/migrations.ts` (number 108, name `meshcore_saved_regions`, settingsKey `migration_108_meshcore_saved_regions`). +- Bump `src/db/migrations.test.ts`: count 107 → 108, last name `meshcore_saved_regions`. + +### Schema wiring +- `src/db/schema/savedRegions.ts` — `savedRegionsSqlite/Postgres/Mysql` + inferred types. +- Export from `src/db/schema/index.ts`. +- Register in `src/db/activeSchema.ts` (import, add to active select map under key `meshcoreSavedRegions`, add to `ActiveSchema` type). + +### Repository +- `src/db/repositories/savedRegions.ts` — `SavedRegionsRepository extends BaseRepository`: + - `getAllAsync(): Promise` (ordered by name) + - `getByNameAsync(name)` + - `addAsync(name, note?): Promise` — normalize name, idempotent upsert (return existing if name already saved) + - `deleteAsync(id): Promise` + - Name normalization helper: strip leading `#`, lowercase, keep `[a-z0-9-]`, reject empty. +- Wire into `DatabaseService`: import, `public savedRegionsRepo`, `get savedRegions()` getter, init in `initialize()`. + +## Backend API routes (`src/server/routes/meshcoreRoutes.ts`) +These are catalog-management routes; they touch the DB only (no node IO), but live +under the meshcore router for cohesion. Region management is global, so they are +NOT under `/sources/:id` — use a top-level meshcore regions router OR mount on the +existing meshcore router with permission `requirePermission('settings','write')` +(read = `'settings','read'`). Confirm existing top-level mounting; if the meshcore +router is always source-scoped, add routes under `/sources/:id/meshcore/saved-regions` +operating on the global table (sourceId ignored) to reuse existing auth wiring. + +- `GET .../saved-regions` → list (`{ success, regions: DbSavedRegion[] }`) +- `POST .../saved-regions` → body `{ name, note? }` → add → `{ success, region }` +- `DELETE .../saved-regions/:id` → delete → `{ success }` + +## Frontend + +### API client (`useMeshCore.ts`) +Add to the hook: `savedRegions: DbSavedRegion[]`, `fetchSavedRegions()`, +`addSavedRegion(name, note?)`, `deleteSavedRegion(id)`. Use `csrfFetch` against +`${mcPrefix}/saved-regions`. Fetch on mount alongside other initial loads. + +### Touchpoint 1 — Save from repeater discovery (`MeshCoreSettingsView.tsx`) +The discovered-regions chips (~L272-291) already render per region. Add a small +"+ Save" affordance per chip (or a save icon) that calls `addSavedRegion(region)`. +Disable/checkmark when already saved (cross-ref `savedRegions`). + +### Touchpoint 2 — Manage list (add/delete) +New section in `MeshCoreSettingsView.tsx` (region/scope area): list of saved +regions, each with a delete button; an input + Add button to add manually. +Reuse the normalization/validation already used for scope input. + +### Touchpoint 3 — Channel settings scope dropdown +Where the channel scope is set. Per the investigation, channel scope is set via +`updateChannelScope` repo + the settings default-scope field; confirm the channel +edit UI (`MeshCoreChannelsConfigSection.tsx` / channel edit modal). Replace/augment +the manual scope text input with a `` or `` with +a `` populated from `discoveredRegions`. +Extend that datalist (or add the saved regions) so saved regions appear as +suggestions — union of `discoveredRegions` + `savedRegions`, de-duplicated. + +## Tests +- `src/db/repositories/savedRegions.test.ts` — add/normalize/dedupe/delete/list, name validation, global (no sourceId). +- `src/db/migrations.test.ts` — count + last name update. +- Route tests in the existing meshcore routes test file (or new) — list/add/delete happy path + auth + validation (mock `DatabaseService.savedRegions.*Async`). +- UI: light test for the union/dedup of suggestions if non-trivial; otherwise rely on existing component tests compiling. + +## Verification +- Full vitest suite (0 failures), `tsc`/build, lint. No `system-test` label (no device-comms change). + +## Scope notes / cut lines +- This feature is DB + routes + UI only; no protocol/native-backend change → low risk. +- If the channel-edit scope UI proves to not exist as a discrete field, deliver the + datalist on the per-message override + the manage list + save-from-discovery, and + note the channel-settings dropdown as a follow-up. All four are targeted though. diff --git a/src/cli/migrationTables.ts b/src/cli/migrationTables.ts index 31763a79c..85a44e5e7 100644 --- a/src/cli/migrationTables.ts +++ b/src/cli/migrationTables.ts @@ -83,6 +83,8 @@ export const TABLE_ORDER = [ 'automation_runs', 'automation_variables', 'automation_variable_values', + // 3770: global MeshCore saved-regions catalog. No sourceId / no FK. + 'meshcore_saved_regions', ]; // Tables in the 4.0 schema that carry a `sourceId` column. When the source diff --git a/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx b/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx index d863c64a8..02377325f 100644 --- a/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx +++ b/src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx @@ -34,7 +34,32 @@ vi.mock('../ToastContainer', () => ({ useToast: () => ({ showToast: vi.fn() }), })); -const csrfFetchMock = vi.fn(); +// A single vi.fn mock. The saved-regions catalog fetch (#3770) fires on mount on +// its own effect; we special-case it inside the implementation so it returns an +// empty list WITHOUT consuming a queued once-value meant for the channel CRUD +// calls. Channel calls fall through to a per-test FIFO queue populated by +// `queueResponse(...)` (aliased to `mockResolvedValueOnce` so the existing tests +// read naturally). Assertions on call URLs use `channelCalls`, which excludes +// the saved-regions noise. +let responseQueue: Response[] = []; +let channelCalls: any[][] = []; +const csrfFetchMock = vi.fn((url: string, ...rest: any[]) => { + if (typeof url === 'string' && url.includes('/saved-regions')) { + return Promise.resolve( + new Response(JSON.stringify({ success: true, regions: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + channelCalls.push([url, ...rest]); + const next = responseQueue.shift(); + return Promise.resolve(next ?? new Response('null', { status: 200, headers: { 'content-type': 'application/json' } })); +}); +// Shim the queue-driving + reset API the existing tests call on the mock. +(csrfFetchMock as any).mockResolvedValueOnce = (v: Response) => { responseQueue.push(v); return csrfFetchMock; }; +(csrfFetchMock as any).mockReset = () => { responseQueue = []; channelCalls = []; csrfFetchMock.mockClear(); return csrfFetchMock; }; + vi.mock('../../hooks/useCsrfFetch', () => ({ useCsrfFetch: () => csrfFetchMock, })); @@ -80,7 +105,7 @@ describe('MeshCoreChannelsConfigSection — list rendering', () => { expect(screen.getByText('# Channel 2')).toBeTruthy(); }); - const calledUrl = csrfFetchMock.mock.calls[0][0] as string; + const calledUrl = channelCalls[0][0] as string; expect(calledUrl).toContain('/api/channels/all?sourceId=src-a'); }); @@ -161,7 +186,7 @@ describe('MeshCoreChannelsConfigSection — add channel', () => { }); // Find the PUT call. - const putCall = csrfFetchMock.mock.calls.find( + const putCall = channelCalls.find( c => typeof c[1]?.method === 'string' && c[1].method === 'PUT', ); expect(putCall).toBeDefined(); @@ -255,7 +280,7 @@ describe('MeshCoreChannelsConfigSection — hashtag channels', () => { let putCall: any; await waitFor(() => { - putCall = csrfFetchMock.mock.calls.find( + putCall = channelCalls.find( c => typeof c[1]?.method === 'string' && c[1].method === 'PUT', ); expect(putCall).toBeDefined(); @@ -300,7 +325,7 @@ describe('MeshCoreChannelsConfigSection — hashtag channels', () => { let putCall: any; await waitFor(() => { - putCall = csrfFetchMock.mock.calls.find( + putCall = channelCalls.find( c => typeof c[1]?.method === 'string' && c[1].method === 'PUT', ); expect(putCall).toBeDefined(); @@ -344,7 +369,7 @@ describe('MeshCoreChannelsConfigSection — delete + secret-visibility', () => { fireEvent.click(deleteButtons[1]); }); - const deleteCall = csrfFetchMock.mock.calls.find( + const deleteCall = channelCalls.find( c => typeof c[1]?.method === 'string' && c[1].method === 'DELETE', ); expect(deleteCall).toBeDefined(); diff --git a/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx b/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx index b70bebd2e..5c4c17ee0 100644 --- a/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx +++ b/src/components/MeshCore/MeshCoreChannelsConfigSection.tsx @@ -114,10 +114,34 @@ export const MeshCoreChannelsConfigSection: React.FC([]); const { showToast } = useToast(); const reload = useCallback(() => setReloadTick(v => v + 1), []); + // Load the global saved-regions catalog for the scope-field datalist (#3770). + // Cheap local DB read, independent of device connection. + useEffect(() => { + if (!sourceId) return; + let cancelled = false; + (async () => { + try { + const url = `${baseUrl}/api/sources/${encodeURIComponent(sourceId)}/meshcore/saved-regions`; + const response = await csrfFetch(url); + if (!response.ok) return; + const data = await response.json(); + if (!cancelled && data?.success && Array.isArray(data.regions)) { + setSavedRegions(data.regions.map((r: any) => String(r.name)).filter(Boolean)); + } + } catch { + /* non-fatal — suggestions are optional */ + } + })(); + return () => { cancelled = true; }; + }, [baseUrl, sourceId, csrfFetch]); + useEffect(() => { if (!sourceId) return; let cancelled = false; @@ -379,6 +403,7 @@ export const MeshCoreChannelsConfigSection: React.FC setShowSecret(v => !v)} onRegenerate={handleRegenerate} @@ -417,6 +442,7 @@ export const MeshCoreChannelsConfigSection: React.FC setShowSecret(v => !v)} onRegenerate={handleRegenerate} @@ -454,6 +480,9 @@ interface ChannelEditorProps { * inherit the source default scope / unscoped. */ scope: string; onScopeChange: (v: string) => void; + /** Saved region names (#3770) offered as datalist suggestions on the scope + * field so the operator can pick a known region instead of typing it. */ + regionSuggestions: string[]; showSecret: boolean; onToggleShowSecret: () => void; onRegenerate: () => void; @@ -474,6 +503,7 @@ const ChannelEditor: React.FC = ({ onSecretChange, scope, onScopeChange, + regionSuggestions, showSecret, onToggleShowSecret, onRegenerate, @@ -560,6 +590,7 @@ const ChannelEditor: React.FC = ({ onScopeChange(e.target.value)} placeholder={t('meshcore.channels.scope_placeholder', 'e.g. muenchen — leave blank to inherit default')} @@ -569,6 +600,11 @@ const ChannelEditor: React.FC = ({ maxLength={63} style={{ width: '100%' }} /> + {regionSuggestions.length > 0 && ( + + {regionSuggestions.map(r => + )}

{t( 'meshcore.channels.scope_hint', diff --git a/src/components/MeshCore/MeshCoreChannelsView.tsx b/src/components/MeshCore/MeshCoreChannelsView.tsx index 4d5247eee..b632f5aa0 100644 --- a/src/components/MeshCore/MeshCoreChannelsView.tsx +++ b/src/components/MeshCore/MeshCoreChannelsView.tsx @@ -149,6 +149,9 @@ export const MeshCoreChannelsView: React.FC = ({ // Region names served by nearby repeaters (#3667 phase 3) for the datalist // suggestions on the override input. const [discoveredRegions, setDiscoveredRegions] = useState([]); + // User-saved regions catalog (#3770) — also offered as scope-override + // suggestions so the operator can pick a known region without typing it. + const [savedRegions, setSavedRegions] = useState([]); // Guard so region discovery — which emits active radio traffic — runs at most // once per mount, and only after the operator signals intent by opening the // scope-override control. We must NOT re-discover on every reconnect (#3704 @@ -301,6 +304,34 @@ export const MeshCoreChannelsView: React.FC = ({ return () => { cancelled = true; }; }, [status?.connected, actions]); + // Load the global saved-regions catalog (#3770) for the override suggestions. + // This is a cheap local DB read (no radio traffic), so it's safe to run on + // mount / source change regardless of connection state. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const rows = await actions.fetchSavedRegions(); + if (!cancelled && rows) setSavedRegions(rows.map(r => r.name)); + } catch { + /* non-fatal — suggestions are optional */ + } + })(); + return () => { cancelled = true; }; + }, [actions, sourceId]); + + // Union of saved + discovered regions, de-duplicated, for the override + // datalist. Saved regions come first (operator-curated), then any extra + // freshly-discovered ones. + const scopeSuggestions = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const r of [...savedRegions, ...discoveredRegions]) { + if (r && !seen.has(r)) { seen.add(r); out.push(r); } + } + return out; + }, [savedRegions, discoveredRegions]); + // Lazily discover regions for the suggestion datalist ONLY once the operator // opens the scope-override control (explicit intent), and at most once per // mount. discoverRegions() emits active radio traffic, so we must never tie it @@ -534,7 +565,7 @@ export const MeshCoreChannelsView: React.FC = ({ autoCorrect="off" /> - {discoveredRegions.map(r => - ))} + {discoveredRegions.map((region) => { + const isSaved = savedRegionNames.has(region.toLowerCase()); + return ( + + + + + ); + })} )} )} +

+

{t('meshcore.regions.title', 'Saved regions')}

+

+ {t('meshcore.regions.hint', + 'A list of region/scope names you maintain. Save regions reported by repeaters or add your own, ' + + 'then pick them when setting a channel scope or overriding the scope for a single message. ' + + 'Letters, digits and hyphens only.')} +

+
+ setNewRegionInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveRegion(newRegionInput); }} + placeholder={t('meshcore.regions.add_placeholder', 'e.g. muenchen')} + disabled={savingRegion} + maxLength={63} + spellCheck={false} + autoComplete="off" + style={{ flex: 1 }} + /> + +
+ {savedRegions.length === 0 ? ( +

+ {t('meshcore.regions.empty', 'No saved regions yet.')} +

+ ) : ( +
+ {savedRegions.map((region) => ( + + {region.name} + + + ))} +
+ )} +
+ {status?.localNode && (

{t('meshcore.settings.local_node', 'Local node')}

diff --git a/src/components/MeshCore/hooks/useMeshCore.ts b/src/components/MeshCore/hooks/useMeshCore.ts index bce2f95ae..76dd285e3 100644 --- a/src/components/MeshCore/hooks/useMeshCore.ts +++ b/src/components/MeshCore/hooks/useMeshCore.ts @@ -108,6 +108,15 @@ export interface ConnectionStatus { localNode: MeshCoreNode | null; } +/** A row in the global MeshCore saved-regions catalog (#3770). */ +export interface SavedRegion { + id: number; + name: string; + note: string | null; + createdAt: number; + updatedAt: number; +} + export interface MeshCoreActions { connect: () => Promise; disconnect: () => Promise; @@ -147,6 +156,12 @@ export interface MeshCoreActions { setDefaultScope: (scope: string) => Promise; /** Discover region/scope names served by nearby repeaters (#3667 phase 3). */ discoverRegions: () => Promise<{ regions: string[]; perRepeater: Array<{ publicKey: string; name: string; regions: string[] }>; noZeroHopRepeaters?: boolean } | null>; + /** Refresh the global saved-regions catalog (#3770). Returns the list, or null on error. */ + fetchSavedRegions: () => Promise; + /** Save a region name to the global catalog (#3770). Returns the saved row, or null on error. */ + addSavedRegion: (name: string, note?: string | null) => Promise; + /** Delete a saved region from the global catalog by id (#3770). Returns true on success. */ + deleteSavedRegion: (id: number) => Promise; /** Remove a contact from the device's contact list. Resolves `true` when * the device ACKed the removal; `false` for any error. */ removeContact: (publicKey: string) => Promise; @@ -820,6 +835,57 @@ export function useMeshCore(options: UseMeshCoreOptions): UseMeshCoreState { } }, [mcPrefix, csrfFetch]); + const fetchSavedRegions = useCallback(async (): Promise => { + try { + const response = await csrfFetch(`${mcPrefix}/saved-regions`); + const data = await response.json(); + if (!data.success) { + setError(data.error || 'Failed to load saved regions'); + return null; + } + return Array.isArray(data.regions) ? (data.regions as SavedRegion[]) : []; + } catch (_err) { + setError('Failed to load saved regions'); + return null; + } + }, [mcPrefix, csrfFetch]); + + const addSavedRegion = useCallback(async (name: string, note?: string | null): Promise => { + try { + const response = await csrfFetch(`${mcPrefix}/saved-regions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, note: note ?? null }), + }); + const data = await response.json(); + if (!data.success) { + setError(data.error || 'Failed to save region'); + return null; + } + return (data.region as SavedRegion) ?? null; + } catch (_err) { + setError('Failed to save region'); + return null; + } + }, [mcPrefix, csrfFetch]); + + const deleteSavedRegion = useCallback(async (id: number): Promise => { + try { + const response = await csrfFetch(`${mcPrefix}/saved-regions/${encodeURIComponent(String(id))}`, { + method: 'DELETE', + }); + const data = await response.json(); + if (!data.success) { + setError(data.error || 'Failed to delete region'); + return false; + } + return true; + } catch (_err) { + setError('Failed to delete region'); + return false; + } + }, [mcPrefix, csrfFetch]); + const loginRemote = useCallback(async ( publicKey: string, password: string, @@ -1482,6 +1548,9 @@ export function useMeshCore(options: UseMeshCoreOptions): UseMeshCoreState { getDefaultScope, setDefaultScope, discoverRegions, + fetchSavedRegions, + addSavedRegion, + deleteSavedRegion, shareContact, setContactOutPath, traceContactPath, diff --git a/src/db/activeSchema.ts b/src/db/activeSchema.ts index 333d9f01c..1e6d83d17 100644 --- a/src/db/activeSchema.ts +++ b/src/db/activeSchema.ts @@ -115,6 +115,11 @@ import { automationVariableValuesSqlite, automationVariableValuesPostgres, automationVariableValuesMysql, } from './schema/automationVariables.js'; +// MeshCore saved-regions catalog (global — no sourceId) (#3770) +import { + meshcoreSavedRegionsSqlite, meshcoreSavedRegionsPostgres, meshcoreSavedRegionsMysql, +} from './schema/savedRegions.js'; + // Waypoints table import { waypointsSqlite, waypointsPostgres, waypointsMysql, @@ -215,6 +220,9 @@ export interface ActiveSchema { automationVariables: any; automationVariableValues: any; + // MeshCore saved-regions catalog (global — no sourceId) (#3770) + meshcoreSavedRegions: any; + // Waypoints waypoints: any; @@ -288,6 +296,7 @@ const SCHEMA_MAP: Record = { automationRuns: automationRunsSqlite, automationVariables: automationVariablesSqlite, automationVariableValues: automationVariableValuesSqlite, + meshcoreSavedRegions: meshcoreSavedRegionsSqlite, waypoints: waypointsSqlite, sources: sourcesSqlite, estimatedPositions: estimatedPositionsSqlite, @@ -342,6 +351,7 @@ const SCHEMA_MAP: Record = { automationRuns: automationRunsPostgres, automationVariables: automationVariablesPostgres, automationVariableValues: automationVariableValuesPostgres, + meshcoreSavedRegions: meshcoreSavedRegionsPostgres, waypoints: waypointsPostgres, sources: sourcesPostgres, estimatedPositions: estimatedPositionsPostgres, @@ -396,6 +406,7 @@ const SCHEMA_MAP: Record = { automationRuns: automationRunsMysql, automationVariables: automationVariablesMysql, automationVariableValues: automationVariableValuesMysql, + meshcoreSavedRegions: meshcoreSavedRegionsMysql, waypoints: waypointsMysql, sources: sourcesMysql, estimatedPositions: estimatedPositionsMysql, diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index 32c388f3d..4f61d1d78 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { registry } from './migrations.js'; describe('migrations registry', () => { - it('has all 107 migrations registered', () => { - expect(registry.count()).toBe(107); + it('has all 108 migrations registered', () => { + expect(registry.count()).toBe(108); }); // Bumping these counts: when adding a new migration, increment to +1 and @@ -15,14 +15,14 @@ describe('migrations registry', () => { expect(all[0].name).toContain('v37_baseline'); }); - it('last migration is clear_null_island_positions', () => { + it('last migration is meshcore_saved_regions', () => { const all = registry.getAll(); const last = all[all.length - 1]; - expect(last.number).toBe(107); - expect(last.name).toContain('clear_null_island_positions'); + expect(last.number).toBe(108); + expect(last.name).toContain('meshcore_saved_regions'); }); - it('migrations are sequentially numbered from 1 to 107', () => { + it('migrations are sequentially numbered from 1 to 108', () => { const all = registry.getAll(); for (let i = 0; i < all.length; i++) { expect(all[i].number).toBe(i + 1); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index d2a42413d..ebb9bcaf9 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -122,6 +122,7 @@ import { migration as channelDatabaseHashMigration, runMigration104Postgres as r import { migration as meshcoreMessageRouteMigration, runMigration105Postgres as runMeshcoreMessageRoutePostgres, runMigration105Mysql as runMeshcoreMessageRouteMysql } from '../server/migrations/105_add_meshcore_message_route.js'; import { migration as meshcoreMessageScopeMigration, runMigration106Postgres as runMeshcoreMessageScopePostgres, runMigration106Mysql as runMeshcoreMessageScopeMysql } from '../server/migrations/106_add_meshcore_message_scope.js'; import { migration as clearNullIslandMigration, runMigration107Postgres as runClearNullIslandPostgres, runMigration107Mysql as runClearNullIslandMysql } from '../server/migrations/107_clear_null_island_positions.js'; +import { migration as meshcoreSavedRegionsMigration, runMigration108Postgres as runMeshcoreSavedRegionsPostgres, runMigration108Mysql as runMeshcoreSavedRegionsMysql } from '../server/migrations/108_meshcore_saved_regions.js'; // ============================================================================ // Registry @@ -1697,3 +1698,18 @@ registry.register({ postgres: (client) => runClearNullIslandPostgres(client), mysql: (pool) => runClearNullIslandMysql(pool), }); + +// --------------------------------------------------------------------------- +// Migration 108: MeshCore saved-regions catalog (#3770) +// Global (no sourceId) user-maintained list of MeshCore region names used to +// populate scope dropdowns (channel settings + per-message override). +// --------------------------------------------------------------------------- + +registry.register({ + number: 108, + name: 'meshcore_saved_regions', + settingsKey: 'migration_108_meshcore_saved_regions', + sqlite: (db) => meshcoreSavedRegionsMigration.up(db), + postgres: (client) => runMeshcoreSavedRegionsPostgres(client), + mysql: (pool) => runMeshcoreSavedRegionsMysql(pool), +}); diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index a7315c1d4..271a8bfc1 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -53,6 +53,8 @@ export type { UpdateVariableInput, AutomationVariableValueRecord, } from './automationVariables.js'; +export { SavedRegionsRepository, normalizeRegionName } from './savedRegions.js'; +export type { SavedRegion } from './savedRegions.js'; export { SourcesRepository } from './sources.js'; export type { Source, CreateSourceInput } from './sources.js'; export { AnalysisRepository } from './analysis.js'; diff --git a/src/db/repositories/savedRegions.test.ts b/src/db/repositories/savedRegions.test.ts new file mode 100644 index 000000000..20eb4493f --- /dev/null +++ b/src/db/repositories/savedRegions.test.ts @@ -0,0 +1,99 @@ +/** + * Saved Regions Repository Tests (#3770) + * + * CRUD + normalization + de-dup coverage for the GLOBAL `meshcore_saved_regions` + * catalog against a real in-memory SQLite database (migration 108 applied via + * createTestDb). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { SavedRegionsRepository, normalizeRegionName } from './savedRegions.js'; +import * as schema from '../schema/index.js'; +import { createTestDb } from '../../server/test-helpers/testDb.js'; + +describe('normalizeRegionName', () => { + it('strips leading #, lowercases, and drops invalid chars', () => { + expect(normalizeRegionName('#Muenchen')).toBe('muenchen'); + expect(normalizeRegionName(' Sample City! ')).toBe('samplecity'); + expect(normalizeRegionName('foo-bar-123')).toBe('foo-bar-123'); + expect(normalizeRegionName('###')).toBe(''); + expect(normalizeRegionName('')).toBe(''); + }); +}); + +describe('SavedRegionsRepository', () => { + let db: ReturnType['sqlite']; + let drizzleDb: BetterSQLite3Database; + let repo: SavedRegionsRepository; + + beforeEach(() => { + const t = createTestDb(); + db = t.sqlite; + drizzleDb = t.db; + repo = new SavedRegionsRepository(drizzleDb, 'sqlite'); + }); + + afterEach(() => { + db.close(); + }); + + it('starts empty', async () => { + expect(await repo.getAllAsync()).toEqual([]); + }); + + it('adds and retrieves a region (normalized)', async () => { + const added = await repo.addAsync('#Muenchen', 'big city'); + expect(added.id).toBeGreaterThan(0); + expect(added.name).toBe('muenchen'); + expect(added.note).toBe('big city'); + expect(added.createdAt).toBeGreaterThan(0); + + const all = await repo.getAllAsync(); + expect(all).toHaveLength(1); + expect(all[0].name).toBe('muenchen'); + }); + + it('is idempotent on duplicate names (returns existing, no duplicate row)', async () => { + const first = await repo.addAsync('muenchen'); + const second = await repo.addAsync('#MUENCHEN'); // same after normalization + expect(second.id).toBe(first.id); + expect(await repo.getAllAsync()).toHaveLength(1); + }); + + it('updates the note when re-adding the same name with a new note', async () => { + const first = await repo.addAsync('berlin'); + expect(first.note).toBeNull(); + const updated = await repo.addAsync('berlin', 'capital'); + expect(updated.id).toBe(first.id); + expect(updated.note).toBe('capital'); + const fetched = await repo.getByNameAsync('berlin'); + expect(fetched?.note).toBe('capital'); + }); + + it('rejects an empty/invalid name', async () => { + await expect(repo.addAsync('###')).rejects.toThrow(/Invalid region name/); + await expect(repo.addAsync(' ')).rejects.toThrow(/Invalid region name/); + }); + + it('looks up by name case-insensitively', async () => { + await repo.addAsync('Hamburg'); + expect((await repo.getByNameAsync('#HAMBURG'))?.name).toBe('hamburg'); + expect(await repo.getByNameAsync('nope')).toBeNull(); + }); + + it('lists regions ordered by name', async () => { + await repo.addAsync('zulu'); + await repo.addAsync('alpha'); + await repo.addAsync('mike'); + const names = (await repo.getAllAsync()).map((r) => r.name); + expect(names).toEqual(['alpha', 'mike', 'zulu']); + }); + + it('deletes a region by id', async () => { + const a = await repo.addAsync('alpha'); + await repo.addAsync('bravo'); + await repo.deleteAsync(a.id); + const names = (await repo.getAllAsync()).map((r) => r.name); + expect(names).toEqual(['bravo']); + }); +}); diff --git a/src/db/repositories/savedRegions.ts b/src/db/repositories/savedRegions.ts new file mode 100644 index 000000000..623dbbd91 --- /dev/null +++ b/src/db/repositories/savedRegions.ts @@ -0,0 +1,140 @@ +/** + * MeshCore Saved Regions Repository (#3770) + * + * CRUD for the GLOBAL `meshcore_saved_regions` catalog — a user-maintained, + * de-duplicated list of MeshCore region names. A "scope" is a transport code + * derived purely from a region name (sha256("#region")[:16]), so the catalog is + * NOT source-scoped (mirrors `channel_database` / `automations`). + * + * Names are normalized on the way in (strip leading '#', lowercase, keep only + * letters/digits/hyphen) and stored UNIQUE, so the same region can't be saved + * twice and lookups are stable. + */ +import { eq, asc } from 'drizzle-orm'; +import { BaseRepository, DrizzleDatabase } from './base.js'; +import { DatabaseType } from '../types.js'; +import { logger } from '../../utils/logger.js'; + +export interface SavedRegion { + id: number; + name: string; + note: string | null; + createdAt: number; + updatedAt: number; +} + +/** + * Normalize a region name for storage / comparison: strip a leading '#', lower- + * case, and keep only letters, digits and hyphens. Returns '' if nothing valid + * remains (callers should reject empty). Mirrors the scope normalization used in + * the MeshCore manager so saved names match what gets hashed into a scope. + */ +export function normalizeRegionName(raw: string): string { + return (raw ?? '') + .trim() + .replace(/^#+/, '') + .toLowerCase() + .replace(/[^a-z0-9-]/g, ''); +} + +export class SavedRegionsRepository extends BaseRepository { + constructor(db: DrizzleDatabase, dbType: DatabaseType) { + super(db, dbType); + } + + private map(row: any): SavedRegion { + return this.normalizeBigInts({ + id: Number(row.id), + name: row.name, + note: row.note ?? null, + createdAt: Number(row.createdAt), + updatedAt: Number(row.updatedAt), + }); + } + + /** List all saved regions, ordered by name. */ + async getAllAsync(): Promise { + const { meshcoreSavedRegions } = this.tables; + const rows = await this.db + .select() + .from(meshcoreSavedRegions) + .orderBy(asc(meshcoreSavedRegions.name)); + return rows.map((r: any) => this.map(r)); + } + + /** Look up a saved region by (normalized) name. Returns null if not found. */ + async getByNameAsync(name: string): Promise { + const normalized = normalizeRegionName(name); + if (!normalized) return null; + const { meshcoreSavedRegions } = this.tables; + const rows = await this.db + .select() + .from(meshcoreSavedRegions) + .where(eq(meshcoreSavedRegions.name, normalized)) + .limit(1); + return rows.length ? this.map(rows[0]) : null; + } + + /** + * Add a region to the catalog (idempotent). Normalizes the name; if the name + * already exists, returns the existing row (optionally updating the note). + * Throws on an empty/invalid name so callers can surface a 400. + */ + async addAsync(name: string, note?: string | null): Promise { + const normalized = normalizeRegionName(name); + if (!normalized) { + throw new Error('Invalid region name (letters, digits and hyphens only)'); + } + const trimmedNote = (note ?? '').trim() || null; + + const existing = await this.getByNameAsync(normalized); + if (existing) { + if (trimmedNote !== null && trimmedNote !== existing.note) { + await this.updateNoteAsync(existing.id, trimmedNote); + return { ...existing, note: trimmedNote }; + } + return existing; + } + + const now = this.now(); + const { meshcoreSavedRegions } = this.tables; + const values: any = { + name: normalized, + note: trimmedNote, + createdAt: now, + updatedAt: now, + }; + + if (this.isMySQL()) { + const db = this.getMysqlDb(); + const result = await db.insert(meshcoreSavedRegions).values(values); + const id = Number(result[0].insertId); + logger.debug(`Added saved region "${normalized}" (ID: ${id})`); + return { id, name: normalized, note: trimmedNote, createdAt: now, updatedAt: now }; + } + + const result = await (this.db as any) + .insert(meshcoreSavedRegions) + .values(values) + .returning({ id: meshcoreSavedRegions.id }); + const id = Number(result[0].id); + logger.debug(`Added saved region "${normalized}" (ID: ${id})`); + return { id, name: normalized, note: trimmedNote, createdAt: now, updatedAt: now }; + } + + /** Update a saved region's note. */ + async updateNoteAsync(id: number, note: string | null): Promise { + const { meshcoreSavedRegions } = this.tables; + await this.db + .update(meshcoreSavedRegions) + .set({ note: note || null, updatedAt: this.now() }) + .where(eq(meshcoreSavedRegions.id, id)); + } + + /** Delete a saved region by id. */ + async deleteAsync(id: number): Promise { + const { meshcoreSavedRegions } = this.tables; + await this.db.delete(meshcoreSavedRegions).where(eq(meshcoreSavedRegions.id, id)); + logger.debug(`Deleted saved region ID: ${id}`); + } +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index f2afef69b..cd5ae6999 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -44,6 +44,9 @@ export * from './embedProfiles.js'; export * from './automations.js'; export * from './automationVariables.js'; +// MeshCore saved-regions catalog (global — no sourceId) (#3770) +export * from './savedRegions.js'; + // Waypoints table export * from './waypoints.js'; diff --git a/src/db/schema/savedRegions.ts b/src/db/schema/savedRegions.ts new file mode 100644 index 000000000..18aa4ba92 --- /dev/null +++ b/src/db/schema/savedRegions.ts @@ -0,0 +1,55 @@ +/** + * Drizzle schema for the MeshCore saved-regions catalog (#3770). + * Supports SQLite, PostgreSQL, and MySQL. + * + * `meshcore_saved_regions` is GLOBAL by design (no sourceId). A MeshCore + * "scope" is a transport code derived purely from a region NAME + * (sha256("#region")[:16]); it is not bound to a source/node, so the saved + * catalog of region names applies across every MeshCore source. This mirrors + * the global-by-design tables `channel_database` and `automations`. + * + * `name` is stored normalized (lowercase, no leading '#', letters/digits/hyphen + * only) and is unique — the catalog is a de-duplicated list of region names. + */ +import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { pgTable, text as pgText, bigint as pgBigint, serial as pgSerial, uniqueIndex as pgUniqueIndex } from 'drizzle-orm/pg-core'; +import { mysqlTable, varchar as myVarchar, int as myInt, bigint as myBigint, uniqueIndex as myUniqueIndex } from 'drizzle-orm/mysql-core'; + +// SQLite +export const meshcoreSavedRegionsSqlite = sqliteTable('meshcore_saved_regions', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + note: text('note'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}, (t) => ({ + nameUniq: uniqueIndex('meshcore_saved_regions_name_idx').on(t.name), +})); + +// PostgreSQL +export const meshcoreSavedRegionsPostgres = pgTable('meshcore_saved_regions', { + id: pgSerial('id').primaryKey(), + name: pgText('name').notNull(), + note: pgText('note'), + createdAt: pgBigint('created_at', { mode: 'number' }).notNull(), + updatedAt: pgBigint('updated_at', { mode: 'number' }).notNull(), +}, (t) => ({ + nameUniq: pgUniqueIndex('meshcore_saved_regions_name_idx').on(t.name), +})); + +// MySQL +export const meshcoreSavedRegionsMysql = mysqlTable('meshcore_saved_regions', { + id: myInt('id').primaryKey().autoincrement(), + name: myVarchar('name', { length: 64 }).notNull(), + note: myVarchar('note', { length: 255 }), + createdAt: myBigint('created_at', { mode: 'number' }).notNull(), + updatedAt: myBigint('updated_at', { mode: 'number' }).notNull(), +}, (t) => ({ + nameUniq: myUniqueIndex('meshcore_saved_regions_name_idx').on(t.name), +})); + +// Inferred types +export type MeshcoreSavedRegionSqlite = typeof meshcoreSavedRegionsSqlite.$inferSelect; +export type NewMeshcoreSavedRegionSqlite = typeof meshcoreSavedRegionsSqlite.$inferInsert; +export type MeshcoreSavedRegionPostgres = typeof meshcoreSavedRegionsPostgres.$inferSelect; +export type MeshcoreSavedRegionMysql = typeof meshcoreSavedRegionsMysql.$inferSelect; diff --git a/src/server/migrations/108_meshcore_saved_regions.ts b/src/server/migrations/108_meshcore_saved_regions.ts new file mode 100644 index 000000000..6b5c3fdfd --- /dev/null +++ b/src/server/migrations/108_meshcore_saved_regions.ts @@ -0,0 +1,96 @@ +/** + * Migration 108: MeshCore saved-regions catalog (#3770). + * + * Creates `meshcore_saved_regions`: a GLOBAL (no sourceId) user-maintained list + * of MeshCore region names. A "scope" is a transport code derived purely from a + * region name (sha256("#region")[:16]), so the catalog is not source-scoped — + * it mirrors the global-by-design `channel_database` / `automations` tables. + * + * `name` is normalized (lowercase, no leading '#', letters/digits/hyphen) and + * UNIQUE so the list is de-duplicated. + * + * Idempotent across SQLite / PostgreSQL / MySQL. + */ +import type { Database } from 'better-sqlite3'; +import { logger } from '../../utils/logger.js'; + +const LABEL = 'Migration 108'; +const TABLE = 'meshcore_saved_regions'; + +// ============ SQLite ============ + +export const migration = { + up: (db: Database): void => { + logger.info(`${LABEL} (SQLite): creating ${TABLE}...`); + + db.exec(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + note TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS meshcore_saved_regions_name_idx ON ${TABLE}(name)`); + + logger.info(`${LABEL} complete (SQLite)`); + }, + + down: (db: Database): void => { + logger.info(`${LABEL} down (SQLite): dropping ${TABLE}`); + db.exec(`DROP TABLE IF EXISTS ${TABLE}`); + }, +}; + +// ============ PostgreSQL ============ + +export async function runMigration108Postgres(client: import('pg').PoolClient): Promise { + logger.info(`${LABEL} (PostgreSQL): creating ${TABLE}...`); + + await client.query(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + note TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) + `); + await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS meshcore_saved_regions_name_idx ON ${TABLE}(name)`); + + logger.info(`${LABEL} complete (PostgreSQL)`); +} + +// ============ MySQL ============ + +export async function runMigration108Mysql(pool: import('mysql2/promise').Pool): Promise { + logger.info(`${LABEL} (MySQL): creating ${TABLE}...`); + + const conn = await pool.getConnection(); + try { + const [exists] = await conn.query( + `SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, + [TABLE], + ); + if ((exists as any[]).length === 0) { + await conn.query(` + CREATE TABLE ${TABLE} ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + note VARCHAR(255), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY meshcore_saved_regions_name_idx (name) + ) + `); + } else { + logger.debug(`${TABLE} already exists, skipping create`); + } + } finally { + conn.release(); + } + + logger.info(`${LABEL} complete (MySQL)`); +} diff --git a/src/server/routes/meshcoreRoutes.test.ts b/src/server/routes/meshcoreRoutes.test.ts index d76c722af..3a7ab99ca 100644 --- a/src/server/routes/meshcoreRoutes.test.ts +++ b/src/server/routes/meshcoreRoutes.test.ts @@ -218,6 +218,15 @@ describe('MeshCore Routes', () => { getSetting: vi.fn(async () => null), }; + // Saved-regions catalog mock (#3770). Tests override these per-case. + (DatabaseService as any).savedRegions = { + getAllAsync: vi.fn(async () => []), + addAsync: vi.fn(async (name: string, note?: string | null) => ({ + id: 1, name: name.toLowerCase().replace(/^#/, ''), note: note ?? null, createdAt: 1, updatedAt: 1, + })), + deleteAsync: vi.fn(async () => undefined), + }; + // Add async method mocks (DatabaseService as any).findUserByIdAsync = async (id: number) => { return userModel.findById(id); @@ -317,6 +326,67 @@ describe('MeshCore Routes', () => { }); }); + describe('Saved regions catalog (#3770)', () => { + it('GET /saved-regions lists regions', async () => { + (DatabaseService as any).savedRegions.getAllAsync.mockResolvedValueOnce([ + { id: 1, name: 'muenchen', note: null, createdAt: 1, updatedAt: 1 }, + ]); + const response = await authenticatedAgent.get('/api/sources/test-source/meshcore/saved-regions'); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.regions).toHaveLength(1); + expect(response.body.regions[0].name).toBe('muenchen'); + }); + + it('GET /saved-regions requires authentication', async () => { + const response = await request(app).get('/api/sources/test-source/meshcore/saved-regions'); + expect(response.status).toBe(401); + }); + + it('POST /saved-regions adds a region', async () => { + const response = await authenticatedAgent + .post('/api/sources/test-source/meshcore/saved-regions') + .send({ name: '#Muenchen' }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect((DatabaseService as any).savedRegions.addAsync).toHaveBeenCalledWith('#Muenchen', null); + }); + + it('POST /saved-regions rejects a missing name', async () => { + const response = await authenticatedAgent + .post('/api/sources/test-source/meshcore/saved-regions') + .send({ note: 'no name' }); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + + it('POST /saved-regions surfaces an invalid-name error as 400', async () => { + (DatabaseService as any).savedRegions.addAsync.mockRejectedValueOnce( + new Error('Invalid region name (letters, digits and hyphens only)'), + ); + const response = await authenticatedAgent + .post('/api/sources/test-source/meshcore/saved-regions') + .send({ name: '###' }); + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/Invalid region name/); + }); + + it('DELETE /saved-regions/:id deletes a region', async () => { + const response = await authenticatedAgent + .delete('/api/sources/test-source/meshcore/saved-regions/5'); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect((DatabaseService as any).savedRegions.deleteAsync).toHaveBeenCalledWith(5); + }); + + it('DELETE /saved-regions/:id rejects an invalid id', async () => { + const response = await authenticatedAgent + .delete('/api/sources/test-source/meshcore/saved-regions/abc'); + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }); + }); + describe('POST /api/sources/test-source/meshcore/connect', () => { it('should require authentication', async () => { const response = await request(app) diff --git a/src/server/routes/meshcoreRoutes.ts b/src/server/routes/meshcoreRoutes.ts index d3d4b4c7a..4e094eed3 100644 --- a/src/server/routes/meshcoreRoutes.ts +++ b/src/server/routes/meshcoreRoutes.ts @@ -671,6 +671,81 @@ router.post( }, ); +/** + * Saved regions catalog (#3770) — a GLOBAL, user-maintained list of MeshCore + * region names used to populate scope dropdowns (channel settings + per-message + * override) so users don't have to type/remember scopes. The catalog is not + * source-scoped (a scope is derived purely from a region name), but the routes + * live under the source-scoped meshcore router and reuse its auth wiring. + * + * GET .../saved-regions → list all saved regions + * POST .../saved-regions → { name, note? } add (idempotent) + * DELETE .../saved-regions/:id → delete one + */ +router.get( + '/saved-regions', + requireAuth(), + requirePermission('configuration', 'read', { sourceIdFrom: 'params.id' }), + async (_req: Request, res: Response) => { + try { + const regions = await databaseService.savedRegions.getAllAsync(); + res.json({ success: true, regions }); + } catch (error) { + logger.error('[API] Error listing saved regions:', error); + res.status(500).json({ success: false, error: 'Failed to list saved regions' }); + } + }, +); + +router.post( + '/saved-regions', + requireAuth(), + requirePermission('configuration', 'write', { sourceIdFrom: 'params.id' }), + async (req: Request, res: Response) => { + try { + const name = req.body?.name; + const note = req.body?.note; + if (typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ success: false, error: 'name is required' }); + } + if (name.length > 64) { + return res.status(400).json({ success: false, error: 'name must be 64 characters or fewer' }); + } + if (note !== undefined && note !== null && typeof note !== 'string') { + return res.status(400).json({ success: false, error: 'note must be a string' }); + } + const region = await databaseService.savedRegions.addAsync(name, note ?? null); + res.json({ success: true, region }); + } catch (error: any) { + // addAsync throws on an empty/invalid normalized name. + if (error?.message?.includes('Invalid region name')) { + return res.status(400).json({ success: false, error: error.message }); + } + logger.error('[API] Error adding saved region:', error); + res.status(500).json({ success: false, error: 'Failed to add saved region' }); + } + }, +); + +router.delete( + '/saved-regions/:regionId', + requireAuth(), + requirePermission('configuration', 'write', { sourceIdFrom: 'params.id' }), + async (req: Request, res: Response) => { + try { + const id = Number(req.params.regionId); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ success: false, error: 'Invalid region id' }); + } + await databaseService.savedRegions.deleteAsync(id); + res.json({ success: true }); + } catch (error) { + logger.error('[API] Error deleting saved region:', error); + res.status(500).json({ success: false, error: 'Failed to delete saved region' }); + } + }, +); + /** * POST /api/sources/:id/meshcore/contacts/:publicKey/trace-path * diff --git a/src/services/database.ts b/src/services/database.ts index 0a0aec51d..615d49f3d 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -43,6 +43,7 @@ import { DeadDropRepository, AutomationsRepository, AutomationVariablesRepository, + SavedRegionsRepository, } from '../db/repositories/index.js'; import type { EstimatedPosition, EstimatedPositionInput } from '../db/repositories/index.js'; import type { DatabaseType, DbPacketLog as DbTypesPacketLog, DbPacketCountByNode, DbPacketCountByPortnum, DbDistinctRelayNode } from '../db/types.js'; @@ -509,6 +510,7 @@ class DatabaseService { public deadDropRepo: DeadDropRepository | null = null; public automationsRepo: AutomationsRepository | null = null; public automationVariablesRepo: AutomationVariablesRepository | null = null; + public savedRegionsRepo: SavedRegionsRepository | null = null; /** * Typed repository accessors — throw if database not initialized. @@ -574,6 +576,11 @@ class DatabaseService { return this.automationVariablesRepo; } + get savedRegions(): SavedRegionsRepository { + if (!this.savedRegionsRepo) throw new Error('Database not initialized'); + return this.savedRegionsRepo; + } + get auth(): AuthRepository { if (!this.authRepo) throw new Error('Database not initialized'); return this.authRepo; @@ -911,6 +918,7 @@ class DatabaseService { this.deadDropRepo = new DeadDropRepository(drizzleDb, this.drizzleDbType); this.automationsRepo = new AutomationsRepository(drizzleDb, this.drizzleDbType); this.automationVariablesRepo = new AutomationVariablesRepository(drizzleDb, this.drizzleDbType); + this.savedRegionsRepo = new SavedRegionsRepository(drizzleDb, this.drizzleDbType); logger.info('[DatabaseService] Drizzle repositories initialized successfully');