feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 650 Industries, Inc. (Expo)
Copyright (c) 2017 Segment, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,34 @@
# `@expo/rudder-sdk-node` ![CI](https://github.com/expo/rudder-sdk-node/actions/workflows/ci.yml/badge.svg)
A lightweight Node SDK for RudderStack with minimal dependencies. This is designed for client-side Node applications like CLIs. This library is smaller than [RudderStack's Node library](https://github.com/rudderlabs/rudder-sdk-node) and doesn't include support for the Redis persistence queue.
It is fully written in TypeScript and exports first-class type declarations to users of this package.
## Installation
```bash
$ npm install @expo/rudder-sdk-node
```
## Usage
```js
import Analytics from '@expo/rudder-sdk-node';
// Specify the batch endpoint of the Rudder server you are running
const client = new Analytics('write key', '<data-plane-uri>/v1/batch');
client.track({
event: 'event name',
userId: 'user id',
});
const flushResponse = await client.flush();
```
## Documentation
Look at the TypeScript type declarations and the source code of this library.
RudderStack's documentation for a different but related library is available [here](https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-node-sdk).

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env node
const program = require('commander');
const Analytics = require('.').default;
const pkg = require('./package');
const toObject = (str) => JSON.parse(str);
// node cli.js -w "write-key" -h "http://localhost" -t "identify" -u 'id
program
.version(pkg.version)
.option('-w, --writeKey <key>', 'the write key to use')
.option('-h, --host <host>', 'the API hostname to use')
.option('-t, --type <type>', 'the message type')
.option('-u, --userId <id>', 'the user id to send the event as')
.option('-a, --anonymousId <id>', 'the anonymous user id to send the event as')
.option('-c, --context <context>', 'additional context for the event (JSON-encoded)', toObject)
.option(
'-i, --integrations <integrations>',
'additional integrations for the event (JSON-encoded)',
toObject
)
.option('-e, --event <event>', 'the event name to send with the event')
.option('-p, --properties <properties>', 'the event properties to send (JSON-encoded)', toObject)
.option('-n, --name <name>', 'name of the screen or page to send with the message')
.option('-t, --traits <traits>', 'the identify/group traits to send (JSON-encoded)', toObject)
.option('-g, --groupId <groupId>', 'the group id')
.option('-pid, --previousId <previousId>', 'the previous id')
.parse(process.argv);
if (program.args.length !== 0) {
program.help();
}
const writeKey = program.writeKey;
const host = program.host;
const type = program.type;
const userId = program.userId;
const anonymousId = program.anonymousId;
const context = program.context;
const integrations = program.integrations;
const event = program.event;
const properties = program.properties;
const name = program.name;
const traits = program.traits;
const groupId = program.groupId;
const previousId = program.previousId;
const run = (method, args) => {
const analytics = new Analytics(writeKey, host, { flushAt: 1 });
analytics[method](args, (err) => {
if (err) {
console.error(err.stack);
process.exit(1);
}
});
};
switch (type) {
case 'track':
run('track', {
event,
properties,
userId,
anonymousId,
context,
integrations,
});
break;
case 'page':
run('page', {
name,
properties,
userId,
anonymousId,
context,
integrations,
});
break;
case 'screen':
run('screen', {
name,
properties,
userId,
anonymousId,
context,
integrations,
});
break;
case 'identify':
run('identify', {
traits,
userId,
anonymousId,
context,
integrations,
});
break;
case 'group':
run('group', {
groupId,
traits,
userId,
anonymousId,
context,
integrations,
});
break;
case 'alias':
run('alias', {
previousId,
userId,
anonymousId,
context,
integrations,
});
break;
default:
console.error('invalid type:', type);
process.exit(1);
}

View File

@@ -0,0 +1,147 @@
import bunyan from '@expo/bunyan';
export declare type AnalyticsMessage = AnalyticsIdentity & {
context?: {
[key: string]: unknown;
};
integrations?: {
[destination: string]: boolean;
};
properties?: {
[key: string]: unknown;
};
timestamp?: Date;
[key: string]: unknown;
};
export declare type AnalyticsIdentity = {
userId: string;
} | {
userId?: string;
anonymousId: string;
};
export declare type AnalyticsMessageCallback = (error?: Error) => void;
export declare type AnalyticsFlushCallback = (flushResponses: FlushResponse[]) => void;
declare type FlushResponse = {
error?: Error;
data: {
batch: AnalyticsPayload[];
sentAt: Date;
};
};
declare type AnalyticsPayload = {
messageId: string;
_metadata: any;
context: any;
type: string;
originalTimestamp: Date;
[key: string]: any;
};
export default class Analytics {
private readonly enable;
private inFlightFlush;
private readonly queue;
private readonly writeKey;
private readonly host;
private readonly timeout;
private readonly flushAt;
private readonly flushInterval;
private readonly maxFlushSizeInBytes;
private readonly maxQueueLength;
private readonly flushCallbacks;
private readonly flushResponses;
private finalMessageId;
private flushed;
private timer;
private readonly logger;
/**
* Initialize a new `Analytics` instance with your RudderStack project's `writeKey` and an
* optional dictionary of options.
*/
constructor(writeKey: string, dataPlaneURL: string, { enable, timeout, flushAt, flushInterval, maxFlushSizeInBytes, // defaults to ~3.9mb
maxQueueLength, logLevel, }?: {
enable?: boolean;
/**
* The network timeout (in milliseconds) for how long to wait for a request to complete when
* sending messages to the data plane. Omit or specify 0 or a negative value to disable
* timeouts.
*/
timeout?: number;
flushAt?: number;
flushInterval?: number;
maxFlushSizeInBytes?: number;
maxQueueLength?: number;
logLevel?: bunyan.LogLevel;
});
/**
* Sends an "identify" message that associates traits with a user.
*/
identify(message: AnalyticsMessage & {
traits?: {
[key: string]: unknown;
};
}, callback?: AnalyticsMessageCallback): Analytics;
traits?: {
[key: string]: unknown;
};
/**
* Sends a "group" message that identifies this user with a group.
*/
group(message: AnalyticsMessage & {
groupId: string;
traits?: {
[key: string]: unknown;
};
}, callback?: AnalyticsMessageCallback): Analytics;
/**
* Sends a "track" event that records an action.
*/
track(message: AnalyticsMessage & {
event: string;
}, callback?: AnalyticsMessageCallback): Analytics;
/**
* Sends a "page" event that records a page view on a website.
*/
page(message: AnalyticsMessage & {
name: string;
}, callback?: AnalyticsMessageCallback): Analytics;
/**
* Sends a "screen" event that records a screen view in an app.
*/
screen(message: AnalyticsMessage, callback?: AnalyticsMessageCallback): Analytics;
/**
* Sends an "alias" message that associates one ID with another.
*/
alias(message: {
previousId: string;
traits?: {
[key: string]: unknown;
};
} & AnalyticsIdentity, callback?: AnalyticsMessageCallback): Analytics;
private validate;
/**
* Adds a message of the specified type to the queue and flushes the queue if appropriate.
*/
private enqueue;
/**
* Flushes the message queue to the server immediately if a flush is not already in progress.
*/
flush(callback?: AnalyticsFlushCallback): Promise<FlushResponse[]>;
/**
* Flushes messages from the message queue to the server immediately. After the flush has finished,
* this checks for pending flushes and executes them. All data is rolled up into a single FlushResponse.
*/
private executeFlush;
/**
* Calculates the amount of time to wait before retrying a request, given the number of prior
* retries (excluding the initial attempt).
*
* @param priorRetryCount the number of prior retries, starting from zero
*/
private getExponentialDelay;
/**
* Returns whether to retry a request that failed with the given error or returned the given
* response.
*/
private isErrorRetryable;
private nullFlushResponse;
}
export {};

View File

@@ -0,0 +1,342 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const bunyan_1 = __importDefault(require("@expo/bunyan"));
const loosely_validate_event_1 = __importDefault(require("@segment/loosely-validate-event"));
const assert_1 = __importDefault(require("assert"));
const fetch_retry_1 = __importDefault(require("fetch-retry"));
const md5_1 = __importDefault(require("md5"));
const node_fetch_1 = __importDefault(require("node-fetch"));
const remove_trailing_slash_1 = __importDefault(require("remove-trailing-slash"));
const uuid_1 = require("uuid");
const version = require('./package.json').version;
const retryableFetch = (0, fetch_retry_1.default)(node_fetch_1.default);
const setImmediate = global.setImmediate || process.nextTick.bind(process);
class Analytics {
/**
* Initialize a new `Analytics` instance with your RudderStack project's `writeKey` and an
* optional dictionary of options.
*/
constructor(writeKey, dataPlaneURL, { enable = true, timeout = 0, flushAt = 20, flushInterval = 20000, maxFlushSizeInBytes = 1024 * 1000 * 3.9, // defaults to ~3.9mb
maxQueueLength = 1000, logLevel = bunyan_1.default.FATAL, } = {}) {
this.inFlightFlush = null;
this.queue = [];
this.flushCallbacks = [];
this.flushResponses = [];
this.finalMessageId = null;
this.flushed = false;
this.timer = null;
this.enable = enable;
(0, assert_1.default)(writeKey, `The project's write key must be specified`);
(0, assert_1.default)(dataPlaneURL, `The data plane URL must be specified`);
this.writeKey = writeKey;
this.host = (0, remove_trailing_slash_1.default)(dataPlaneURL);
this.timeout = timeout;
this.flushAt = Math.max(flushAt, 1);
this.flushInterval = flushInterval;
this.maxFlushSizeInBytes = maxFlushSizeInBytes;
this.maxQueueLength = maxQueueLength;
this.logger = bunyan_1.default.createLogger({
name: '@expo/rudder-node-sdk',
level: logLevel,
});
}
/**
* Sends an "identify" message that associates traits with a user.
*/
identify(message, callback) {
this.validate(message, 'identify');
this.enqueue('identify', message, callback);
return this;
}
/**
* Sends a "group" message that identifies this user with a group.
*/
group(message, callback) {
this.validate(message, 'group');
this.enqueue('group', message, callback);
return this;
}
/**
* Sends a "track" event that records an action.
*/
track(message, callback) {
this.validate(message, 'track');
this.enqueue('track', message, callback);
return this;
}
/**
* Sends a "page" event that records a page view on a website.
*/
page(message, callback) {
this.validate(message, 'page');
this.enqueue('page', message, callback);
return this;
}
/**
* Sends a "screen" event that records a screen view in an app.
*/
screen(message, callback) {
this.validate(message, 'screen');
this.enqueue('screen', message, callback);
return this;
}
/**
* Sends an "alias" message that associates one ID with another.
*/
alias(message, callback) {
this.validate(message, 'alias');
this.enqueue('alias', message, callback);
return this;
}
validate(message, type) {
try {
(0, loosely_validate_event_1.default)(message, type);
}
catch (e) {
if (e.message === 'Your message must be < 32kb.') {
this.logger.warn('Your message must be < 32KiB. This is currently surfaced as a warning. Please update your code.', message);
return;
}
throw e;
}
}
/**
* Adds a message of the specified type to the queue and flushes the queue if appropriate.
*/
enqueue(type, message, callback = () => { }) {
var _a, _b;
if (!this.enable) {
setImmediate(callback);
return;
}
if (this.queue.length >= this.maxQueueLength) {
this.logger.error(`Not adding events for processing as queue size ${this.queue.length} exceeds max configuration ${this.maxQueueLength}`);
setImmediate(callback);
return;
}
if (type === 'identify') {
(_a = message.traits) !== null && _a !== void 0 ? _a : (message.traits = {});
(_b = message.context) !== null && _b !== void 0 ? _b : (message.context = {});
message.context.traits = message.traits;
}
message = { ...message };
message.type = type;
message.context = {
library: {
name: '@expo/rudder-sdk-node',
version,
},
...message.context,
};
message._metadata = {
nodeVersion: process.versions.node,
...message._metadata,
};
if (!message.originalTimestamp) {
message.originalTimestamp = new Date();
}
if (!message.messageId) {
// We md5 the messaage to add more randomness. This is primarily meant
// for use in the browser where the uuid package falls back to Math.random()
// which is not a great source of randomness.
// Borrowed from analytics.js (https://github.com/segment-integrations/analytics.js-integration-segmentio/blob/a20d2a2d222aeb3ab2a8c7e72280f1df2618440e/lib/index.js#L255-L256).
message.messageId = `node-${(0, md5_1.default)(JSON.stringify(message))}-${(0, uuid_1.v4)()}`;
}
this.queue.push({ message, callback });
if (!this.flushed) {
this.flushed = true;
this.flush();
return;
}
const isDivisibleByFlushAt = this.queue.length % this.flushAt === 0;
if (isDivisibleByFlushAt) {
this.logger.debug(`flushAt reached, messageQueueLength is ${this.queue.length}, trying flush...`);
this.flush();
}
else if (this.flushInterval && !this.timer) {
// only start a timer if there are dangling items in the message queue
this.logger.debug('no existing flush timer, creating new one');
this.timer = setTimeout(this.flush.bind(this), this.flushInterval);
}
}
/**
* Flushes the message queue to the server immediately if a flush is not already in progress.
*/
async flush(callback = () => { }) {
this.logger.debug('in flush');
// will cause new messages to be rolled up into the in-flight flush
this.finalMessageId = this.queue.length
? this.queue[this.queue.length - 1].message.messageId
: null;
this.logger.trace('finalMessageId: ' + this.finalMessageId);
this.flushCallbacks.push(callback);
if (this.inFlightFlush) {
this.logger.debug('skipping flush, there is an in flight flush');
return await this.inFlightFlush;
}
this.inFlightFlush = this.executeFlush();
const flushResponse = await this.inFlightFlush;
this.logger.debug('resetting client flush state');
this.inFlightFlush = null;
this.finalMessageId = null;
this.logger.trace('===flushResponse===', flushResponse);
return flushResponse;
}
/**
* Flushes messages from the message queue to the server immediately. After the flush has finished,
* this checks for pending flushes and executes them. All data is rolled up into a single FlushResponse.
*/
async executeFlush(flushedItems = []) {
var _a;
this.logger.debug('in execute flush');
if (!this.enable) {
this.logger.debug('client not enabled, skipping flush');
this.flushResponses.splice(0, this.flushResponses.length);
const nullResponse = this.nullFlushResponse();
this.flushCallbacks
.splice(0, this.flushCallbacks.length)
.map((callback) => setImmediate(callback, nullResponse));
return nullResponse;
}
if (this.timer) {
this.logger.debug('cancelling existing timer...');
clearTimeout(this.timer);
this.timer = null;
}
if (!this.queue.length) {
this.logger.debug('queue is empty, nothing to flush');
this.flushResponses.splice(0, this.flushResponses.length);
const nullResponse = this.nullFlushResponse();
this.flushCallbacks
.splice(0, this.flushCallbacks.length)
.map((callback) => setImmediate(callback, nullResponse));
return nullResponse;
}
let flushSize = 0;
let spliceIndex = 0;
// guard against requests larger than 4mb
for (let i = 0; i < this.queue.length; i++) {
const item = this.queue[i];
const itemSize = JSON.stringify(item).length;
const exceededMaxFlushSize = flushSize + itemSize > this.maxFlushSizeInBytes;
if (exceededMaxFlushSize) {
break;
}
flushSize += itemSize;
spliceIndex++;
if (((_a = item.message.messageId) !== null && _a !== void 0 ? _a : null) === this.finalMessageId || !this.finalMessageId) {
break; // guard against flushing items added to the message queue during this flush
}
}
const itemsToFlush = this.queue.splice(0, spliceIndex);
const callbacks = itemsToFlush.map((item) => item.callback);
const currentBatchOfMessages = itemsToFlush.map((item) => {
// if someone mangles directly with queue
if (typeof item.message == 'object') {
item.message.sentAt = new Date();
}
return item.message;
});
const done = (err) => {
callbacks.forEach((callback_) => {
callback_(err);
});
const flushResponses = this.flushResponses.slice(0, this.flushResponses.length);
this.flushCallbacks
.splice(0, this.flushCallbacks.length)
.map((callback) => setImmediate(callback, flushResponses));
};
const data = {
batch: currentBatchOfMessages,
sentAt: new Date(),
};
this.logger.debug('batch size is ' + itemsToFlush.length);
this.logger.trace('===data===', data);
const req = {
method: 'POST',
headers: {
accept: 'application/json, text/plain, */*',
'content-type': 'application/json;charset=utf-8',
'user-agent': `expo-rudder-sdk-node/${version}`,
authorization: 'Basic ' + Buffer.from(`${this.writeKey}:`).toString('base64'),
},
body: JSON.stringify(data),
timeout: this.timeout > 0 ? this.timeout : undefined,
retryDelay: this.getExponentialDelay.bind(this),
retryOn: this.isErrorRetryable.bind(this),
};
let error = undefined;
try {
const response = await retryableFetch(`${this.host}`, req);
if (!response.ok) {
// handle 4xx 5xx errors
this.logger.error('request failed to send after 3 retries, dropping ' + itemsToFlush.length + ' events');
error = new Error(response.statusText);
}
}
catch (err) {
// handle network errors
this.logger.error('request failed to send after 3 retries, dropping ' + itemsToFlush.length + ' events');
error = err;
}
this.flushResponses.push({ error, data });
const finishedFlushing = currentBatchOfMessages[currentBatchOfMessages.length - 1].messageId === this.finalMessageId ||
!this.finalMessageId;
if (finishedFlushing) {
if (error) {
done(error);
}
else {
done();
}
return this.flushResponses.splice(0, this.flushResponses.length);
}
callbacks.forEach((callback_) => {
callback_(error);
});
return await this.executeFlush(flushedItems.concat(itemsToFlush));
}
/**
* Calculates the amount of time to wait before retrying a request, given the number of prior
* retries (excluding the initial attempt).
*
* @param priorRetryCount the number of prior retries, starting from zero
*/
getExponentialDelay(priorRetryCount) {
const delay = 2 ** priorRetryCount * 200;
const jitter = delay * 0.2 * Math.random(); // 0-20% of the delay
return delay + jitter;
}
/**
* Returns whether to retry a request that failed with the given error or returned the given
* response.
*/
isErrorRetryable(priorRetryCount, error, response) {
// 3 retries max
if (priorRetryCount > 2) {
return false;
}
return (
// Retry on any network error
!!error ||
// Retry if rate limited
response.status === 429 ||
// Retry on 5xx status codes due to server errors
(response.status >= 500 && response.status <= 599));
}
nullFlushResponse() {
return [
{
data: {
batch: [],
sentAt: new Date(),
},
},
];
}
}
exports.default = Analytics;
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,75 @@
{
"name": "@expo/rudder-sdk-node",
"version": "1.1.1",
"description": "Compact fork of rudder-node-sdk",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/expo/rudder-sdk-node.git"
},
"author": "Expo",
"engines": {
"node": ">=12"
},
"size-limit": [
{
"limit": "25 KB",
"path": "index.js"
}
],
"scripts": {
"dependencies": "yarn",
"size": "size-limit",
"watch": "tsc --watch",
"build": "tsc",
"prepare": "tsc",
"test": "nyc --reporter=lcov --reporter=html --reporter=text ava --serial --verbose > coverage.lcov",
"lint": "eslint .",
"changelog": "auto-changelog -p -t keepachangelog -u true -l false --sort-commits date-desc "
},
"main": "index.js",
"types": "index.d.ts",
"files": [
"cli.js",
"index.d.ts",
"index.js",
"index.js.map"
],
"keywords": [
"analytics"
],
"dependencies": {
"@expo/bunyan": "^4.0.0",
"@segment/loosely-validate-event": "^2.0.0",
"fetch-retry": "^4.1.1",
"md5": "^2.2.1",
"node-fetch": "^2.6.1",
"remove-trailing-slash": "^0.1.0",
"uuid": "^8.3.2"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@tsconfig/node12": "^1.0.9",
"@types/jest": "^27.0.1",
"@types/md5": "^2.3.1",
"@types/node-fetch": "^2.5.12",
"@types/uuid": "^8.3.1",
"auto-changelog": "^1.16.2",
"ava": "^0.25.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.17.1",
"commander": "^2.9.0",
"delay": "^4.2.0",
"eslint": "^7.32.0",
"eslint-config-universe": "^7.0.1",
"express": "^4.15.2",
"fetch-mock": "^9.11.0",
"jest": "^27.1.0",
"nyc": "^14.1.1",
"prettier": "^2.3.2",
"sinon": "^7.3.2",
"size-limit": "^1.3.5",
"ts-jest": "~27.0.5",
"typescript": "^4.3.5"
}
}