@kafitra/react-native-live-tracking 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +396 -0
- package/android/build.gradle +71 -0
- package/android/gradle.properties +7 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
- package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
- package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
- package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
- package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
- package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
- package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
- package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
- package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
- package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
- package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
- package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
- package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
- package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
- package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
- package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
- package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
- package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
- package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
- package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
- package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
- package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
- package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
- package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
- package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
- package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
- package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
- package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
- package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
- package/ios/ActivityRecognitionHandler.swift +196 -0
- package/ios/BackgroundModeHelper.swift +132 -0
- package/ios/FirebaseSyncEngine.swift +276 -0
- package/ios/LiveTracking-Bridging-Header.h +2 -0
- package/ios/LiveTracking.m +37 -0
- package/ios/LiveTracking.swift +773 -0
- package/ios/LocationDataPoint.swift +56 -0
- package/ios/LocationEngine.swift +160 -0
- package/ios/MotionSleepManager.swift +151 -0
- package/ios/NetworkListener.swift +105 -0
- package/ios/OfflineQueueManager.swift +503 -0
- package/ios/PermissionHandler.swift +148 -0
- package/ios/QueueEngine.swift +249 -0
- package/ios/SyncEngineController.swift +396 -0
- package/ios/SyncTargetConfig.swift +36 -0
- package/ios/TargetHandler.swift +715 -0
- package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
- package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
- package/ios/Tests/LocationEngineTests.swift +244 -0
- package/ios/Tests/MotionSleepManagerTests.swift +355 -0
- package/ios/Tests/NetworkListenerTests.swift +188 -0
- package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
- package/ios/Tests/PermissionHandlerTests.swift +238 -0
- package/ios/Tests/QueueEngineTests.swift +346 -0
- package/ios/TrackingCleanup.swift +93 -0
- package/ios/TrackingNotificationManager.swift +187 -0
- package/lib/commonjs/EventEmitter.js +113 -0
- package/lib/commonjs/EventEmitter.js.map +1 -0
- package/lib/commonjs/LiveTracking.js +134 -0
- package/lib/commonjs/LiveTracking.js.map +1 -0
- package/lib/commonjs/NativeLiveTracking.js +21 -0
- package/lib/commonjs/NativeLiveTracking.js.map +1 -0
- package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
- package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
- package/lib/commonjs/index.js +103 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/serialization/locationSerializer.js +51 -0
- package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
- package/lib/commonjs/types.js +77 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/utils/distance.js +63 -0
- package/lib/commonjs/utils/distance.js.map +1 -0
- package/lib/commonjs/utils/retry.js +80 -0
- package/lib/commonjs/utils/retry.js.map +1 -0
- package/lib/commonjs/validation.js +463 -0
- package/lib/commonjs/validation.js.map +1 -0
- package/lib/module/EventEmitter.js +105 -0
- package/lib/module/EventEmitter.js.map +1 -0
- package/lib/module/LiveTracking.js +127 -0
- package/lib/module/LiveTracking.js.map +1 -0
- package/lib/module/NativeLiveTracking.js +16 -0
- package/lib/module/NativeLiveTracking.js.map +1 -0
- package/lib/module/filters/distanceTimeFilter.js +58 -0
- package/lib/module/filters/distanceTimeFilter.js.map +1 -0
- package/lib/module/index.js +32 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/serialization/locationSerializer.js +45 -0
- package/lib/module/serialization/locationSerializer.js.map +1 -0
- package/lib/module/types.js +94 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/distance.js +56 -0
- package/lib/module/utils/distance.js.map +1 -0
- package/lib/module/utils/retry.js +72 -0
- package/lib/module/utils/retry.js.map +1 -0
- package/lib/module/validation.js +456 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/EventEmitter.d.ts +65 -0
- package/lib/typescript/EventEmitter.d.ts.map +1 -0
- package/lib/typescript/LiveTracking.d.ts +23 -0
- package/lib/typescript/LiveTracking.d.ts.map +1 -0
- package/lib/typescript/NativeLiveTracking.d.ts +25 -0
- package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +21 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
- package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +217 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/utils/distance.d.ts +38 -0
- package/lib/typescript/utils/distance.d.ts.map +1 -0
- package/lib/typescript/utils/retry.d.ts +60 -0
- package/lib/typescript/utils/retry.d.ts.map +1 -0
- package/lib/typescript/validation.d.ts +26 -0
- package/lib/typescript/validation.d.ts.map +1 -0
- package/package.json +126 -0
- package/react-native-live-tracking.podspec +47 -0
- package/src/EventEmitter.ts +118 -0
- package/src/LiveTracking.ts +159 -0
- package/src/NativeLiveTracking.ts +29 -0
- package/src/filters/distanceTimeFilter.ts +75 -0
- package/src/index.ts +51 -0
- package/src/serialization/locationSerializer.ts +57 -0
- package/src/types.ts +252 -0
- package/src/utils/distance.ts +68 -0
- package/src/utils/retry.ts +75 -0
- package/src/validation.ts +552 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation and default value application for react-native-live-tracking.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ConfigError,
|
|
9
|
+
ConfigValidationResult,
|
|
10
|
+
TrackingConfig,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
// ─── Default Values ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULT_INTERVAL_MS = 10000;
|
|
16
|
+
const DEFAULT_DISTANCE_FILTER_METERS = 10;
|
|
17
|
+
const DEFAULT_STOP_WHEN_STILL = true;
|
|
18
|
+
const DEFAULT_MODE: 'interval' | 'distance' | 'both' = 'both';
|
|
19
|
+
|
|
20
|
+
const VALID_OPTIMIZATION_MODES = ['interval', 'distance', 'both'] as const;
|
|
21
|
+
|
|
22
|
+
// ─── Validation Constants ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const MAX_TARGETS = 20;
|
|
25
|
+
const MAX_PATH_LENGTH = 768;
|
|
26
|
+
const MAX_BATCH_SIZE = 1000;
|
|
27
|
+
const VALID_METHODS = ['set', 'push', 'update'] as const;
|
|
28
|
+
|
|
29
|
+
// ─── Validation ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validates a raw configuration object and returns a structured result
|
|
33
|
+
* indicating whether the config is valid or contains errors.
|
|
34
|
+
*
|
|
35
|
+
* @param config - The raw configuration object to validate (unknown type for safety)
|
|
36
|
+
* @returns A ConfigValidationResult with validity status and any errors found
|
|
37
|
+
*/
|
|
38
|
+
export function validateConfig(config: unknown): ConfigValidationResult {
|
|
39
|
+
const errors: ConfigError[] = [];
|
|
40
|
+
|
|
41
|
+
// Check that config is an object
|
|
42
|
+
if (config === null || config === undefined || typeof config !== 'object') {
|
|
43
|
+
errors.push({
|
|
44
|
+
field: 'config',
|
|
45
|
+
message: 'Configuration must be a non-null object',
|
|
46
|
+
code: 'INVALID_TYPE',
|
|
47
|
+
});
|
|
48
|
+
return { valid: false, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cfg = config as Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
// Validate optimization
|
|
54
|
+
validateOptimization(cfg['optimization'], errors);
|
|
55
|
+
|
|
56
|
+
// Validate firebase
|
|
57
|
+
validateFirebase(cfg['firebase'], errors);
|
|
58
|
+
|
|
59
|
+
// Validate androidNotification (optional)
|
|
60
|
+
if (cfg['androidNotification'] !== undefined) {
|
|
61
|
+
validateAndroidNotification(cfg['androidNotification'], errors);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Validate iosNotification (optional)
|
|
65
|
+
if (cfg['iosNotification'] !== undefined) {
|
|
66
|
+
validateIOSNotification(cfg['iosNotification'], errors);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
valid: errors.length === 0,
|
|
71
|
+
errors,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Applies default values to a valid TrackingConfig object.
|
|
77
|
+
* Should only be called after validateConfig returns valid: true.
|
|
78
|
+
*
|
|
79
|
+
* Note: Sync target defaults (batchSize, offlineQueue) are handled per-target
|
|
80
|
+
* at the native layer. Targets pass through without modification here.
|
|
81
|
+
*
|
|
82
|
+
* @param config - A valid TrackingConfig object
|
|
83
|
+
* @returns A new TrackingConfig with all defaults applied
|
|
84
|
+
*/
|
|
85
|
+
export function applyDefaults(config: TrackingConfig): TrackingConfig {
|
|
86
|
+
return {
|
|
87
|
+
...config,
|
|
88
|
+
optimization: {
|
|
89
|
+
intervalMs: config.optimization.intervalMs ?? DEFAULT_INTERVAL_MS,
|
|
90
|
+
distanceFilterMeters:
|
|
91
|
+
config.optimization.distanceFilterMeters ?? DEFAULT_DISTANCE_FILTER_METERS,
|
|
92
|
+
stopWhenStill:
|
|
93
|
+
config.optimization.stopWhenStill ?? DEFAULT_STOP_WHEN_STILL,
|
|
94
|
+
mode: config.optimization.mode ?? DEFAULT_MODE,
|
|
95
|
+
},
|
|
96
|
+
firebase: {
|
|
97
|
+
service: config.firebase.service,
|
|
98
|
+
targets: config.firebase.targets,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Internal Validation Helpers ─────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function validateOptimization(
|
|
106
|
+
optimization: unknown,
|
|
107
|
+
errors: ConfigError[]
|
|
108
|
+
): void {
|
|
109
|
+
if (optimization === undefined || optimization === null) {
|
|
110
|
+
errors.push({
|
|
111
|
+
field: 'optimization',
|
|
112
|
+
message: 'optimization is required and must be an object',
|
|
113
|
+
code: 'REQUIRED_FIELD',
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof optimization !== 'object') {
|
|
119
|
+
errors.push({
|
|
120
|
+
field: 'optimization',
|
|
121
|
+
message: 'optimization must be an object',
|
|
122
|
+
code: 'INVALID_TYPE',
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const opt = optimization as Record<string, unknown>;
|
|
128
|
+
|
|
129
|
+
// intervalMs (optional, but if provided must be positive number)
|
|
130
|
+
if (opt['intervalMs'] !== undefined) {
|
|
131
|
+
if (typeof opt['intervalMs'] !== 'number' || !isFinite(opt['intervalMs'] as number)) {
|
|
132
|
+
errors.push({
|
|
133
|
+
field: 'optimization.intervalMs',
|
|
134
|
+
message: 'intervalMs must be a finite number',
|
|
135
|
+
code: 'INVALID_TYPE',
|
|
136
|
+
});
|
|
137
|
+
} else if ((opt['intervalMs'] as number) <= 0) {
|
|
138
|
+
errors.push({
|
|
139
|
+
field: 'optimization.intervalMs',
|
|
140
|
+
message: 'intervalMs must be greater than 0',
|
|
141
|
+
code: 'OUT_OF_RANGE',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// distanceFilterMeters (optional, but if provided must be positive number)
|
|
147
|
+
if (opt['distanceFilterMeters'] !== undefined) {
|
|
148
|
+
if (
|
|
149
|
+
typeof opt['distanceFilterMeters'] !== 'number' ||
|
|
150
|
+
!isFinite(opt['distanceFilterMeters'] as number)
|
|
151
|
+
) {
|
|
152
|
+
errors.push({
|
|
153
|
+
field: 'optimization.distanceFilterMeters',
|
|
154
|
+
message: 'distanceFilterMeters must be a finite number',
|
|
155
|
+
code: 'INVALID_TYPE',
|
|
156
|
+
});
|
|
157
|
+
} else if ((opt['distanceFilterMeters'] as number) <= 0) {
|
|
158
|
+
errors.push({
|
|
159
|
+
field: 'optimization.distanceFilterMeters',
|
|
160
|
+
message: 'distanceFilterMeters must be greater than 0',
|
|
161
|
+
code: 'OUT_OF_RANGE',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// stopWhenStill (optional, but if provided must be boolean)
|
|
167
|
+
if (opt['stopWhenStill'] !== undefined) {
|
|
168
|
+
if (typeof opt['stopWhenStill'] !== 'boolean') {
|
|
169
|
+
errors.push({
|
|
170
|
+
field: 'optimization.stopWhenStill',
|
|
171
|
+
message: 'stopWhenStill must be a boolean',
|
|
172
|
+
code: 'INVALID_TYPE',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// mode (optional, but if provided must be 'interval', 'distance', or 'both')
|
|
178
|
+
if (opt['mode'] !== undefined) {
|
|
179
|
+
if (
|
|
180
|
+
typeof opt['mode'] !== 'string' ||
|
|
181
|
+
!(VALID_OPTIMIZATION_MODES as readonly string[]).includes(opt['mode'] as string)
|
|
182
|
+
) {
|
|
183
|
+
errors.push({
|
|
184
|
+
field: 'optimization.mode',
|
|
185
|
+
message: `optimization.mode must be 'interval', 'distance', or 'both'`,
|
|
186
|
+
code: 'INVALID_VALUE',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateFirebase(firebase: unknown, errors: ConfigError[]): void {
|
|
193
|
+
if (firebase === undefined || firebase === null) {
|
|
194
|
+
errors.push({
|
|
195
|
+
field: 'firebase',
|
|
196
|
+
message: 'firebase is required and must be an object',
|
|
197
|
+
code: 'REQUIRED_FIELD',
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof firebase !== 'object') {
|
|
203
|
+
errors.push({
|
|
204
|
+
field: 'firebase',
|
|
205
|
+
message: 'firebase must be an object',
|
|
206
|
+
code: 'INVALID_TYPE',
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const fb = firebase as Record<string, unknown>;
|
|
212
|
+
|
|
213
|
+
// Detect deprecated fields
|
|
214
|
+
validateDeprecatedFields(fb, errors);
|
|
215
|
+
|
|
216
|
+
// service (required, must be 'RTDB' or 'Firestore')
|
|
217
|
+
if (fb['service'] === undefined || fb['service'] === null) {
|
|
218
|
+
errors.push({
|
|
219
|
+
field: 'firebase.service',
|
|
220
|
+
message: "firebase.service is required and must be 'RTDB' or 'Firestore'",
|
|
221
|
+
code: 'REQUIRED_FIELD',
|
|
222
|
+
});
|
|
223
|
+
} else if (fb['service'] !== 'RTDB' && fb['service'] !== 'Firestore') {
|
|
224
|
+
errors.push({
|
|
225
|
+
field: 'firebase.service',
|
|
226
|
+
message: `firebase.service must be 'RTDB' or 'Firestore', got '${String(fb['service'])}'`,
|
|
227
|
+
code: 'INVALID_VALUE',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Validate targets array
|
|
232
|
+
validateTargets(fb['targets'], errors);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function validateDeprecatedFields(
|
|
236
|
+
fb: Record<string, unknown>,
|
|
237
|
+
errors: ConfigError[]
|
|
238
|
+
): void {
|
|
239
|
+
if (fb['currentLocationPath'] !== undefined) {
|
|
240
|
+
errors.push({
|
|
241
|
+
field: 'firebase.currentLocationPath',
|
|
242
|
+
message:
|
|
243
|
+
'currentLocationPath is deprecated. Migrate to the targets array by adding a SyncTarget with method \'set\'',
|
|
244
|
+
code: 'DEPRECATED_FIELD',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (fb['historyPath'] !== undefined) {
|
|
249
|
+
errors.push({
|
|
250
|
+
field: 'firebase.historyPath',
|
|
251
|
+
message:
|
|
252
|
+
'historyPath is deprecated. Migrate to the targets array by adding a SyncTarget with method \'push\'',
|
|
253
|
+
code: 'DEPRECATED_FIELD',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (fb['historyBatchSize'] !== undefined) {
|
|
258
|
+
errors.push({
|
|
259
|
+
field: 'firebase.historyBatchSize',
|
|
260
|
+
message:
|
|
261
|
+
'historyBatchSize is deprecated. Migrate to per-target batchSize in the targets array',
|
|
262
|
+
code: 'DEPRECATED_FIELD',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function validateTargets(targets: unknown, errors: ConfigError[]): void {
|
|
268
|
+
if (targets === undefined || targets === null || !Array.isArray(targets)) {
|
|
269
|
+
errors.push({
|
|
270
|
+
field: 'firebase.targets',
|
|
271
|
+
message: 'firebase.targets is required and must be an array',
|
|
272
|
+
code: 'REQUIRED_FIELD',
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (targets.length === 0) {
|
|
278
|
+
errors.push({
|
|
279
|
+
field: 'firebase.targets',
|
|
280
|
+
message: 'firebase.targets must contain at least 1 sync target',
|
|
281
|
+
code: 'INVALID_VALUE',
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (targets.length > MAX_TARGETS) {
|
|
287
|
+
errors.push({
|
|
288
|
+
field: 'firebase.targets',
|
|
289
|
+
message: `firebase.targets must contain at most ${MAX_TARGETS} sync targets, got ${targets.length}`,
|
|
290
|
+
code: 'INVALID_VALUE',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Validate each target
|
|
295
|
+
const seenPaths = new Set<string>();
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < targets.length; i++) {
|
|
298
|
+
const target = targets[i];
|
|
299
|
+
const prefix = `firebase.targets[${i}]`;
|
|
300
|
+
|
|
301
|
+
if (target === null || target === undefined || typeof target !== 'object') {
|
|
302
|
+
errors.push({
|
|
303
|
+
field: prefix,
|
|
304
|
+
message: `${prefix} must be an object`,
|
|
305
|
+
code: 'INVALID_TYPE',
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const t = target as Record<string, unknown>;
|
|
311
|
+
|
|
312
|
+
// Validate path
|
|
313
|
+
validateTargetPath(t['path'], i, prefix, errors, seenPaths);
|
|
314
|
+
|
|
315
|
+
// Validate method
|
|
316
|
+
validateTargetMethod(t['method'], i, prefix, errors);
|
|
317
|
+
|
|
318
|
+
// Validate batchSize (optional)
|
|
319
|
+
if (t['batchSize'] !== undefined) {
|
|
320
|
+
validateTargetBatchSize(t['batchSize'], i, prefix, errors);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate offlineQueue (optional)
|
|
324
|
+
if (t['offlineQueue'] !== undefined) {
|
|
325
|
+
validateTargetOfflineQueue(t['offlineQueue'], i, prefix, errors);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateTargetPath(
|
|
331
|
+
path: unknown,
|
|
332
|
+
_index: number,
|
|
333
|
+
prefix: string,
|
|
334
|
+
errors: ConfigError[],
|
|
335
|
+
seenPaths: Set<string>
|
|
336
|
+
): void {
|
|
337
|
+
if (path === undefined || path === null || typeof path !== 'string') {
|
|
338
|
+
errors.push({
|
|
339
|
+
field: `${prefix}.path`,
|
|
340
|
+
message: `${prefix}.path is required and must be a string`,
|
|
341
|
+
code: 'REQUIRED_FIELD',
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (path.trim().length === 0) {
|
|
347
|
+
errors.push({
|
|
348
|
+
field: `${prefix}.path`,
|
|
349
|
+
message: `${prefix}.path must not be empty or whitespace-only`,
|
|
350
|
+
code: 'INVALID_VALUE',
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (path.length > MAX_PATH_LENGTH) {
|
|
356
|
+
errors.push({
|
|
357
|
+
field: `${prefix}.path`,
|
|
358
|
+
message: `${prefix}.path must not exceed ${MAX_PATH_LENGTH} characters, got ${path.length}`,
|
|
359
|
+
code: 'INVALID_VALUE',
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Check for duplicate paths (case-sensitive)
|
|
365
|
+
if (seenPaths.has(path)) {
|
|
366
|
+
errors.push({
|
|
367
|
+
field: `${prefix}.path`,
|
|
368
|
+
message: `${prefix}.path '${path}' is a duplicate; each target must have a unique path`,
|
|
369
|
+
code: 'DUPLICATE_VALUE',
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
seenPaths.add(path);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateTargetMethod(
|
|
377
|
+
method: unknown,
|
|
378
|
+
_index: number,
|
|
379
|
+
prefix: string,
|
|
380
|
+
errors: ConfigError[]
|
|
381
|
+
): void {
|
|
382
|
+
if (method === undefined || method === null) {
|
|
383
|
+
errors.push({
|
|
384
|
+
field: `${prefix}.method`,
|
|
385
|
+
message: `${prefix}.method is required and must be 'set', 'push', or 'update'`,
|
|
386
|
+
code: 'REQUIRED_FIELD',
|
|
387
|
+
});
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (
|
|
392
|
+
typeof method !== 'string' ||
|
|
393
|
+
!(VALID_METHODS as readonly string[]).includes(method)
|
|
394
|
+
) {
|
|
395
|
+
errors.push({
|
|
396
|
+
field: `${prefix}.method`,
|
|
397
|
+
message: `${prefix}.method must be 'set', 'push', or 'update', got '${String(method)}'`,
|
|
398
|
+
code: 'INVALID_VALUE',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function validateTargetBatchSize(
|
|
404
|
+
batchSize: unknown,
|
|
405
|
+
_index: number,
|
|
406
|
+
prefix: string,
|
|
407
|
+
errors: ConfigError[]
|
|
408
|
+
): void {
|
|
409
|
+
if (
|
|
410
|
+
typeof batchSize !== 'number' ||
|
|
411
|
+
!isFinite(batchSize) ||
|
|
412
|
+
!Number.isInteger(batchSize)
|
|
413
|
+
) {
|
|
414
|
+
errors.push({
|
|
415
|
+
field: `${prefix}.batchSize`,
|
|
416
|
+
message: `${prefix}.batchSize must be a positive integer between 1 and ${MAX_BATCH_SIZE}`,
|
|
417
|
+
code: 'INVALID_VALUE',
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (batchSize < 1 || batchSize > MAX_BATCH_SIZE) {
|
|
423
|
+
errors.push({
|
|
424
|
+
field: `${prefix}.batchSize`,
|
|
425
|
+
message: `${prefix}.batchSize must be between 1 and ${MAX_BATCH_SIZE}, got ${batchSize}`,
|
|
426
|
+
code: 'OUT_OF_RANGE',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function validateTargetOfflineQueue(
|
|
432
|
+
offlineQueue: unknown,
|
|
433
|
+
_index: number,
|
|
434
|
+
prefix: string,
|
|
435
|
+
errors: ConfigError[]
|
|
436
|
+
): void {
|
|
437
|
+
if (typeof offlineQueue !== 'boolean') {
|
|
438
|
+
errors.push({
|
|
439
|
+
field: `${prefix}.offlineQueue`,
|
|
440
|
+
message: `${prefix}.offlineQueue must be a boolean`,
|
|
441
|
+
code: 'INVALID_TYPE',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function validateAndroidNotification(
|
|
447
|
+
notification: unknown,
|
|
448
|
+
errors: ConfigError[]
|
|
449
|
+
): void {
|
|
450
|
+
if (typeof notification !== 'object' || notification === null) {
|
|
451
|
+
errors.push({
|
|
452
|
+
field: 'androidNotification',
|
|
453
|
+
message: 'androidNotification must be an object',
|
|
454
|
+
code: 'INVALID_TYPE',
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const notif = notification as Record<string, unknown>;
|
|
460
|
+
|
|
461
|
+
// enabled (optional, but if provided must be boolean)
|
|
462
|
+
if (notif['enabled'] !== undefined && typeof notif['enabled'] !== 'boolean') {
|
|
463
|
+
errors.push({
|
|
464
|
+
field: 'androidNotification.enabled',
|
|
465
|
+
message: 'androidNotification.enabled must be a boolean',
|
|
466
|
+
code: 'INVALID_TYPE',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const isEnabled = notif['enabled'] !== false;
|
|
471
|
+
|
|
472
|
+
// title and text are only required when notification is enabled
|
|
473
|
+
if (isEnabled) {
|
|
474
|
+
// title (required, non-empty string)
|
|
475
|
+
if (
|
|
476
|
+
typeof notif['title'] !== 'string' ||
|
|
477
|
+
(notif['title'] as string).trim().length === 0
|
|
478
|
+
) {
|
|
479
|
+
errors.push({
|
|
480
|
+
field: 'androidNotification.title',
|
|
481
|
+
message: 'androidNotification.title must be a non-empty string',
|
|
482
|
+
code: 'REQUIRED_FIELD',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// text (required, non-empty string)
|
|
487
|
+
if (
|
|
488
|
+
typeof notif['text'] !== 'string' ||
|
|
489
|
+
(notif['text'] as string).trim().length === 0
|
|
490
|
+
) {
|
|
491
|
+
errors.push({
|
|
492
|
+
field: 'androidNotification.text',
|
|
493
|
+
message: 'androidNotification.text must be a non-empty string',
|
|
494
|
+
code: 'REQUIRED_FIELD',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function validateIOSNotification(
|
|
501
|
+
notification: unknown,
|
|
502
|
+
errors: ConfigError[]
|
|
503
|
+
): void {
|
|
504
|
+
if (typeof notification !== 'object' || notification === null) {
|
|
505
|
+
errors.push({
|
|
506
|
+
field: 'iosNotification',
|
|
507
|
+
message: 'iosNotification must be an object',
|
|
508
|
+
code: 'INVALID_TYPE',
|
|
509
|
+
});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const notif = notification as Record<string, unknown>;
|
|
514
|
+
|
|
515
|
+
// enabled (optional, but if provided must be boolean)
|
|
516
|
+
if (notif['enabled'] !== undefined && typeof notif['enabled'] !== 'boolean') {
|
|
517
|
+
errors.push({
|
|
518
|
+
field: 'iosNotification.enabled',
|
|
519
|
+
message: 'iosNotification.enabled must be a boolean',
|
|
520
|
+
code: 'INVALID_TYPE',
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const isEnabled = notif['enabled'] !== false;
|
|
525
|
+
|
|
526
|
+
// title and text are only required when notification is enabled
|
|
527
|
+
if (isEnabled) {
|
|
528
|
+
// title (required, non-empty string)
|
|
529
|
+
if (
|
|
530
|
+
typeof notif['title'] !== 'string' ||
|
|
531
|
+
(notif['title'] as string).trim().length === 0
|
|
532
|
+
) {
|
|
533
|
+
errors.push({
|
|
534
|
+
field: 'iosNotification.title',
|
|
535
|
+
message: 'iosNotification.title must be a non-empty string',
|
|
536
|
+
code: 'REQUIRED_FIELD',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// text (required, non-empty string)
|
|
541
|
+
if (
|
|
542
|
+
typeof notif['text'] !== 'string' ||
|
|
543
|
+
(notif['text'] as string).trim().length === 0
|
|
544
|
+
) {
|
|
545
|
+
errors.push({
|
|
546
|
+
field: 'iosNotification.text',
|
|
547
|
+
message: 'iosNotification.text must be a non-empty string',
|
|
548
|
+
code: 'REQUIRED_FIELD',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|