BigBrainParking/parksmarter-client/src/client.ts
Hank fb3d1cdf3a Detect failed logins; fix map camera recenter, marker contrast, dark map tiles
- Client: server returns 200 + Status:"Error" on bad credentials; loginWith* now
  throw LoginError with the server message instead of faking a signed-in state
- Map: uncontrolled camera positioned once on load + explicit actions only, so
  Search/marker-tap/re-renders no longer snap back to the user's location
- Markers: always-dark high-contrast bubble + zone-colored dot (some zones report
  a white BackgroundColor -> was white-on-white)
- Dark mode now switches map tiles to CARTO dark-matter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:27:41 -07:00

748 lines
24 KiB
TypeScript

/**
* ParkSmarterClient — a typed wrapper over the ParkSmarter (IPS Group) REST API.
*
* Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0,
* a React Native / Hermes build). All endpoint paths, header names, the app-token
* scheme, and request/response field names were recovered from the app bundle.
*
* Auth model (NOT OAuth bearer):
* - `Application_Token` : identifies the app build; required on every call.
* - `Auth_Token` : the logged-in user's token; returned by POST /api/Auth
* (login) in the body field `Auth_Token`, then sent as a
* header on authenticated calls.
* - `ParkSmarter_SessionId` : a server session id; seeded by
* GET /api/ApplicationValidity and login, echoed on some
* responses. Sent as a header when present.
* - `X-Request-Id` : a fresh UUID per request.
*
* The client persists Auth_Token and SessionId into the provided TokenStore
* automatically after login / bootstrap.
*/
import {
DEFAULT_ENVIRONMENT,
ENVIRONMENTS,
Environment,
EnvironmentName,
} from './environments.js';
import {
HttpClient,
MemoryTokenStore,
ParkSmarterResponse,
TokenStore,
} from './http.js';
import * as T from './types.js';
export interface ParkSmarterClientOptions {
/** Named environment (default 'prodv2') or a fully custom Environment. */
environment?: EnvironmentName | Environment;
/** Token persistence. Defaults to in-memory. Use secure storage in real apps. */
tokens?: TokenStore;
/** Locale sent as the `localeCode` query param (default 'en'). */
localeCode?: string;
timeoutMs?: number;
/** Provide a fetch impl for React Native / Node < 18 / tests. */
fetchImpl?: typeof fetch;
uuid?: () => string;
/** Called when any authenticated request returns 401 (token cleared automatically). */
onUnauthorized?: () => void;
/** Log every request/response (redacted) — useful for on-device debugging via logcat. */
logRequests?: boolean;
/** Where log lines go (default console.log). */
logSink?: (line: string) => void;
}
/** Thrown when credentials are rejected (the server signals this via a 200 + Status:"Error"). */
export class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = 'LoginError';
}
}
function resolveEnvironment(
env: EnvironmentName | Environment | undefined,
): Environment {
if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT];
if (typeof env === 'string') return ENVIRONMENTS[env];
return env;
}
export class ParkSmarterClient {
readonly http: HttpClient;
readonly tokens: TokenStore;
constructor(options: ParkSmarterClientOptions = {}) {
this.tokens = options.tokens ?? new MemoryTokenStore();
this.http = new HttpClient({
environment: resolveEnvironment(options.environment),
tokens: this.tokens,
localeCode: options.localeCode,
timeoutMs: options.timeoutMs,
fetchImpl: options.fetchImpl,
uuid: options.uuid,
onUnauthorized: options.onUnauthorized,
logRequests: options.logRequests,
logSink: options.logSink,
});
}
/** Set the locale used for the `localeCode` query param on subsequent calls. */
setLocale(localeCode: string): void {
this.http.setLocaleCode(localeCode);
}
/** Toggle redacted request/response logging at runtime. */
setLogRequests(on: boolean): void {
this.http.setLogRequests(on);
}
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
return p.then((r) => r.data);
}
/* ============================================================== */
/* Bootstrap / app validity */
/* ============================================================== */
/**
* GET /api/ApplicationValidity — call this first. Returns feature flags,
* maintenance/upgrade status, and a SessionId that is stored automatically.
* Public (no Auth_Token required).
*/
async getApplicationValidity(): Promise<T.ApplicationValidityResponse> {
const res = await this.http.request<T.ApplicationValidityResponse>({
method: 'GET',
path: '/api/ApplicationValidity',
query: { ApplicationToken: this.http.environment.appToken },
includeAuthToken: false,
});
return res.data;
}
/* ============================================================== */
/* Auth */
/* ============================================================== */
/**
* The server returns HTTP 200 even for a FAILED login, signalling the failure
* only via `Status: "Error"` + a null `Auth_Token`. Detect that here so callers
* get a real error instead of a phantom "signed-in" state.
*/
private finishLogin(data: T.AuthResponse): Promise<T.AuthResponse> {
const token = typeof data?.Auth_Token === 'string' ? data.Auth_Token : '';
if (!token || data?.Status === 'Error') {
throw new LoginError(data?.Message || 'Invalid login or password.');
}
return this.tokens.setAuthToken
? Promise.resolve(this.tokens.setAuthToken(token)).then(() => data)
: Promise.resolve(data);
}
/** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: { UserName: params.phoneNumber, Password: params.password },
includeAuthToken: false,
});
return this.finishLogin(res.data);
}
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
async loginWithApple(params: T.LoginWithAppleParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
method: 'POST',
path: '/api/Auth',
body: {
EmailAddress: params.emailAddress,
AppleUserId: params.appleId,
ProviderAuth: params.providerAuth,
ProviderIdentity: params.providerIdentity,
},
includeAuthToken: false,
});
return this.finishLogin(res.data);
}
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */
async loginWithCachedToken(authToken: string): Promise<void> {
await this.tokens.setAuthToken(authToken);
}
/** POST /api/Auth/Logout — invalidate the token on all devices. */
async logoutAllDevices(): Promise<void> {
await this.http.request({ method: 'POST', path: '/api/Auth/Logout' });
}
/** Clear local tokens (client-side sign out). */
async logoutLocal(): Promise<void> {
await this.tokens.setAuthToken(null);
await this.tokens.setSessionId(null);
}
/* ============================================================== */
/* Sign up, password, verification */
/* ============================================================== */
/** POST /api/User — create an account. Public. */
async signUp(params: T.SignUpParams): Promise<boolean> {
await this.http.request({
method: 'POST',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.mobilePhone,
Password: params.password,
},
includeAuthToken: false,
});
return true;
}
/** POST /api/Password — request a password reset. Public. */
async requestResetPassword(
params: T.RequestResetPasswordParams,
): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Password',
body: {
ResetPasswordType: params.reqType,
PersonalEmailAddress: '',
PersonalPhone: params.phoneNumber,
},
includeAuthToken: false,
});
}
/** PUT /api/Password — change password (authenticated). */
async updatePassword(params: T.UpdatePasswordParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/Password',
body: { OldPassword: params.oldPassword, NewPassword: params.newPassword },
});
}
/** POST /api/UserVerification — request an SMS verification code. */
async requestVerifyUser(params: T.RequestVerifyUserParams): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/UserVerification',
body: { PhoneNumber: '1' + params.phoneNumber },
});
}
/** GET /api/UserVerification — confirm an SMS code. */
async verifyUser(params: T.VerifyUserParams): Promise<void> {
await this.http.request({
method: 'GET',
path: '/api/UserVerification',
query: {
PhoneNumber: '1' + params.phoneNumber,
ConfirmationCode: params.code,
},
});
}
/** GET /api/User?EmailAddress=… — is this email already registered? */
async isEmailRegistered(emailAddress: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: emailAddress },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalEmailAddress);
}
/** GET /api/User?PhoneNumber=… — is this phone already registered? */
async isPhoneRegistered(phoneNumber: string): Promise<boolean> {
const res = await this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { PhoneNumber: phoneNumber },
includeAuthToken: false,
});
return Boolean(res.data?.PersonalPhone);
}
/* ============================================================== */
/* User profile */
/* ============================================================== */
/** GET /api/User — full profile incl. vehicles & cards (authenticated). */
getUserDetail(): Promise<T.UserDetail> {
return this.unwrap(
this.http.request<T.UserDetail>({
method: 'GET',
path: '/api/User',
query: { EmailAddress: '', PhoneNumber: '' },
}),
);
}
/** PUT /api/User — update email/phone (authenticated). */
async updateProfile(params: T.UpdateProfileParams): Promise<void> {
await this.http.request({
method: 'PUT',
path: '/api/User',
body: {
PersonalEmailAddress: params.emailAddress,
PersonalPhone: params.phoneNumber,
},
});
}
/** DELETE /api/User — request account deletion (authenticated). */
async requestDeleteUser(): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/User',
query: { DeviceType: 'Android' },
});
}
/** PUT /api/Device — register/update the push device token. */
async updateDeviceToken(
params: T.UpdateDeviceTokenParams,
): Promise<T.UpdateDeviceTokenResponse> {
return this.unwrap(
this.http.request<T.UpdateDeviceTokenResponse>({
method: 'PUT',
path: '/api/Device',
body: {
DeviceID: params.pushNotificationsToken,
IMEINumber: params.imeiNumber,
DeviceType: params.deviceType ?? '1',
Language: params.language,
},
}),
);
}
/* ============================================================== */
/* Vehicles */
/* ============================================================== */
/** POST /api/Vehicle — add a vehicle. */
async addVehicle(params: T.AddVehicleParams): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'POST',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */
async updateVehicle(
params: T.UpdateVehicleParams,
): Promise<T.VehicleMutationResponse> {
return this.unwrap(
this.http.request<T.VehicleMutationResponse>({
method: 'PUT',
path: '/api/Vehicle',
body: {
VehiclePlate: params.plate,
VehicleState: params.state,
VehicleID: params.id,
VehicleAlias: params.vehicleAlias,
IsDefault: params.isDefaultVehicle ?? false,
},
}),
);
}
/** DELETE /api/Vehicle?VehicleID=… */
async deleteVehicle(vehicleId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Vehicle',
query: { VehicleID: vehicleId },
});
}
/* ============================================================== */
/* Credit cards */
/* ============================================================== */
/** POST /api/Card — add a card. */
async addCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** POST /api/Card — update a card (full replace). */
async updateCard(params: T.AddCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'POST',
path: '/api/Card',
body: this.buildCardBody(params),
}),
);
}
/** PUT /api/Card — set (or unset) a card as default. */
async setCardDefault(params: T.SetDefaultCardParams): Promise<unknown> {
return this.unwrap(
this.http.request({
method: 'PUT',
path: '/api/Card',
body: { CCID: params.id, CCDefault: params.isDefaultCard },
}),
);
}
/** DELETE /api/Card?CCID=… */
async deleteCard(cardId: number | string): Promise<void> {
await this.http.request({
method: 'DELETE',
path: '/api/Card',
query: { CCID: cardId },
});
}
private buildCardBody(p: T.AddCardParams) {
return {
CCNumber: p.cardNumber,
CCAlias: p.alias,
CCExpDate: p.expDate,
CCZip: p.zipCode,
CCDefault: p.isDefaultCard ?? false,
ParentCCID: p.id,
};
}
/* ============================================================== */
/* Meters / zones */
/* ============================================================== */
/** GET /api/Meter?Lat=&Long= — meters near a coordinate. */
getMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */
getLimitedMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/MeterList',
query: { Lat: loc.latitude, Long: loc.longitude },
}),
);
}
/** GET /api/Meter?ZoneName=… */
getMetersByZoneName(zoneName: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ZoneName: zoneName },
}),
);
}
/** GET /api/Meter?Query=… — zone or space name search. */
searchMetersByZoneOrSpace(query: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { Query: query },
}),
);
}
/** GET /api/Meter?TerminalSerNo=… */
getMetersBySerialNumber(serialNumber: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { TerminalSerNo: serialNumber },
}),
);
}
/** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */
getMetersByScannerCode(scannerCode: string): Promise<T.MetersResponse> {
return this.unwrap(
this.http.request<T.MetersResponse>({
method: 'GET',
path: '/api/Meter',
query: { ScannerCode: scannerCode },
}),
);
}
/** GET /api/ParkingLogix — nearby parking lots with occupancy. */
getParkingLots(): Promise<T.ParkingLotsResponse> {
return this.unwrap(
this.http.request<T.ParkingLotsResponse>({
method: 'GET',
path: '/api/ParkingLogix',
}),
);
}
/* ============================================================== */
/* Estimates */
/* ============================================================== */
/** GET /api/ParkingEstimateMulti — multi-duration price ladder. */
getParkingEstimateMulti(
params: T.MultiEstimateParams,
): Promise<T.MultiEstimateResponse> {
const query: Record<string, unknown> = {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
MinCreditAmount: params.minCreditAmount,
VehicleID: params.vehicleId,
};
if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes;
return this.unwrap(
this.http.request<T.MultiEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateMulti',
query,
}),
);
}
/** GET /api/ParkingEstimate — single-duration price. */
getParkingEstimateSingle(
params: T.SingleEstimateParams,
): Promise<T.SingleEstimateResponse> {
return this.unwrap(
this.http.request<T.SingleEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimate',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
ParkingDuration: params.durationInMinutes,
VehicleID: params.vehicleId,
CCID: params.creditCardId,
},
}),
);
}
/** GET /api/ParkingEstimateItems — item-based price options. */
getParkingEstimateItems(
params: T.ItemsEstimateParams,
): Promise<T.ItemsEstimateResponse> {
return this.unwrap(
this.http.request<T.ItemsEstimateResponse>({
method: 'GET',
path: '/api/ParkingEstimateItems',
query: {
ZoneID: params.zoneId,
SpaceID: params.spaceId,
CustomerID: params.customerId,
VehicleID: params.vehicleId,
},
}),
);
}
/* ============================================================== */
/* Parking sessions */
/* ============================================================== */
/** POST /api/Session — start/pay for a parking session. */
startParkingSession(
params: T.StartParkingSessionParams,
): Promise<T.StartParkingSessionResponse> {
const fee = params.transactionFee ?? 0;
const body: Record<string, unknown> = {
CCID: String(params.creditCardId),
Amount: (params.parkingCost + fee).toFixed(2),
SpaceID: params.spaceId,
StartTime: params.startTime,
EndTime: params.endTime,
CustomerID: params.customerId,
VehicleID: String(params.vehicleId),
TimePurchased: String(params.minutesToPurchase),
ParkingCost: params.parkingCost.toFixed(2),
TransactionFee: fee.toFixed(2),
ZoneID: params.zoneId,
MinCreditAmount:
params.minCreditAmount != null
? params.minCreditAmount.toFixed(2)
: undefined,
MeterTypeId: params.meterTypeId,
};
if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
return this.unwrap(
this.http.request<T.StartParkingSessionResponse>({
method: 'POST',
path: '/api/Session',
body,
}),
);
}
/** GET /api/ParkingSession — currently active sessions. */
getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {
return this.unwrap(
this.http.request<T.ActiveSessionsResponse>({
method: 'GET',
path: '/api/ParkingSession',
}),
);
}
/** GET /api/Session — past sessions (paged). */
getPastParkingSessions(
params: T.PastSessionsParams = {},
): Promise<T.PastSessionsResponse> {
return this.unwrap(
this.http.request<T.PastSessionsResponse>({
method: 'GET',
path: '/api/Session',
query: {
CurrentPage: params.currentPage ?? '',
PageSize: params.pageSize ?? '',
},
}),
);
}
/* ============================================================== */
/* Receipts */
/* ============================================================== */
/** GET /api/ParkingReceipt?TransactionID=… */
getParkingReceipt(transactionId: number | string): Promise<T.ReceiptResponse> {
return this.unwrap(
this.http.request<T.ReceiptResponse>({
method: 'GET',
path: '/api/ParkingReceipt',
query: { TransactionID: transactionId },
}),
);
}
/** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */
async emailParkingReceipt(receiptId: number | string): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/ParkingReceipt',
query: { Id: receiptId },
});
}
/* ============================================================== */
/* Notification settings */
/* ============================================================== */
/** GET /api/Setting — raw notification settings bitmask. */
getNotificationSettings(): Promise<T.RawSettingsResponse> {
return this.unwrap(
this.http.request<T.RawSettingsResponse>({
method: 'GET',
path: '/api/Setting',
}),
);
}
/**
* POST /api/Setting — update notification settings.
* `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings.
*/
async setNotificationSettings(userSettings: string | number): Promise<void> {
await this.http.request({
method: 'POST',
path: '/api/Setting',
body: { UserSettings: String(userSettings) },
});
}
/* ============================================================== */
/* Static content */
/* ============================================================== */
/**
* GET /api/State?ID=… — states/regions under a parent id.
* The `ID` param is required — the route 404s without it (confirmed against prod).
*/
getStates(id: number | string): Promise<T.StatesResponse> {
return this.unwrap(
this.http.request<T.StatesResponse>({
method: 'GET',
path: '/api/State',
query: { ID: id },
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterAbout — `{ Value }`. */
getAbout(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterAbout',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */
getFAQ(): Promise<T.FAQResponse> {
return this.unwrap(
this.http.request<T.FAQResponse>({
method: 'GET',
path: '/api/ParkSmarterFAQ',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */
getPrivacyPolicy(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterPrivacyPolicies',
includeAuthToken: false,
}),
);
}
/** GET /api/ParkSmarterTerms — `{ Value }`. */
getTerms(): Promise<T.ValueResponse> {
return this.unwrap(
this.http.request<T.ValueResponse>({
method: 'GET',
path: '/api/ParkSmarterTerms',
includeAuthToken: false,
}),
);
}
}