feat: initialize ingest-api with Koa and telemetry handling

- Create ingest-api with Koa framework for handling device telemetry.
- Implement telemetry ingestion with MongoDB, Redis, and PostgreSQL.
- Add API endpoints for telemetry and health checks.
- Set up testing with Vitest for unit and end-to-end tests.
- Configure Prettier and ESLint for code formatting and linting.
- Create Docker Compose setup for PostgreSQL, MongoDB, and Redis services.
- Establish contracts package for shared Zod schemas and TypeScript types.
- Implement React frontend with device status and greeting functionalities.
- Add VS Code setup documentation for onboarding developers.
This commit is contained in:
marco.chan committed 2026-09-03 10:13:02 +08:00
1 parent 534fa6dc51
commit 7923ba1b8d
70 files changed
+3085 -3434

No files matched your search

-114
View File
@@ -1,114 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ yarn install
```
## Compile and run the project
```bash
# development
$ yarn run start
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prod
```
## Run tests
```bash
# unit tests
$ yarn run test
# e2e tests
$ yarn run test:e2e
# test coverage
$ yarn run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ yarn install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Observability
In production applications, observability is essential for understanding how your system behaves, detecting issues early, and maintaining reliable performance.
[NestJS Observe](https://observe.nestjs.com) automatically instruments your NestJS application, giving you deep visibility into your system with minimal setup:
- **Distributed tracing:** Follow requests across services and understand how they flow through your system.
- **Waterfall analysis:** Visualize request execution and identify slow operations, bottlenecks, and unexpected delays.
- **Performance analysis:** Analyze application performance in real time and quickly pinpoint areas that need optimization.
- **Metrics:** Track key application and infrastructure metrics to understand system health and performance trends.
- **Logging:** Centralize and correlate logs with traces and other telemetry to make debugging easier.
- **Error tracking:** Detect errors quickly and investigate their root causes with the surrounding context.
- **SLA monitoring:** Track service-level objectives and identify when your application is approaching or exceeding defined thresholds.
- **Alarms and alerts:** Set up alerts for critical errors, performance degradation, SLA violations, and other anomalies so your team can react quickly.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Auto-instrument your application with [NestJS Observer](https://observer.nestjs.com). Distributed tracing, metrics, and logging made easy. Error tracking and performance monitoring for your NestJS applications.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-48
View File
@@ -1,48 +0,0 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-fastify": "^12.0.1",
"fastify": "^5.12.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
}
}
-22
View File
@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service.js';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
-12
View File
@@ -1,12 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
await bootstrap();
-29
View File
@@ -1,29 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module.js';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
// Resolves the path aliases declared in tsconfig.json, including the ones
// added by `nest g library`.
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
+3
View File
@@ -0,0 +1,3 @@
# crud-api
Express + Drizzle. User-facing HTTP. Reads `device_current_state` from Postgres and optional Redis presence. Does not accept device telemetry.
File renamed without changes.
+36
View File
@@ -0,0 +1,36 @@
{
"name": "crud-api",
"version": "0.0.1",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "tsx src/main.ts",
"start:dev": "tsx watch src/main.ts",
"start:debug": "tsx watch --inspect=9229 src/main.ts",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"contracts": "workspace:*",
"cors": "^2",
"drizzle-orm": "^0.44.5",
"express": "^4",
"ioredis": "^5",
"pg": "^8"
},
"devDependencies": {
"@types/cors": "^2",
"@types/express": "^4",
"@types/node": "^24.0.0",
"@types/pg": "^8",
"@types/supertest": "^7.0.0",
"oxlint": "^1.58.0",
"supertest": "^7.0.0",
"tsx": "^4",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
}
}
+52
View File
@@ -0,0 +1,52 @@
import request from 'supertest';
import { emptyStatus, toDeviceStatus } from './status.js';
import { createApp } from './app.js';
import { createMemoryStores } from './stores.js';
describe('crud-api', () => {
const app = createApp(createMemoryStores());
it('returns Hello World on GET /', async () => {
await request(app).get('/').expect(200).expect('Hello World!');
});
it('returns a greeting on GET /greeting', async () => {
await request(app)
.get('/greeting')
.query({ name: 'Marco' })
.expect(200)
.expect({ message: 'Hello, Marco' });
});
it('rejects an empty greeting name', async () => {
await request(app).get('/greeting').query({ name: '' }).expect(400);
});
it('returns missing status when the device is unknown', async () => {
const response = await request(app).get('/devices/dev-1/status').expect(200);
expect(response.body.postgres.source).toBe('missing');
expect(response.body.redis.online).toBe(false);
});
it('returns postgres snapshot from the store', async () => {
const lastSeen = new Date();
const appWithSeed = createApp(
createMemoryStores({
'dev-1': {
postgres: toDeviceStatus({
deviceId: 'dev-1',
lastSeen,
payload: { temp: 21 },
source: 'postgres',
}),
redis: emptyStatus('dev-1').redis,
},
}),
);
const response = await request(appWithSeed).get('/devices/dev-1/status').expect(200);
expect(response.body.postgres.online).toBe(true);
expect(response.body.postgres.payload).toEqual({ temp: 21 });
expect(response.body.postgres.source).toBe('postgres');
});
});
+40
View File
@@ -0,0 +1,40 @@
import cors from 'cors';
import {
deviceStatusPairSchema,
greetingOutputSchema,
greetingQuerySchema,
} from 'contracts';
import express from 'express';
import { createDeviceStores, type DeviceStores } from './stores.js';
export function createApp(stores: DeviceStores = createDeviceStores()): express.Express {
const app = express();
app.use(cors());
app.use(express.json());
app.get('/', (_req, res) => {
res.type('text/plain').send('Hello World!');
});
app.get('/greeting', (req, res) => {
const parsed = greetingQuerySchema.safeParse({
name: typeof req.query.name === 'string' ? req.query.name : '',
});
if (!parsed.success) {
res.status(400).json({ error: parsed.error.flatten() });
return;
}
res.json(greetingOutputSchema.parse({ message: `Hello, ${parsed.data.name}` }));
});
app.get('/devices/:id/status', async (req, res) => {
const deviceId = req.params.id;
const pair = deviceStatusPairSchema.parse(await stores.readStatus(deviceId));
res.json(pair);
});
return app;
}
+8
View File
@@ -0,0 +1,8 @@
import { jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const deviceCurrentState = pgTable('device_current_state', {
deviceId: text('device_id').primaryKey(),
payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
lastSeen: timestamp('last_seen', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
});
+6
View File
@@ -0,0 +1,6 @@
export const env = {
databaseUrl: process.env.DATABASE_URL ?? 'postgres://app:app@127.0.0.1:5432/app',
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
port: Number(process.env.CRUD_PORT ?? process.env.PORT ?? 3000),
host: process.env.HOST ?? '0.0.0.0',
};
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from './app.js';
import { env } from './env.js';
import { createDeviceStores } from './stores.js';
const stores = createDeviceStores();
const app = createApp(stores);
const server = app.listen(env.port, env.host, () => {
console.log(`crud-api listening on http://${env.host}:${env.port}`);
});
async function shutdown() {
server.close();
await stores.close();
}
process.on('SIGINT', () => {
void shutdown().then(() => process.exit(0));
});
process.on('SIGTERM', () => {
void shutdown().then(() => process.exit(0));
});
+49
View File
@@ -0,0 +1,49 @@
import {
PRESENCE_TTL_SECONDS,
type DeviceStatus,
type DeviceStatusPair,
} from 'contracts';
export function redisKey(deviceId: string): string {
return `device:${deviceId}`;
}
export function toDeviceStatus(input: {
deviceId: string;
lastSeen: Date | null;
payload: Record<string, unknown>;
source: DeviceStatus['source'];
now?: Date;
}): DeviceStatus {
const now = input.now ?? new Date();
const ageMs =
input.lastSeen === null ? null : Math.max(0, now.getTime() - input.lastSeen.getTime());
const withinTtl = ageMs !== null && ageMs <= PRESENCE_TTL_SECONDS * 1000;
const missing = input.lastSeen === null;
return {
deviceId: input.deviceId,
lastSeen: input.lastSeen?.toISOString() ?? null,
payload: input.payload,
online: !missing && withinTtl,
source: missing ? 'missing' : input.source,
ageMs,
};
}
export function emptyStatus(deviceId: string): DeviceStatusPair {
return {
postgres: toDeviceStatus({
deviceId,
lastSeen: null,
payload: {},
source: 'postgres',
}),
redis: toDeviceStatus({
deviceId,
lastSeen: null,
payload: {},
source: 'redis',
}),
};
}
+86
View File
@@ -0,0 +1,86 @@
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import Redis from 'ioredis';
import pg from 'pg';
import type { DeviceStatusPair } from 'contracts';
import { env } from './env.js';
import { deviceCurrentState } from './db/schema.js';
import { emptyStatus, redisKey, toDeviceStatus } from './status.js';
export type DeviceStores = {
readStatus(deviceId: string): Promise<DeviceStatusPair>;
close(): Promise<void>;
};
type RedisSnapshot = {
lastSeen: string;
payload: Record<string, unknown>;
};
export function createDeviceStores(): DeviceStores {
const pool = new pg.Pool({ connectionString: env.databaseUrl });
const db = drizzle(pool);
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
return {
async readStatus(deviceId: string): Promise<DeviceStatusPair> {
const pair = emptyStatus(deviceId);
try {
const rows = await db
.select()
.from(deviceCurrentState)
.where(eq(deviceCurrentState.deviceId, deviceId))
.limit(1);
const row = rows[0];
if (row) {
pair.postgres = toDeviceStatus({
deviceId,
lastSeen: row.lastSeen,
payload: row.payload,
source: 'postgres',
});
}
} catch (error) {
console.error('postgres status read failed', error);
}
try {
if (redis.status === 'wait') {
await redis.connect();
}
const raw = await redis.get(redisKey(deviceId));
if (raw) {
const snapshot = JSON.parse(raw) as RedisSnapshot;
pair.redis = toDeviceStatus({
deviceId,
lastSeen: new Date(snapshot.lastSeen),
payload: snapshot.payload ?? {},
source: 'redis',
});
}
} catch (error) {
console.error('redis status read failed', error);
}
return pair;
},
async close() {
await Promise.allSettled([pool.end(), redis.quit()]);
},
};
}
export function createMemoryStores(
seed: Partial<Record<string, DeviceStatusPair>> = {},
): DeviceStores {
return {
async readStatus(deviceId: string) {
return seed[deviceId] ?? emptyStatus(deviceId);
},
async close() {
return;
},
};
}
+15
View File
@@ -0,0 +1,15 @@
import request from 'supertest';
import { createApp } from '../src/app.js';
import { createMemoryStores } from '../src/stores.js';
describe('crud-api (e2e)', () => {
const app = createApp(createMemoryStores());
it('GET /greeting', async () => {
await request(app)
.get('/greeting')
.query({ name: 'Marco' })
.expect(200)
.expect({ message: 'Hello, Marco' });
});
});
@@ -1,7 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
@@ -7,8 +7,6 @@
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
@@ -16,7 +14,7 @@
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"types": ["vitest/globals", "node"]
}
},
"include": ["src", "test"]
}
@@ -1,8 +1,6 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
root: './',
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
# ingest-api
Koa device ingest. `POST /telemetry` writes raw events to Mongo, a TTL key to Redis, and upserts `device_current_state` in Postgres. Dual-write is a demo shortcut — production should derive Redis/Postgres from a queue.
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/rules.rs",
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-floating-promises": "warn"
},
"env": {
"node": true
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "ingest-api",
"version": "0.0.1",
"description": "Koa device ingest API",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "tsx src/main.ts",
"start:dev": "tsx watch src/main.ts",
"start:debug": "tsx watch --inspect=9230 src/main.ts",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@koa/cors": "^5.0.0",
"@koa/router": "^12",
"contracts": "workspace:*",
"ioredis": "^5",
"koa": "^2",
"koa-body": "^6",
"mongodb": "^6",
"pg": "^8"
},
"devDependencies": {
"@types/koa": "^2",
"@types/koa__cors": "^5.0.1",
"@types/koa__router": "^12",
"@types/node": "^24.0.0",
"@types/pg": "^8",
"@types/supertest": "^7.0.0",
"oxlint": "^1.58.0",
"supertest": "^7.0.0",
"tsx": "^4",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
}
}
+38
View File
@@ -0,0 +1,38 @@
import request from 'supertest';
import type { TelemetryIngest } from 'contracts';
import { createApp } from './app.js';
import { createMemoryWriter } from './stores.js';
describe('ingest-api', () => {
const writes: TelemetryIngest[] = [];
const app = createApp(createMemoryWriter(writes));
it('GET /', async () => {
await request(app.callback()).get('/').expect(200).expect('ingest-api');
});
it('accepts telemetry', async () => {
writes.length = 0;
const body = {
deviceId: 'dev-1',
sentAt: new Date().toISOString(),
payload: { temp: 21 },
};
await request(app.callback())
.post('/telemetry')
.send(body)
.expect(200)
.expect({
accepted: true,
writes: { mongo: true, redis: true, postgres: true },
});
expect(writes).toHaveLength(1);
expect(writes[0]?.deviceId).toBe('dev-1');
});
it('rejects invalid telemetry', async () => {
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
});
});
+39
View File
@@ -0,0 +1,39 @@
import cors from '@koa/cors';
import Router from '@koa/router';
import { telemetryIngestSchema } from 'contracts';
import Koa from 'koa';
import { koaBody } from 'koa-body';
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
const app = new Koa();
const router = new Router();
app.use(cors());
app.use(koaBody());
router.get('/', (ctx) => {
ctx.body = 'ingest-api';
});
router.get('/health', (ctx) => {
ctx.body = { ok: true };
});
router.post('/telemetry', async (ctx) => {
const parsed = telemetryIngestSchema.safeParse(ctx.request.body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
return;
}
const writes = await writer.write(parsed.data);
ctx.body = { accepted: true, writes };
});
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
+7
View File
@@ -0,0 +1,7 @@
export const env = {
databaseUrl: process.env.DATABASE_URL ?? 'postgres://app:app@127.0.0.1:5432/app',
mongoUrl: process.env.MONGO_URL ?? 'mongodb://127.0.0.1:27017/app',
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
port: Number(process.env.INGEST_PORT ?? process.env.PORT ?? 3001),
host: process.env.HOST ?? '0.0.0.0',
};
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from './app.js';
import { env } from './env.js';
import { createTelemetryWriter } from './stores.js';
const writer = createTelemetryWriter();
const app = createApp(writer);
const server = app.listen(env.port, env.host, () => {
console.log(`ingest-api listening on http://${env.host}:${env.port}`);
});
async function shutdown() {
server.close();
await writer.close();
}
process.on('SIGINT', () => {
void shutdown().then(() => process.exit(0));
});
process.on('SIGTERM', () => {
void shutdown().then(() => process.exit(0));
});
+100
View File
@@ -0,0 +1,100 @@
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
import Redis from 'ioredis';
import { MongoClient } from 'mongodb';
import pg from 'pg';
import { env } from './env.js';
export type WriteReport = {
mongo: boolean;
redis: boolean;
postgres: boolean;
};
export type TelemetryWriter = {
write(event: TelemetryIngest): Promise<WriteReport>;
close(): Promise<void>;
};
function redisKey(deviceId: string): string {
return `device:${deviceId}`;
}
export function createTelemetryWriter(): TelemetryWriter {
const mongo = new MongoClient(env.mongoUrl);
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const pool = new pg.Pool({ connectionString: env.databaseUrl });
let mongoReady: Promise<MongoClient> | null = null;
function mongoClient(): Promise<MongoClient> {
mongoReady ??= mongo.connect();
return mongoReady;
}
return {
async write(event: TelemetryIngest): Promise<WriteReport> {
const report: WriteReport = { mongo: false, redis: false, postgres: false };
const lastSeen = new Date(event.sentAt);
try {
const client = await mongoClient();
await client.db().collection('telemetry').insertOne({
deviceId: event.deviceId,
sentAt: lastSeen,
payload: event.payload,
ingestedAt: new Date(),
});
report.mongo = true;
} catch (error) {
console.error('mongo ingest write failed', error);
}
try {
if (redis.status === 'wait') {
await redis.connect();
}
await redis.set(
redisKey(event.deviceId),
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
'EX',
PRESENCE_TTL_SECONDS,
);
report.redis = true;
} catch (error) {
console.error('redis ingest write failed', error);
}
try {
await pool.query(
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
VALUES ($1, $2::jsonb, $3, NOW())
ON CONFLICT (device_id) DO UPDATE SET
payload = EXCLUDED.payload,
last_seen = EXCLUDED.last_seen,
updated_at = NOW()`,
[event.deviceId, JSON.stringify(event.payload), lastSeen],
);
report.postgres = true;
} catch (error) {
console.error('postgres ingest write failed', error);
}
return report;
},
async close() {
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
},
};
}
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
return {
async write(event: TelemetryIngest) {
writes.push(event);
return { mongo: true, redis: true, postgres: true };
},
async close() {
return;
},
};
}
+18
View File
@@ -0,0 +1,18 @@
import request from 'supertest';
import { createApp } from '../src/app.js';
import { createMemoryWriter } from '../src/stores.js';
describe('ingest-api (e2e)', () => {
const app = createApp(createMemoryWriter());
it('POST /telemetry', async () => {
await request(app.callback())
.post('/telemetry')
.send({
deviceId: 'dev-1',
sentAt: new Date().toISOString(),
payload: { temp: 21 },
})
.expect(200);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"types": ["vitest/globals", "node"]
},
"include": ["src", "test"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
+9 -3
View File
@@ -10,13 +10,18 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.3.4",
"antd": "^6.6.2",
"@reduxjs/toolkit": "^2.12.0",
"axios": "^1.20.0",
"contracts": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.3",
"zod": "^3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -25,6 +30,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"tailwindcss": "^4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.2"
+29 -116
View File
@@ -1,122 +1,35 @@
import { useState } from 'react'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { DevicesPage } from './pages/DevicesPage'
import { GreetingPage } from './pages/GreetingPage'
function App() {
const [count, setCount] = useState(0)
const linkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-1.5 text-sm ${
isActive ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-200'
}`
export default function App() {
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
<div className="min-h-screen bg-slate-100">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<span className="font-semibold text-slate-900">marco-trest-test</span>
<nav className="flex gap-2">
<NavLink to="/greeting" className={linkClass}>
Greeting
</NavLink>
<NavLink to="/devices" className={linkClass}>
Devices
</NavLink>
</nav>
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">
<Routes>
<Route path="/" element={<Navigate to="/devices" replace />} />
<Route path="/greeting" element={<GreetingPage />} />
<Route path="/devices" element={<DevicesPage />} />
</Routes>
</main>
</div>
)
}
export default App
+17
View File
@@ -0,0 +1,17 @@
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:3000',
headers: {
Accept: 'application/json',
},
})
/** Demo only — production UI must not call ingest-api. */
export const ingestApi = axios.create({
baseURL: import.meta.env.VITE_INGEST_BASE_URL ?? 'http://127.0.0.1:3001',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
+1 -111
View File
@@ -1,111 +1 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
@import 'tailwindcss';
+9 -2
View File
@@ -1,10 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
import App from './App.tsx'
import { store } from './store'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)
+104
View File
@@ -0,0 +1,104 @@
import { useEffect } from 'react'
import type { DeviceStatus } from 'contracts'
import { PRESENCE_TTL_SECONDS } from 'contracts'
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
import { useAppDispatch, useAppSelector } from '../store/hooks'
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
if (!status) {
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
</article>
)
}
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<dl className="mt-3 space-y-1 text-sm text-slate-700">
<div className="flex justify-between gap-4">
<dt>online</dt>
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
{status.online ? 'yes' : 'no'}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>source</dt>
<dd>{status.source}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>age</dt>
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>lastSeen</dt>
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
</div>
<div>
<dt>payload</dt>
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
{JSON.stringify(status.payload)}
</dd>
</div>
</dl>
</article>
)
}
export function DevicesPage() {
const dispatch = useAppDispatch()
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
useEffect(() => {
void dispatch(fetchDeviceStatus(deviceId))
const timer = window.setInterval(() => {
void dispatch(fetchDeviceStatus(deviceId))
}, 1000)
return () => window.clearInterval(timer)
}, [dispatch, deviceId])
return (
<section className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
<p className="mt-2 text-sm text-slate-600">
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
</p>
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
</p>
<button
type="button"
disabled={pinging}
onClick={() => void dispatch(pingDevice(deviceId))}
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
</button>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="mb-2 font-medium text-slate-900">Data flow</p>
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
→ Mongo (raw event)
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
→ Postgres UPSERT device_current_state
React poll 1s → Express crud :3000
→ SELECT postgres snapshot ← near-real-time product path
→ GET redis key ← optional presence`}</pre>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
</div>
</section>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { fetchGreeting, setName } from '../store/greeting-slice'
import { useAppDispatch, useAppSelector } from '../store/hooks'
export function GreetingPage() {
const dispatch = useAppDispatch()
const { name, message, status, error } = useAppSelector((state) => state.greeting)
const loading = status === 'loading'
return (
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Greeting</h1>
<p className="mt-2 text-sm text-slate-600">
User lane: axios → Express GET /greeting on crud-api. Schemas come from the contracts
package.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault()
void dispatch(fetchGreeting(name))
}}
>
<label className="block text-sm font-medium text-slate-700">
Name
<input
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
value={name}
onChange={(event) => dispatch(setName(event.target.value))}
autoComplete="nickname"
/>
</label>
<button
type="submit"
disabled={loading}
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{loading ? 'Fetching…' : 'Fetch greeting'}
</button>
</form>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
{message ? (
<p className="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{message}</p>
) : null}
</section>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import {
DEMO_DEVICE_ID,
deviceStatusPairSchema,
telemetryIngestSchema,
type DeviceStatusPair,
} from 'contracts'
import { api, ingestApi } from '../api/client'
import { toErrorMessage } from './greeting-slice'
export type DeviceState = {
deviceId: string
pair: DeviceStatusPair | null
status: 'idle' | 'loading' | 'succeeded' | 'failed'
pinging: boolean
error: string | null
lastPingAt: string | null
}
const initialState: DeviceState = {
deviceId: DEMO_DEVICE_ID,
pair: null,
status: 'idle',
pinging: false,
error: null,
lastPingAt: null,
}
export const fetchDeviceStatus = createAsyncThunk<
DeviceStatusPair,
string,
{ rejectValue: string }
>('device/fetchStatus', async (deviceId, { rejectWithValue }) => {
try {
const response = await api.get(`/devices/${deviceId}/status`)
return deviceStatusPairSchema.parse(response.data)
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
})
export const pingDevice = createAsyncThunk<void, string, { rejectValue: string }>(
'device/ping',
async (deviceId, { rejectWithValue, dispatch }) => {
try {
const body = telemetryIngestSchema.parse({
deviceId,
sentAt: new Date().toISOString(),
payload: { temp: 20 + Math.round(Math.random() * 5), demo: true },
})
await ingestApi.post('/telemetry', body)
await dispatch(fetchDeviceStatus(deviceId)).unwrap()
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
},
)
const deviceSlice = createSlice({
name: 'device',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchDeviceStatus.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchDeviceStatus.fulfilled, (state, action) => {
state.status = 'succeeded'
state.pair = action.payload
})
.addCase(fetchDeviceStatus.rejected, (state, action) => {
state.status = 'failed'
state.error = action.payload ?? action.error.message ?? 'Request failed'
})
.addCase(pingDevice.pending, (state) => {
state.pinging = true
state.error = null
})
.addCase(pingDevice.fulfilled, (state) => {
state.pinging = false
state.lastPingAt = new Date().toISOString()
})
.addCase(pingDevice.rejected, (state, action) => {
state.pinging = false
state.error = action.payload ?? action.error.message ?? 'Ping failed'
})
},
})
export default deviceSlice.reducer
+92
View File
@@ -0,0 +1,92 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { greetingOutputSchema, greetingQuerySchema, type GreetingOutput } from 'contracts'
import { isAxiosError } from 'axios'
import { ZodError } from 'zod'
import { api } from '../api/client'
export type GreetingStatus = 'idle' | 'loading' | 'succeeded' | 'failed'
export type GreetingState = {
name: string
message: string | null
status: GreetingStatus
error: string | null
}
const initialState: GreetingState = {
name: 'Marco',
message: null,
status: 'idle',
error: null,
}
export function toErrorMessage(error: unknown): string {
if (error instanceof ZodError) {
return error.issues.map((issue) => issue.message).join(', ')
}
if (isAxiosError(error)) {
const data = error.response?.data
if (typeof data === 'string' && data.length > 0) {
return data
}
if (data && typeof data === 'object' && 'message' in data) {
const message = (data as { message: unknown }).message
if (typeof message === 'string' && message.length > 0) {
return message
}
}
return error.message
}
if (error instanceof Error) {
return error.message
}
return 'Request failed'
}
export const fetchGreeting = createAsyncThunk<GreetingOutput, string, { rejectValue: string }>(
'greeting/fetch',
async (name, { rejectWithValue }) => {
try {
const query = greetingQuerySchema.parse({ name })
const response = await api.get('/greeting', { params: query })
return greetingOutputSchema.parse(response.data)
} catch (error) {
return rejectWithValue(toErrorMessage(error))
}
},
)
const greetingSlice = createSlice({
name: 'greeting',
initialState,
reducers: {
setName(state, action: PayloadAction<string>) {
state.name = action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(fetchGreeting.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchGreeting.fulfilled, (state, action) => {
state.status = 'succeeded'
state.message = action.payload.message
state.error = null
})
.addCase(fetchGreeting.rejected, (state, action) => {
state.status = 'failed'
state.message = null
state.error = action.payload ?? action.error.message ?? 'Request failed'
})
},
})
export const { setName } = greetingSlice.actions
export default greetingSlice.reducer
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './index'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+13
View File
@@ -0,0 +1,13 @@
import { configureStore } from '@reduxjs/toolkit'
import deviceReducer from './device-slice'
import greetingReducer from './greeting-slice'
export const store = configureStore({
reducer: {
greeting: greetingReducer,
device: deviceReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
+7 -2
View File
@@ -1,7 +1,12 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
server: {
fs: {
allow: ['..'],
},
},
})