diff --git a/.gitignore b/.gitignore index 0b97633..ba93ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,7 @@ _NCrunch* BenchmarkDotNet.Artifacts/ # Semantic Release version file .release-version + +# Node.js (React chat client) +node_modules/ +tsconfig.tsbuildinfo diff --git a/Trax.Samples.slnx b/Trax.Samples.slnx index 8f0ce54..87d6c45 100644 --- a/Trax.Samples.slnx +++ b/Trax.Samples.slnx @@ -18,6 +18,14 @@ + + + + + + + + diff --git a/samples/ChatService/README.md b/samples/ChatService/README.md new file mode 100644 index 0000000..cc0d4e9 --- /dev/null +++ b/samples/ChatService/README.md @@ -0,0 +1,124 @@ +# Trax Chat Service Sample + +A single-server chat application demonstrating how Trax lifecycle hooks drive domain-specific real-time GraphQL subscriptions. When a chat mutation train completes, a custom `ITrainLifecycleHook` publishes the result to a room-scoped HotChocolate topic, and any client subscribed to that room receives the event via WebSocket. + +## Architecture + +``` +ChatService/ +├── Trax.Samples.ChatService.Data/ EF Core entities, DbContext, migrations (chat schema) +├── Trax.Samples.ChatService/ Trains, lifecycle hook, subscription types, auth +├── Trax.Samples.ChatService.Api/ Single-server ASP.NET Core host +└── Trax.Samples.ChatService.Client/ React + TypeScript frontend (Apollo Client) +``` + +Everything runs in one process — no scheduler, no workers. The data layer uses a separate `chat` schema that coexists with Trax's `trax` schema in the same Postgres database. + +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Node.js 20+](https://nodejs.org/) (for the React client) +- Docker (for PostgreSQL) + +## Running + +```bash +# 1. Start PostgreSQL +cd Trax.Samples && docker compose up -d + +# 2. Pack local Trax packages (if not already done) +./pack-local.sh + +# 3. Start the API +dotnet run --project samples/ChatService/Trax.Samples.ChatService.Api + +# 4. Start the React client (separate terminal) +cd samples/ChatService/Trax.Samples.ChatService.Client +npm install +npm run dev +``` + +- GraphQL IDE (Banana Cake Pop): http://localhost:5210/trax/graphql +- React client: http://localhost:5173 + +## Authentication + +Fake API key authentication via `X-Api-Key` header (for demonstration only): + +| API Key | User ID | Display Name | +|---------------|-----------|--------------| +| `alice-key` | `alice` | Alice | +| `bob-key` | `bob` | Bob | +| `charlie-key` | `charlie` | Charlie | + +The React client provides a dropdown to switch between users. Open multiple browser tabs to simulate different users chatting in real time. + +## Quick Walkthrough (GraphQL IDE) + +```graphql +# 1. Create a room (as Alice — set X-Api-Key: alice-key) +mutation { + dispatch { + createChatRoom(input: { name: "General", userId: "alice", displayName: "Alice" }) { + externalId + output { chatRoomId name } + } + } +} + +# 2. Join the room (as Bob — set X-Api-Key: bob-key) +mutation { + dispatch { + joinChatRoom(input: { chatRoomId: "", userId: "bob", displayName: "Bob" }) { + externalId + output { joinedAt } + } + } +} + +# 3. Subscribe to real-time events (in a second tab) +subscription { + onChatEvent(chatRoomId: "") { + eventType + payload + timestamp + } +} + +# 4. Send a message — the subscription tab receives it +mutation { + dispatch { + sendMessage(input: { chatRoomId: "", senderUserId: "alice", content: "Hello!" }) { + externalId + output { messageId content sentAt } + } + } +} + +# 5. Query chat history +{ + discover { + getChatHistory(input: { chatRoomId: "" }) { + messages { senderDisplayName content sentAt } + } + } +} +``` + +## How the Lifecycle Hook Works + +1. Chat mutation trains (`CreateChatRoom`, `JoinChatRoom`, `SendMessage`) are decorated with `[TraxBroadcast]`, which causes lifecycle hooks to fire on completion. +2. `ChatLifecycleHook` implements `ITrainLifecycleHook` and checks `metadata.Name` against a map of chat train interfaces. +3. On match, it parses `metadata.Output` (serialized JSON) to extract the `chatRoomId`. +4. It publishes a `ChatSubscriptionEvent` to the HotChocolate topic `"ChatRoom:{chatRoomId}"`. +5. Clients subscribed via `onChatEvent(chatRoomId: "...")` receive the event in real time. + +This coexists with the built-in `GraphQLSubscriptionHook` — both hooks fire for `[TraxBroadcast]` trains. + +## Tests + +```bash +dotnet test tests/Trax.Samples.ChatService.Tests +``` + +31 tests covering lifecycle hook behavior (10 unit) and all train steps (21 integration using EF Core in-memory provider). diff --git a/samples/ChatService/Trax.Samples.ChatService.Api/Program.cs b/samples/ChatService/Trax.Samples.ChatService.Api/Program.cs new file mode 100644 index 0000000..f2e1867 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Api/Program.cs @@ -0,0 +1,123 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Trax Chat Service — GraphQL API with Real-Time Subscriptions +// +// A single-server chat application powered by HotChocolate GraphQL. +// Demonstrates how Trax lifecycle hooks can drive domain-specific real-time +// subscriptions: when a SendMessage train completes, the ChatLifecycleHook +// publishes the message to a room-scoped topic, and any client subscribed +// to that room receives the event via WebSocket. +// +// Authentication: fake API key via X-Api-Key header (for demonstration only) +// alice-key → user "alice" (display name: Alice) +// bob-key → user "bob" (display name: Bob) +// charlie-key → user "charlie" (display name: Charlie) +// +// Prerequisites: +// 1. Start Postgres: cd Trax.Samples && docker compose up -d +// 2. Pack local: ./pack-local.sh +// 3. Start API: dotnet run --project samples/ChatService/Trax.Samples.ChatService.Api +// +// Try it: +// Open http://localhost:5210/trax/graphql in a browser for Banana Cake Pop IDE +// +// # Create a chat room (as Alice) +// mutation { dispatch { createChatRoom(input: { name: "General", userId: "alice", displayName: "Alice" }) { externalId output { chatRoomId name } } } } +// +// # Join the room (as Bob) +// mutation { dispatch { joinChatRoom(input: { chatRoomId: "", userId: "bob", displayName: "Bob" }) { externalId output { joinedAt } } } } +// +// # Send a message +// mutation { dispatch { sendMessage(input: { chatRoomId: "", senderUserId: "alice", content: "Hello!" }) { externalId output { messageId content sentAt } } } } +// +// # Subscribe to real-time chat events (in Banana Cake Pop): +// subscription { onChatEvent(chatRoomId: "") { eventType payload timestamp } } +// +// # Query chat history +// { discover { getChatHistory(input: { chatRoomId: "" }) { messages { senderDisplayName content sentAt } } } } +// ───────────────────────────────────────────────────────────────────────────── + +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Trax.Api.Extensions; +using Trax.Api.GraphQL.Extensions; +using Trax.Effect.Data.Postgres.Extensions; +using Trax.Effect.Extensions; +using Trax.Effect.Provider.Json.Extensions; +using Trax.Effect.Provider.Parameter.Extensions; +using Trax.Mediator.Extensions; +using Trax.Samples.ChatService.Auth; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Hooks; +using Trax.Samples.ChatService.Subscriptions; + +var builder = WebApplication.CreateBuilder(args); + +var traxConnectionString = + builder.Configuration.GetConnectionString("TraxDatabase") + ?? throw new InvalidOperationException("Connection string 'TraxDatabase' not found."); + +var chatConnectionString = + builder.Configuration.GetConnectionString("ChatDatabase") + ?? throw new InvalidOperationException("Connection string 'ChatDatabase' not found."); + +builder.Services.AddLogging(logging => logging.AddConsole()); + +// ── Chat data layer ───────────────────────────────────────────────────────── +builder.Services.AddDbContext(options => options.UseNpgsql(chatConnectionString)); + +// ── Authentication — fake API key for demonstration ───────────────────────── +builder + .Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme) + .AddScheme( + ApiKeyDefaults.AuthenticationScheme, + null + ); + +builder.Services.AddAuthorization(); + +// ── Register Trax Effect + Mediator + ChatLifecycleHook ───────────────────── +builder.Services.AddTrax(trax => + trax.AddEffects(effects => + effects + .UsePostgres(traxConnectionString) + .AddJson() + .SaveTrainParameters() + .AddLifecycleHook() + ) + .AddMediator(typeof(ChatLifecycleHookFactory).Assembly) +); + +// ── Register GraphQL API + chat subscriptions ─────────────────────────────── +builder.Services.AddTraxGraphQL(); +builder.Services.AddGraphQLServer("trax").AddTypeExtension(); + +builder.Services.AddHealthChecks().AddTraxHealthCheck(); + +// ── CORS — allow React dev server ──────────────────────────────────────── +builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => + policy + .WithOrigins("http://localhost:5173") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + ) +); + +var app = builder.Build(); + +// ── Auto-migrate chat schema ──────────────────────────────────────────────── +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +// ── Map endpoints ─────────────────────────────────────────────────────────── +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseTraxGraphQL(); +app.MapHealthChecks("/trax/health"); + +app.Run(); diff --git a/samples/ChatService/Trax.Samples.ChatService.Api/Trax.Samples.ChatService.Api.csproj b/samples/ChatService/Trax.Samples.ChatService.Api/Trax.Samples.ChatService.Api.csproj new file mode 100644 index 0000000..144d016 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Api/Trax.Samples.ChatService.Api.csproj @@ -0,0 +1,20 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.Development.json b/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.json b/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.json new file mode 100644 index 0000000..599f6b8 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Api/appsettings.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "TraxDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123", + "ChatDatabase": "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123" + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5210" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/index.html b/samples/ChatService/Trax.Samples.ChatService.Client/index.html new file mode 100644 index 0000000..2e7d287 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/index.html @@ -0,0 +1,12 @@ + + + + + + Trax Chat + + +
+ + + diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/package-lock.json b/samples/ChatService/Trax.Samples.ChatService.Client/package-lock.json new file mode 100644 index 0000000..1448773 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/package-lock.json @@ -0,0 +1,2129 @@ +{ + "name": "trax-chat-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trax-chat-client", + "version": "0.0.0", + "dependencies": { + "@apollo/client": "^3.12.0", + "graphql": "^16.10.0", + "graphql-ws": "^6.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "~5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@apollo/client": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.14.1.tgz", + "integrity": "sha512-SgGX6E23JsZhUdG2anxiyHvEvvN6CUaI4ZfMsndZFeuHPXL3H0IsaiNAhLITSISbeyeYd+CBd9oERXQDdjXWZw==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.18.0", + "prop-types": "^15.7.2", + "rehackt": "^0.1.0", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.7.tgz", + "integrity": "sha512-yoLRW+KRlDmnnROdAu7sX77VNLC0bsFoZyGQJLy1cF+X/SkLg/fWkRGrEEYQK8o2cafJ2wmEaMqMEZB3U3DYDg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rehackt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", + "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", + "license": "MIT" + }, + "node_modules/zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "license": "MIT", + "dependencies": { + "zen-observable": "0.8.15" + } + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/package.json b/samples/ChatService/Trax.Samples.ChatService.Client/package.json new file mode 100644 index 0000000..726fc0a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/package.json @@ -0,0 +1,25 @@ +{ + "name": "trax-chat-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@apollo/client": "^3.12.0", + "graphql": "^16.10.0", + "graphql-ws": "^6.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "~5.7.0", + "vite": "^6.0.0" + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/apollo.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/apollo.ts new file mode 100644 index 0000000..da1f79f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/apollo.ts @@ -0,0 +1,43 @@ +import { + ApolloClient, + InMemoryCache, + HttpLink, + split, +} from "@apollo/client"; +import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; +import { getMainDefinition } from "@apollo/client/utilities"; +import { createClient } from "graphql-ws"; + +const API_URL = "http://localhost:5210/trax/graphql"; +const WS_URL = "ws://localhost:5210/trax/graphql"; + +export function createApolloClient(apiKey: string): ApolloClient { + const httpLink = new HttpLink({ + uri: API_URL, + headers: { "X-Api-Key": apiKey }, + }); + + const wsLink = new GraphQLWsLink( + createClient({ + url: WS_URL, + connectionParams: { "X-Api-Key": apiKey }, + }), + ); + + const link = split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + httpLink, + ); + + return new ApolloClient({ + link, + cache: new InMemoryCache(), + }); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/App.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/App.tsx new file mode 100644 index 0000000..b4a701c --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/App.tsx @@ -0,0 +1,38 @@ +import { useState, useEffect } from "react"; +import { useUser } from "../context/UserContext"; +import { UserSelector } from "./UserSelector"; +import { RoomList } from "./RoomList"; +import { ChatRoom } from "./ChatRoom"; + +export function App() { + const { user } = useUser(); + const [selectedRoomId, setSelectedRoomId] = useState(null); + + // Clear room selection when switching users + useEffect(() => { + setSelectedRoomId(null); + }, [user.userId]); + + return ( +
+ +
+ {selectedRoomId ? ( + + ) : ( +
Select or create a room to start chatting.
+ )} +
+
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/ChatRoom.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/ChatRoom.tsx new file mode 100644 index 0000000..bead8c9 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/ChatRoom.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation, useSubscription } from "@apollo/client"; +import { GET_CHAT_HISTORY } from "../graphql/queries"; +import { SEND_MESSAGE } from "../graphql/mutations"; +import { ON_CHAT_EVENT } from "../graphql/subscriptions"; +import { useUser } from "../context/UserContext"; +import { Message } from "./Message"; +import type { ChatMessageDto } from "../types"; + +interface ChatRoomProps { + roomId: string; +} + +let pendingCounter = 0; + +export function ChatRoom({ roomId }: ChatRoomProps) { + const { user } = useUser(); + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const messagesEndRef = useRef(null); + + const { data, loading } = useQuery(GET_CHAT_HISTORY, { + variables: { input: { chatRoomId: roomId, take: 100 } }, + }); + + const [sendMessage] = useMutation(SEND_MESSAGE); + + // Load initial messages from query + useEffect(() => { + const fetched: ChatMessageDto[] = + data?.discover?.getChatHistory?.messages ?? []; + setMessages(fetched); + }, [data]); + + // Subscribe to real-time events + useSubscription(ON_CHAT_EVENT, { + variables: { chatRoomId: roomId }, + onData: ({ data: subData }) => { + const event = subData?.data?.onChatEvent; + if (!event || event.eventType !== "MessageSent") return; + + try { + const payload = JSON.parse(event.payload); + const newMsg: ChatMessageDto = { + id: payload.messageId ?? payload.MessageId ?? event.trainExternalId, + senderUserId: payload.senderUserId ?? payload.SenderUserId ?? "", + senderDisplayName: + payload.senderDisplayName ?? payload.SenderDisplayName ?? "", + content: payload.content ?? payload.Content ?? "", + sentAt: payload.sentAt ?? payload.SentAt ?? event.timestamp, + }; + + setMessages((prev) => { + // Replace pending message with matching content from the same sender + const pendingIdx = prev.findIndex( + (m) => + m.pending && + m.senderUserId === newMsg.senderUserId && + m.content === newMsg.content, + ); + if (pendingIdx !== -1) { + const updated = [...prev]; + updated[pendingIdx] = newMsg; + return updated; + } + // Otherwise append if not a duplicate + if (prev.some((m) => m.id === newMsg.id)) return prev; + return [...prev, newMsg]; + }); + } catch { + // Ignore malformed payloads + } + }, + }); + + // Auto-scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!draft.trim()) return; + + const content = draft.trim(); + setDraft(""); + + // Add optimistic pending message immediately + const pendingMsg: ChatMessageDto = { + id: `pending-${++pendingCounter}`, + senderUserId: user.userId, + senderDisplayName: user.displayName, + content, + sentAt: new Date().toISOString(), + pending: true, + }; + setMessages((prev) => [...prev, pendingMsg]); + + await sendMessage({ + variables: { + input: { + chatRoomId: roomId, + senderUserId: user.userId, + content, + }, + }, + }); + }; + + return ( +
+
+ Room: {roomId} +
+ +
+ {loading &&
Loading messages...
} + {messages.map((msg) => ( + + ))} +
+
+ +
+ setDraft(e.target.value)} + autoFocus + /> + +
+
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/CreateRoomDialog.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/CreateRoomDialog.tsx new file mode 100644 index 0000000..0c5fbe6 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/CreateRoomDialog.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { useMutation } from "@apollo/client"; +import { CREATE_CHAT_ROOM } from "../graphql/mutations"; +import { GET_CHAT_ROOMS } from "../graphql/queries"; +import { useUser } from "../context/UserContext"; + +interface CreateRoomDialogProps { + onClose: () => void; + onCreated: (roomId: string) => void; +} + +export function CreateRoomDialog({ onClose, onCreated }: CreateRoomDialogProps) { + const { user } = useUser(); + const [name, setName] = useState(""); + + const [createRoom, { loading }] = useMutation(CREATE_CHAT_ROOM, { + refetchQueries: [GET_CHAT_ROOMS], + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + const result = await createRoom({ + variables: { + input: { + name: name.trim(), + userId: user.userId, + displayName: user.displayName, + }, + }, + }); + + const roomId = result.data?.dispatch?.createChatRoom?.output?.chatRoomId; + if (roomId) onCreated(roomId); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + > +

Create Chat Room

+ setName(e.target.value)} + autoFocus + /> +
+ + +
+
+
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/Message.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/Message.tsx new file mode 100644 index 0000000..fd26c7e --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/Message.tsx @@ -0,0 +1,31 @@ +import type { ChatMessageDto } from "../types"; + +interface MessageProps { + message: ChatMessageDto; + isOwn: boolean; +} + +export function Message({ message, isOwn }: MessageProps) { + const time = new Date(message.sentAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + const classes = [ + "message", + isOwn ? "message-own" : "message-other", + message.pending ? "message-pending" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+ {message.senderDisplayName} + {time} +
+
{message.content}
+
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/RoomList.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/RoomList.tsx new file mode 100644 index 0000000..a24229c --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/RoomList.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@apollo/client"; +import { GET_CHAT_ROOMS } from "../graphql/queries"; +import { JOIN_CHAT_ROOM } from "../graphql/mutations"; +import { useUser } from "../context/UserContext"; +import { CreateRoomDialog } from "./CreateRoomDialog"; +import type { ChatRoomSummary } from "../types"; + +interface RoomListProps { + selectedRoomId: string | null; + onSelectRoom: (roomId: string) => void; +} + +export function RoomList({ selectedRoomId, onSelectRoom }: RoomListProps) { + const { user } = useUser(); + const [showCreate, setShowCreate] = useState(false); + const [joinRoomId, setJoinRoomId] = useState(null); + + const { data, loading } = useQuery(GET_CHAT_ROOMS, { + variables: { input: { userId: user.userId } }, + pollInterval: 5000, + }); + + const [joinRoom] = useMutation(JOIN_CHAT_ROOM, { + refetchQueries: [GET_CHAT_ROOMS], + }); + + const rooms: ChatRoomSummary[] = + data?.discover?.getChatRooms?.rooms ?? []; + + const handleJoin = async (roomId: string) => { + setJoinRoomId(roomId); + await joinRoom({ + variables: { + input: { + chatRoomId: roomId, + userId: user.userId, + displayName: user.displayName, + }, + }, + }); + setJoinRoomId(null); + onSelectRoom(roomId); + }; + + return ( +
+
+

Rooms

+ +
+ + {loading && rooms.length === 0 && ( +
Loading...
+ )} + + {rooms.map((room) => ( +
onSelectRoom(room.id)} + > +
{room.name}
+
+ {room.participantCount} member{room.participantCount !== 1 && "s"} + {room.unreadCount > 0 && ( + {room.unreadCount} + )} +
+
+ ))} + + {!loading && rooms.length === 0 && ( +
+ No rooms yet. Create one or ask another user to invite you. +
+ )} + +
+

Join a Room

+

+ Paste a room ID to join an existing room. +

+
{ + e.preventDefault(); + const input = e.currentTarget.elements.namedItem( + "roomId", + ) as HTMLInputElement; + if (input.value.trim()) handleJoin(input.value.trim()); + }} + > + + +
+
+ + {showCreate && ( + setShowCreate(false)} + onCreated={onSelectRoom} + /> + )} +
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/components/UserSelector.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/UserSelector.tsx new file mode 100644 index 0000000..99b8612 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/components/UserSelector.tsx @@ -0,0 +1,26 @@ +import { useUser } from "../context/UserContext"; +import { USERS } from "../types"; + +export function UserSelector() { + const { user, setUser } = useUser(); + + return ( +
+ + +
+ ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/context/UserContext.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/context/UserContext.tsx new file mode 100644 index 0000000..22d2d79 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/context/UserContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState, useMemo, type ReactNode } from "react"; +import { ApolloProvider } from "@apollo/client"; +import { createApolloClient } from "../apollo"; +import { USERS, type User } from "../types"; + +interface UserContextValue { + user: User; + setUser: (user: User) => void; +} + +const UserContext = createContext(null); + +export function useUser(): UserContextValue { + const ctx = useContext(UserContext); + if (!ctx) throw new Error("useUser must be used within UserProvider"); + return ctx; +} + +export function UserProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(USERS[0]); + + const client = useMemo(() => createApolloClient(user.key), [user.key]); + + return ( + + {children} + + ); +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/mutations.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/mutations.ts new file mode 100644 index 0000000..ccd1d95 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/mutations.ts @@ -0,0 +1,50 @@ +import { gql } from "@apollo/client"; + +export const CREATE_CHAT_ROOM = gql` + mutation CreateChatRoom($input: CreateChatRoomInput!) { + dispatch { + createChatRoom(input: $input) { + externalId + output { + chatRoomId + name + createdAt + } + } + } + } +`; + +export const JOIN_CHAT_ROOM = gql` + mutation JoinChatRoom($input: JoinChatRoomInput!) { + dispatch { + joinChatRoom(input: $input) { + externalId + output { + chatRoomId + userId + displayName + joinedAt + } + } + } + } +`; + +export const SEND_MESSAGE = gql` + mutation SendMessage($input: SendMessageInput!) { + dispatch { + sendMessage(input: $input) { + externalId + output { + messageId + chatRoomId + senderUserId + senderDisplayName + content + sentAt + } + } + } + } +`; diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/queries.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/queries.ts new file mode 100644 index 0000000..be09d00 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/queries.ts @@ -0,0 +1,33 @@ +import { gql } from "@apollo/client"; + +export const GET_CHAT_ROOMS = gql` + query GetChatRooms($input: GetChatRoomsInput!) { + discover { + getChatRooms(input: $input) { + rooms { + id + name + participantCount + lastMessageAt + unreadCount + } + } + } + } +`; + +export const GET_CHAT_HISTORY = gql` + query GetChatHistory($input: GetChatHistoryInput!) { + discover { + getChatHistory(input: $input) { + messages { + id + senderUserId + senderDisplayName + content + sentAt + } + } + } + } +`; diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/subscriptions.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/subscriptions.ts new file mode 100644 index 0000000..d91d074 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/graphql/subscriptions.ts @@ -0,0 +1,13 @@ +import { gql } from "@apollo/client"; + +export const ON_CHAT_EVENT = gql` + subscription OnChatEvent($chatRoomId: UUID!) { + onChatEvent(chatRoomId: $chatRoomId) { + chatRoomId + eventType + payload + timestamp + trainExternalId + } + } +`; diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/index.css b/samples/ChatService/Trax.Samples.ChatService.Client/src/index.css new file mode 100644 index 0000000..75730de --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/index.css @@ -0,0 +1,404 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + background: #f0f2f5; + color: #1a1a1a; + height: 100vh; + overflow: hidden; +} + +#root { + height: 100vh; +} + +/* ── Layout ─────────────────────────────────────────────────────────────── */ + +.app { + display: flex; + height: 100vh; +} + +.sidebar { + width: 300px; + background: #fff; + border-right: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; +} + +.app-title { + padding: 16px; + font-size: 20px; + font-weight: 700; + border-bottom: 1px solid #e0e0e0; +} + +/* ── User Selector ──────────────────────────────────────────────────────── */ + +.user-selector { + padding: 12px 16px; + border-bottom: 1px solid #e0e0e0; + display: flex; + align-items: center; + gap: 8px; +} + +.user-selector label { + font-size: 13px; + color: #666; + white-space: nowrap; +} + +.user-selector select { + flex: 1; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +/* ── Room List ──────────────────────────────────────────────────────────── */ + +.room-list { + flex: 1; + display: flex; + flex-direction: column; +} + +.room-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; +} + +.room-list-header h2 { + font-size: 15px; + font-weight: 600; +} + +.room-list-header button { + padding: 4px 12px; + font-size: 13px; + border: 1px solid #0084ff; + background: #0084ff; + color: #fff; + border-radius: 4px; + cursor: pointer; +} + +.room-list-header button:hover { + background: #0073e6; +} + +.room-item { + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; +} + +.room-item:hover { + background: #f5f5f5; +} + +.room-item-selected { + background: #e8f0fe; +} + +.room-item-selected:hover { + background: #d4e4fc; +} + +.room-item-name { + font-size: 14px; + font-weight: 500; +} + +.room-item-meta { + font-size: 12px; + color: #888; + margin-top: 2px; +} + +.room-item-unread { + display: inline-block; + background: #0084ff; + color: #fff; + font-size: 11px; + font-weight: 600; + border-radius: 10px; + padding: 1px 6px; + margin-left: 6px; +} + +.room-list-empty { + padding: 16px; + color: #888; + font-size: 13px; + text-align: center; +} + +/* ── Join Room ──────────────────────────────────────────────────────────── */ + +.room-list-join { + padding: 12px 16px; + border-top: 1px solid #e0e0e0; + margin-top: auto; +} + +.room-list-join h3 { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; +} + +.room-list-join-hint { + font-size: 12px; + color: #888; + margin-bottom: 8px; +} + +.room-list-join form { + display: flex; + gap: 6px; +} + +.room-list-join input { + flex: 1; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; +} + +.room-list-join button { + padding: 6px 12px; + border: 1px solid #0084ff; + background: #0084ff; + color: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +/* ── No Room Placeholder ────────────────────────────────────────────────── */ + +.no-room { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #888; + font-size: 16px; +} + +/* ── Chat Room ──────────────────────────────────────────────────────────── */ + +.chat-room { + display: flex; + flex-direction: column; + height: 100%; +} + +.chat-room-header { + padding: 12px 16px; + background: #fff; + border-bottom: 1px solid #e0e0e0; +} + +.chat-room-id { + font-size: 13px; + color: #666; + font-family: monospace; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-loading { + text-align: center; + color: #888; + padding: 20px; +} + +/* ── Messages ───────────────────────────────────────────────────────────── */ + +.message { + max-width: 70%; +} + +.message-own { + align-self: flex-end; +} + +.message-other { + align-self: flex-start; +} + +.message-meta { + font-size: 11px; + color: #888; + margin-bottom: 2px; + display: flex; + gap: 6px; +} + +.message-own .message-meta { + justify-content: flex-end; +} + +.message-sender { + font-weight: 600; +} + +.message-bubble { + padding: 8px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.4; + word-wrap: break-word; +} + +.message-own .message-bubble { + background: #0084ff; + color: #fff; + border-bottom-right-radius: 4px; +} + +.message-pending .message-bubble { + background: #a0c8f0; +} + +.message-pending .message-meta { + opacity: 0.5; +} + +.message-other .message-bubble { + background: #e4e6eb; + color: #1a1a1a; + border-bottom-left-radius: 4px; +} + +/* ── Chat Input ─────────────────────────────────────────────────────────── */ + +.chat-input { + display: flex; + gap: 8px; + padding: 12px 16px; + background: #fff; + border-top: 1px solid #e0e0e0; +} + +.chat-input input { + flex: 1; + padding: 10px 14px; + border: 1px solid #ccc; + border-radius: 20px; + font-size: 14px; + outline: none; +} + +.chat-input input:focus { + border-color: #0084ff; +} + +.chat-input button { + padding: 10px 20px; + border: none; + background: #0084ff; + color: #fff; + border-radius: 20px; + font-size: 14px; + cursor: pointer; +} + +.chat-input button:hover { + background: #0073e6; +} + +.chat-input button:disabled { + background: #b0b0b0; + cursor: not-allowed; +} + +/* ── Dialog ─────────────────────────────────────────────────────────────── */ + +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.dialog { + background: #fff; + padding: 24px; + border-radius: 8px; + width: 360px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.dialog h3 { + margin-bottom: 16px; + font-size: 18px; +} + +.dialog input { + width: 100%; + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + margin-bottom: 16px; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.dialog-actions button { + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + cursor: pointer; +} + +.dialog-actions button[type="button"] { + border: 1px solid #ccc; + background: #fff; +} + +.dialog-actions button[type="submit"] { + border: none; + background: #0084ff; + color: #fff; +} + +.dialog-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/main.tsx b/samples/ChatService/Trax.Samples.ChatService.Client/src/main.tsx new file mode 100644 index 0000000..c884e1e --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { UserProvider } from "./context/UserContext"; +import { App } from "./components/App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/types.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/types.ts new file mode 100644 index 0000000..6cf1a29 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/types.ts @@ -0,0 +1,36 @@ +export interface ChatRoomSummary { + id: string; + name: string; + participantCount: number; + lastMessageAt: string | null; + unreadCount: number; +} + +export interface ChatMessageDto { + id: string; + senderUserId: string; + senderDisplayName: string; + content: string; + sentAt: string; + pending?: boolean; +} + +export interface ChatSubscriptionEvent { + chatRoomId: string; + eventType: string; + payload: string; + timestamp: string; + trainExternalId: string; +} + +export interface User { + key: string; + userId: string; + displayName: string; +} + +export const USERS: User[] = [ + { key: "alice-key", userId: "alice", displayName: "Alice" }, + { key: "bob-key", userId: "bob", displayName: "Bob" }, + { key: "charlie-key", userId: "charlie", displayName: "Charlie" }, +]; diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/src/vite-env.d.ts b/samples/ChatService/Trax.Samples.ChatService.Client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/tsconfig.json b/samples/ChatService/Trax.Samples.ChatService.Client/tsconfig.json new file mode 100644 index 0000000..39a405b --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Client/vite.config.ts b/samples/ChatService/Trax.Samples.ChatService.Client/vite.config.ts new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Client/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContext.cs b/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContext.cs new file mode 100644 index 0000000..ef9d7aa --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContext.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Trax.Samples.ChatService.Data.Entities; + +namespace Trax.Samples.ChatService.Data; + +public class ChatDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet ChatRooms => Set(); + public DbSet ChatParticipants => Set(); + public DbSet ChatMessages => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("chat"); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired(); + entity.Property(e => e.CreatedByUserId).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.UserId).IsRequired(); + entity.Property(e => e.DisplayName).IsRequired(); + + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => new { e.ChatRoomId, e.UserId }).IsUnique(); + + entity + .HasOne(e => e.ChatRoom) + .WithMany(r => r.Participants) + .HasForeignKey(e => e.ChatRoomId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.SenderUserId).IsRequired(); + entity.Property(e => e.SenderDisplayName).IsRequired(); + entity.Property(e => e.Content).IsRequired(); + + entity.HasIndex(e => new { e.ChatRoomId, e.SentAt }); + + entity + .HasOne(e => e.ChatRoom) + .WithMany(r => r.Messages) + .HasForeignKey(e => e.ChatRoomId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContextFactory.cs b/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContextFactory.cs new file mode 100644 index 0000000..2798452 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/ChatDbContextFactory.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Trax.Samples.ChatService.Data; + +public class ChatDbContextFactory : IDesignTimeDbContextFactory +{ + public ChatDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql( + "Host=localhost;Port=5432;Database=trax;Username=trax;Password=trax123" + ); + return new ChatDbContext(optionsBuilder.Options); + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatMessage.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatMessage.cs new file mode 100644 index 0000000..011f83c --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatMessage.cs @@ -0,0 +1,13 @@ +namespace Trax.Samples.ChatService.Data.Entities; + +public class ChatMessage +{ + public Guid Id { get; set; } + public Guid ChatRoomId { get; set; } + public required string SenderUserId { get; set; } + public required string SenderDisplayName { get; set; } + public required string Content { get; set; } + public DateTime SentAt { get; set; } + + public ChatRoom ChatRoom { get; set; } = null!; +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatParticipant.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatParticipant.cs new file mode 100644 index 0000000..27d9ddb --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatParticipant.cs @@ -0,0 +1,13 @@ +namespace Trax.Samples.ChatService.Data.Entities; + +public class ChatParticipant +{ + public Guid Id { get; set; } + public Guid ChatRoomId { get; set; } + public required string UserId { get; set; } + public required string DisplayName { get; set; } + public DateTime JoinedAt { get; set; } + public DateTime? LastReadAt { get; set; } + + public ChatRoom ChatRoom { get; set; } = null!; +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatRoom.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatRoom.cs new file mode 100644 index 0000000..6d75d16 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Entities/ChatRoom.cs @@ -0,0 +1,12 @@ +namespace Trax.Samples.ChatService.Data.Entities; + +public class ChatRoom +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } + public required string CreatedByUserId { get; set; } + + public ICollection Participants { get; set; } = []; + public ICollection Messages { get; set; } = []; +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.Designer.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.Designer.cs new file mode 100644 index 0000000..5d1abcd --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.Designer.cs @@ -0,0 +1,146 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Trax.Samples.ChatService.Data; + +#nullable disable + +namespace Trax.Samples.ChatService.Data.Migrations +{ + [DbContext(typeof(ChatDbContext))] + [Migration("20260315161220_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("chat") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatRoomId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChatRoomId", "SentAt"); + + b.ToTable("ChatMessages", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatRoomId") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChatRoomId", "UserId") + .IsUnique(); + + b.ToTable("ChatParticipants", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ChatRooms", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatMessage", b => + { + b.HasOne("Trax.Samples.ChatService.Data.Entities.ChatRoom", "ChatRoom") + .WithMany("Messages") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatRoom"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatParticipant", b => + { + b.HasOne("Trax.Samples.ChatService.Data.Entities.ChatRoom", "ChatRoom") + .WithMany("Participants") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatRoom"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatRoom", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.cs new file mode 100644 index 0000000..f8be1d1 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/20260315161220_Initial.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Trax.Samples.ChatService.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "chat"); + + migrationBuilder.CreateTable( + name: "ChatRooms", + schema: "chat", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + CreatedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + CreatedByUserId = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_ChatRooms", x => x.Id); + } + ); + + migrationBuilder.CreateTable( + name: "ChatMessages", + schema: "chat", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChatRoomId = table.Column(type: "uuid", nullable: false), + SenderUserId = table.Column(type: "text", nullable: false), + SenderDisplayName = table.Column(type: "text", nullable: false), + Content = table.Column(type: "text", nullable: false), + SentAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessages", x => x.Id); + table.ForeignKey( + name: "FK_ChatMessages_ChatRooms_ChatRoomId", + column: x => x.ChatRoomId, + principalSchema: "chat", + principalTable: "ChatRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateTable( + name: "ChatParticipants", + schema: "chat", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChatRoomId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + JoinedAt = table.Column( + type: "timestamp with time zone", + nullable: false + ), + LastReadAt = table.Column( + type: "timestamp with time zone", + nullable: true + ), + }, + constraints: table => + { + table.PrimaryKey("PK_ChatParticipants", x => x.Id); + table.ForeignKey( + name: "FK_ChatParticipants_ChatRooms_ChatRoomId", + column: x => x.ChatRoomId, + principalSchema: "chat", + principalTable: "ChatRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade + ); + } + ); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_ChatRoomId_SentAt", + schema: "chat", + table: "ChatMessages", + columns: new[] { "ChatRoomId", "SentAt" } + ); + + migrationBuilder.CreateIndex( + name: "IX_ChatParticipants_ChatRoomId_UserId", + schema: "chat", + table: "ChatParticipants", + columns: new[] { "ChatRoomId", "UserId" }, + unique: true + ); + + migrationBuilder.CreateIndex( + name: "IX_ChatParticipants_UserId", + schema: "chat", + table: "ChatParticipants", + column: "UserId" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "ChatMessages", schema: "chat"); + + migrationBuilder.DropTable(name: "ChatParticipants", schema: "chat"); + + migrationBuilder.DropTable(name: "ChatRooms", schema: "chat"); + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/ChatDbContextModelSnapshot.cs b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/ChatDbContextModelSnapshot.cs new file mode 100644 index 0000000..55602f3 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Migrations/ChatDbContextModelSnapshot.cs @@ -0,0 +1,143 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Trax.Samples.ChatService.Data; + +#nullable disable + +namespace Trax.Samples.ChatService.Data.Migrations +{ + [DbContext(typeof(ChatDbContext))] + partial class ChatDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("chat") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatRoomId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChatRoomId", "SentAt"); + + b.ToTable("ChatMessages", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatRoomId") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChatRoomId", "UserId") + .IsUnique(); + + b.ToTable("ChatParticipants", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ChatRooms", "chat"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatMessage", b => + { + b.HasOne("Trax.Samples.ChatService.Data.Entities.ChatRoom", "ChatRoom") + .WithMany("Messages") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatRoom"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatParticipant", b => + { + b.HasOne("Trax.Samples.ChatService.Data.Entities.ChatRoom", "ChatRoom") + .WithMany("Participants") + .HasForeignKey("ChatRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatRoom"); + }); + + modelBuilder.Entity("Trax.Samples.ChatService.Data.Entities.ChatRoom", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService.Data/Trax.Samples.ChatService.Data.csproj b/samples/ChatService/Trax.Samples.ChatService.Data/Trax.Samples.ChatService.Data.csproj new file mode 100644 index 0000000..5a46b0c --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService.Data/Trax.Samples.ChatService.Data.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyAuthHandler.cs b/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyAuthHandler.cs new file mode 100644 index 0000000..d278e8a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyAuthHandler.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Trax.Samples.ChatService.Auth; + +/// +/// Maps X-Api-Key headers to chat user identities. +/// Three users: alice, bob, charlie — for demonstration only. +/// +public class ApiKeyAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder +) : AuthenticationHandler(options, logger, encoder) +{ + private static readonly Dictionary Users = new() + { + [ApiKeyDefaults.AliceKey] = ("alice", "Alice"), + [ApiKeyDefaults.BobKey] = ("bob", "Bob"), + [ApiKeyDefaults.CharlieKey] = ("charlie", "Charlie"), + }; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(ApiKeyDefaults.HeaderName, out var headerValue)) + return Task.FromResult( + AuthenticateResult.Fail($"Missing {ApiKeyDefaults.HeaderName} header") + ); + + var apiKey = headerValue.ToString(); + + if (!Users.TryGetValue(apiKey, out var user)) + return Task.FromResult(AuthenticateResult.Fail("Invalid API key")); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.UserId), + new(ClaimTypes.Name, user.DisplayName), + new(ClaimTypes.Role, "User"), + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyDefaults.cs b/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyDefaults.cs new file mode 100644 index 0000000..34bea34 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Auth/ApiKeyDefaults.cs @@ -0,0 +1,12 @@ +namespace Trax.Samples.ChatService.Auth; + +public static class ApiKeyDefaults +{ + public const string AuthenticationScheme = "ApiKey"; + public const string HeaderName = "X-Api-Key"; + + // WARNING: FAKE KEYS — never use plaintext keys in production. + public const string AliceKey = "alice-key"; + public const string BobKey = "bob-key"; + public const string CharlieKey = "charlie-key"; +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHook.cs b/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHook.cs new file mode 100644 index 0000000..5386111 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHook.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using HotChocolate.Subscriptions; +using Trax.Effect.Models.Metadata; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Samples.ChatService.Trains.CreateChatRoom; +using Trax.Samples.ChatService.Trains.JoinChatRoom; +using Trax.Samples.ChatService.Trains.SendMessage; + +namespace Trax.Samples.ChatService.Hooks; + +/// +/// Lifecycle hook that intercepts completed chat mutation trains and publishes +/// their output to room-scoped HotChocolate subscription topics. +/// +/// When a SendMessage, CreateChatRoom, or JoinChatRoom train completes, +/// this hook extracts the chatRoomId from the serialized output and publishes +/// a ChatSubscriptionEvent to the "ChatRoom:{chatRoomId}" topic. Any client +/// subscribed to that room receives the event in real time. +/// +public class ChatLifecycleHook(ITopicEventSender eventSender) : ITrainLifecycleHook +{ + private static readonly Dictionary TrainEventTypes = new() + { + [typeof(ISendMessageTrain).FullName!] = "MessageSent", + [typeof(ICreateChatRoomTrain).FullName!] = "RoomCreated", + [typeof(IJoinChatRoomTrain).FullName!] = "UserJoined", + }; + + private static readonly HashSet ChatTrains = TrainEventTypes.Keys.ToHashSet(); + + public async Task OnCompleted(Metadata metadata, CancellationToken ct) + { + if (!ChatTrains.Contains(metadata.Name) || metadata.Output is null) + return; + + var eventType = TrainEventTypes[metadata.Name]; + + Guid chatRoomId; + try + { + using var doc = JsonDocument.Parse(metadata.Output); + if ( + !doc.RootElement.TryGetProperty("chatRoomId", out var roomIdElement) + && !doc.RootElement.TryGetProperty("ChatRoomId", out roomIdElement) + ) + return; + + chatRoomId = roomIdElement.GetGuid(); + } + catch (JsonException) + { + return; + } + + var chatEvent = new Subscriptions.ChatSubscriptionEvent( + ChatRoomId: chatRoomId, + EventType: eventType, + Payload: metadata.Output, + Timestamp: metadata.EndTime ?? DateTime.UtcNow, + TrainExternalId: metadata.ExternalId + ); + + await eventSender.SendAsync($"ChatRoom:{chatRoomId}", chatEvent, ct); + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHookFactory.cs b/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHookFactory.cs new file mode 100644 index 0000000..6111d05 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Hooks/ChatLifecycleHookFactory.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Trax.Effect.Services.TrainLifecycleHook; +using Trax.Effect.Services.TrainLifecycleHookFactory; + +namespace Trax.Samples.ChatService.Hooks; + +public class ChatLifecycleHookFactory(IServiceProvider serviceProvider) : ITrainLifecycleHookFactory +{ + public ITrainLifecycleHook Create() => + ActivatorUtilities.CreateInstance(serviceProvider); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptionEvent.cs b/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptionEvent.cs new file mode 100644 index 0000000..2b6ef6f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptionEvent.cs @@ -0,0 +1,9 @@ +namespace Trax.Samples.ChatService.Subscriptions; + +public record ChatSubscriptionEvent( + Guid ChatRoomId, + string EventType, + string Payload, + DateTime Timestamp, + string TrainExternalId +); diff --git a/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptions.cs b/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptions.cs new file mode 100644 index 0000000..74a7bf9 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Subscriptions/ChatSubscriptions.cs @@ -0,0 +1,15 @@ +using HotChocolate; +using HotChocolate.Types; + +namespace Trax.Samples.ChatService.Subscriptions; + +[ExtendObjectType(OperationTypeNames.Subscription)] +public class ChatSubscriptions +{ + [Subscribe] + [Topic("ChatRoom:{chatRoomId}")] + public ChatSubscriptionEvent OnChatEvent( + Guid chatRoomId, + [EventMessage] ChatSubscriptionEvent message + ) => message; +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomInput.cs new file mode 100644 index 0000000..0b421b8 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomInput.cs @@ -0,0 +1,8 @@ +namespace Trax.Samples.ChatService.Trains.CreateChatRoom; + +public record CreateChatRoomInput +{ + public required string Name { get; init; } + public required string UserId { get; init; } + public required string DisplayName { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomOutput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomOutput.cs new file mode 100644 index 0000000..9fc5bf2 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomOutput.cs @@ -0,0 +1,8 @@ +namespace Trax.Samples.ChatService.Trains.CreateChatRoom; + +public record CreateChatRoomOutput +{ + public Guid ChatRoomId { get; init; } + public required string Name { get; init; } + public DateTime CreatedAt { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomTrain.cs new file mode 100644 index 0000000..c94eeef --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/CreateChatRoomTrain.cs @@ -0,0 +1,17 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.CreateChatRoom.Steps; + +namespace Trax.Samples.ChatService.Trains.CreateChatRoom; + +[TraxMutation(Description = "Creates a new chat room and adds the creator as a participant")] +[TraxBroadcast] +public class CreateChatRoomTrain + : ServiceTrain, + ICreateChatRoomTrain +{ + protected override async Task> RunInternal( + CreateChatRoomInput input + ) => Activate(input).Chain().Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/ICreateChatRoomTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/ICreateChatRoomTrain.cs new file mode 100644 index 0000000..ad29e47 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/ICreateChatRoomTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.CreateChatRoom; + +public interface ICreateChatRoomTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/PersistRoomStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/PersistRoomStep.cs new file mode 100644 index 0000000..5b3f74a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/PersistRoomStep.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; + +namespace Trax.Samples.ChatService.Trains.CreateChatRoom.Steps; + +public class PersistRoomStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(CreateChatRoomInput input) + { + var now = DateTime.UtcNow; + + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = input.Name, + CreatedAt = now, + CreatedByUserId = input.UserId, + }; + + var participant = new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = room.Id, + UserId = input.UserId, + DisplayName = input.DisplayName, + JoinedAt = now, + LastReadAt = now, + }; + + db.ChatRooms.Add(room); + db.ChatParticipants.Add(participant); + await db.SaveChangesAsync(); + + logger.LogInformation( + "Created chat room {RoomId} '{Name}' with creator {UserId}", + room.Id, + room.Name, + input.UserId + ); + + return new CreateChatRoomOutput + { + ChatRoomId = room.Id, + Name = room.Name, + CreatedAt = room.CreatedAt, + }; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/ValidateInputStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/ValidateInputStep.cs new file mode 100644 index 0000000..05ce294 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/CreateChatRoom/Steps/ValidateInputStep.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using Trax.Core.Step; + +namespace Trax.Samples.ChatService.Trains.CreateChatRoom.Steps; + +public class ValidateInputStep(ILogger logger) + : Step +{ + public override Task Run(CreateChatRoomInput input) + { + if (string.IsNullOrWhiteSpace(input.Name)) + throw new ArgumentException("Chat room name is required."); + + if (string.IsNullOrWhiteSpace(input.UserId)) + throw new ArgumentException("User ID is required."); + + if (string.IsNullOrWhiteSpace(input.DisplayName)) + throw new ArgumentException("Display name is required."); + + logger.LogInformation( + "Validated CreateChatRoom input: name={Name}, user={UserId}", + input.Name, + input.UserId + ); + + return Task.FromResult(input); + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryInput.cs new file mode 100644 index 0000000..b0b88b3 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryInput.cs @@ -0,0 +1,8 @@ +namespace Trax.Samples.ChatService.Trains.GetChatHistory; + +public record GetChatHistoryInput +{ + public Guid ChatRoomId { get; init; } + public int Take { get; init; } = 50; + public DateTime? Before { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryOutput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryOutput.cs new file mode 100644 index 0000000..87726aa --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryOutput.cs @@ -0,0 +1,15 @@ +namespace Trax.Samples.ChatService.Trains.GetChatHistory; + +public record GetChatHistoryOutput +{ + public required List Messages { get; init; } +} + +public record ChatMessageDto +{ + public Guid Id { get; init; } + public required string SenderUserId { get; init; } + public required string SenderDisplayName { get; init; } + public required string Content { get; init; } + public DateTime SentAt { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryTrain.cs new file mode 100644 index 0000000..939751f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/GetChatHistoryTrain.cs @@ -0,0 +1,16 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.GetChatHistory.Steps; + +namespace Trax.Samples.ChatService.Trains.GetChatHistory; + +[TraxQuery(Description = "Retrieves message history for a chat room")] +public class GetChatHistoryTrain + : ServiceTrain, + IGetChatHistoryTrain +{ + protected override async Task> RunInternal( + GetChatHistoryInput input + ) => Activate(input).Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/IGetChatHistoryTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/IGetChatHistoryTrain.cs new file mode 100644 index 0000000..aded325 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/IGetChatHistoryTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.GetChatHistory; + +public interface IGetChatHistoryTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/Steps/FetchMessagesStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/Steps/FetchMessagesStep.cs new file mode 100644 index 0000000..7219f3e --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatHistory/Steps/FetchMessagesStep.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Trains.GetChatHistory.Steps; + +public class FetchMessagesStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(GetChatHistoryInput input) + { + logger.LogInformation( + "Fetching chat history for room {ChatRoomId}, take {Take}", + input.ChatRoomId, + input.Take + ); + + var query = db.ChatMessages.Where(m => m.ChatRoomId == input.ChatRoomId); + + if (input.Before.HasValue) + query = query.Where(m => m.SentAt < input.Before.Value); + + var messages = await query + .OrderByDescending(m => m.SentAt) + .Take(input.Take) + .Select(m => new ChatMessageDto + { + Id = m.Id, + SenderUserId = m.SenderUserId, + SenderDisplayName = m.SenderDisplayName, + Content = m.Content, + SentAt = m.SentAt, + }) + .ToListAsync(); + + // Return in chronological order + messages.Reverse(); + + logger.LogInformation( + "Returning {Count} messages for room {ChatRoomId}", + messages.Count, + input.ChatRoomId + ); + + return new GetChatHistoryOutput { Messages = messages }; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsInput.cs new file mode 100644 index 0000000..111c36f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsInput.cs @@ -0,0 +1,6 @@ +namespace Trax.Samples.ChatService.Trains.GetChatRooms; + +public record GetChatRoomsInput +{ + public required string UserId { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsOutput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsOutput.cs new file mode 100644 index 0000000..9137c1d --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsOutput.cs @@ -0,0 +1,15 @@ +namespace Trax.Samples.ChatService.Trains.GetChatRooms; + +public record GetChatRoomsOutput +{ + public required List Rooms { get; init; } +} + +public record ChatRoomSummary +{ + public Guid Id { get; init; } + public required string Name { get; init; } + public int ParticipantCount { get; init; } + public DateTime? LastMessageAt { get; init; } + public int UnreadCount { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsTrain.cs new file mode 100644 index 0000000..059ec78 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/GetChatRoomsTrain.cs @@ -0,0 +1,16 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.GetChatRooms.Steps; + +namespace Trax.Samples.ChatService.Trains.GetChatRooms; + +[TraxQuery(Description = "Lists chat rooms the user participates in")] +public class GetChatRoomsTrain + : ServiceTrain, + IGetChatRoomsTrain +{ + protected override async Task> RunInternal( + GetChatRoomsInput input + ) => Activate(input).Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/IGetChatRoomsTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/IGetChatRoomsTrain.cs new file mode 100644 index 0000000..6311b88 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/IGetChatRoomsTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.GetChatRooms; + +public interface IGetChatRoomsTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/Steps/FetchRoomsStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/Steps/FetchRoomsStep.cs new file mode 100644 index 0000000..b22220a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/GetChatRooms/Steps/FetchRoomsStep.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Trains.GetChatRooms.Steps; + +public class FetchRoomsStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(GetChatRoomsInput input) + { + logger.LogInformation("Fetching chat rooms for user {UserId}", input.UserId); + + var rooms = await db + .ChatParticipants.Where(p => p.UserId == input.UserId) + .Select(p => new ChatRoomSummary + { + Id = p.ChatRoom.Id, + Name = p.ChatRoom.Name, + ParticipantCount = p.ChatRoom.Participants.Count, + LastMessageAt = p.ChatRoom.Messages.Max(m => (DateTime?)m.SentAt), + UnreadCount = + p.LastReadAt == null + ? p.ChatRoom.Messages.Count + : p.ChatRoom.Messages.Count(m => m.SentAt > p.LastReadAt), + }) + .OrderByDescending(r => r.LastMessageAt) + .ToListAsync(); + + logger.LogInformation("Found {Count} rooms for user {UserId}", rooms.Count, input.UserId); + + return new GetChatRoomsOutput { Rooms = rooms }; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/IJoinChatRoomTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/IJoinChatRoomTrain.cs new file mode 100644 index 0000000..a03d49a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/IJoinChatRoomTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.JoinChatRoom; + +public interface IJoinChatRoomTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomInput.cs new file mode 100644 index 0000000..4aa6d8f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomInput.cs @@ -0,0 +1,8 @@ +namespace Trax.Samples.ChatService.Trains.JoinChatRoom; + +public record JoinChatRoomInput +{ + public Guid ChatRoomId { get; init; } + public required string UserId { get; init; } + public required string DisplayName { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomOutput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomOutput.cs new file mode 100644 index 0000000..5aa6345 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomOutput.cs @@ -0,0 +1,9 @@ +namespace Trax.Samples.ChatService.Trains.JoinChatRoom; + +public record JoinChatRoomOutput +{ + public Guid ChatRoomId { get; init; } + public required string UserId { get; init; } + public required string DisplayName { get; init; } + public DateTime JoinedAt { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomTrain.cs new file mode 100644 index 0000000..284e613 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/JoinChatRoomTrain.cs @@ -0,0 +1,17 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.JoinChatRoom.Steps; + +namespace Trax.Samples.ChatService.Trains.JoinChatRoom; + +[TraxMutation(Description = "Adds a user to an existing chat room")] +[TraxBroadcast] +public class JoinChatRoomTrain + : ServiceTrain, + IJoinChatRoomTrain +{ + protected override async Task> RunInternal( + JoinChatRoomInput input + ) => Activate(input).Chain().Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/AddParticipantStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/AddParticipantStep.cs new file mode 100644 index 0000000..bf2176e --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/AddParticipantStep.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; + +namespace Trax.Samples.ChatService.Trains.JoinChatRoom.Steps; + +public class AddParticipantStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(JoinChatRoomInput input) + { + var now = DateTime.UtcNow; + + var participant = new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = input.ChatRoomId, + UserId = input.UserId, + DisplayName = input.DisplayName, + JoinedAt = now, + LastReadAt = now, + }; + + db.ChatParticipants.Add(participant); + await db.SaveChangesAsync(); + + logger.LogInformation( + "User {UserId} joined room {ChatRoomId}", + input.UserId, + input.ChatRoomId + ); + + return new JoinChatRoomOutput + { + ChatRoomId = input.ChatRoomId, + UserId = input.UserId, + DisplayName = input.DisplayName, + JoinedAt = now, + }; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/ValidateJoinStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/ValidateJoinStep.cs new file mode 100644 index 0000000..9ca0042 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/JoinChatRoom/Steps/ValidateJoinStep.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Trains.JoinChatRoom.Steps; + +public class ValidateJoinStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(JoinChatRoomInput input) + { + var roomExists = await db.ChatRooms.AnyAsync(r => r.Id == input.ChatRoomId); + if (!roomExists) + throw new InvalidOperationException($"Chat room {input.ChatRoomId} does not exist."); + + var alreadyJoined = await db.ChatParticipants.AnyAsync(p => + p.ChatRoomId == input.ChatRoomId && p.UserId == input.UserId + ); + if (alreadyJoined) + throw new InvalidOperationException( + $"User {input.UserId} is already a participant in room {input.ChatRoomId}." + ); + + logger.LogInformation( + "Validated join: user {UserId} can join room {ChatRoomId}", + input.UserId, + input.ChatRoomId + ); + + return input; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/IMarkChatAsReadTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/IMarkChatAsReadTrain.cs new file mode 100644 index 0000000..51273b6 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/IMarkChatAsReadTrain.cs @@ -0,0 +1,6 @@ +using LanguageExt; +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.MarkChatAsRead; + +public interface IMarkChatAsReadTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadInput.cs new file mode 100644 index 0000000..9e742d1 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadInput.cs @@ -0,0 +1,7 @@ +namespace Trax.Samples.ChatService.Trains.MarkChatAsRead; + +public record MarkChatAsReadInput +{ + public Guid ChatRoomId { get; init; } + public required string UserId { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadTrain.cs new file mode 100644 index 0000000..853ae61 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/MarkChatAsReadTrain.cs @@ -0,0 +1,13 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.MarkChatAsRead.Steps; + +namespace Trax.Samples.ChatService.Trains.MarkChatAsRead; + +[TraxMutation(Description = "Marks a chat room as read for a user")] +public class MarkChatAsReadTrain : ServiceTrain, IMarkChatAsReadTrain +{ + protected override async Task> RunInternal(MarkChatAsReadInput input) => + Activate(input).Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/Steps/UpdateLastReadStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/Steps/UpdateLastReadStep.cs new file mode 100644 index 0000000..eaa11bc --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/MarkChatAsRead/Steps/UpdateLastReadStep.cs @@ -0,0 +1,34 @@ +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Trains.MarkChatAsRead.Steps; + +public class UpdateLastReadStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(MarkChatAsReadInput input) + { + var participant = await db.ChatParticipants.FirstOrDefaultAsync(p => + p.ChatRoomId == input.ChatRoomId && p.UserId == input.UserId + ); + + if (participant is null) + throw new InvalidOperationException( + $"User {input.UserId} is not a participant in room {input.ChatRoomId}." + ); + + participant.LastReadAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + logger.LogInformation( + "Marked room {ChatRoomId} as read for user {UserId}", + input.ChatRoomId, + input.UserId + ); + + return Unit.Default; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/ISendMessageTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/ISendMessageTrain.cs new file mode 100644 index 0000000..5c3da94 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/ISendMessageTrain.cs @@ -0,0 +1,5 @@ +using Trax.Effect.Services.ServiceTrain; + +namespace Trax.Samples.ChatService.Trains.SendMessage; + +public interface ISendMessageTrain : IServiceTrain; diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageInput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageInput.cs new file mode 100644 index 0000000..10f730a --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageInput.cs @@ -0,0 +1,8 @@ +namespace Trax.Samples.ChatService.Trains.SendMessage; + +public record SendMessageInput +{ + public Guid ChatRoomId { get; init; } + public required string SenderUserId { get; init; } + public required string Content { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageOutput.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageOutput.cs new file mode 100644 index 0000000..33b298f --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageOutput.cs @@ -0,0 +1,11 @@ +namespace Trax.Samples.ChatService.Trains.SendMessage; + +public record SendMessageOutput +{ + public Guid MessageId { get; init; } + public Guid ChatRoomId { get; init; } + public required string SenderUserId { get; init; } + public required string SenderDisplayName { get; init; } + public required string Content { get; init; } + public DateTime SentAt { get; init; } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageTrain.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageTrain.cs new file mode 100644 index 0000000..e500fe8 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/SendMessageTrain.cs @@ -0,0 +1,15 @@ +using LanguageExt; +using Trax.Effect.Attributes; +using Trax.Effect.Services.ServiceTrain; +using Trax.Samples.ChatService.Trains.SendMessage.Steps; + +namespace Trax.Samples.ChatService.Trains.SendMessage; + +[TraxMutation(Description = "Sends a message to a chat room")] +[TraxBroadcast] +public class SendMessageTrain : ServiceTrain, ISendMessageTrain +{ + protected override async Task> RunInternal( + SendMessageInput input + ) => Activate(input).Chain().Chain().Resolve(); +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/PersistMessageStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/PersistMessageStep.cs new file mode 100644 index 0000000..b0474f0 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/PersistMessageStep.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; + +namespace Trax.Samples.ChatService.Trains.SendMessage.Steps; + +public class PersistMessageStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(SendMessageInput input) + { + var participant = await db.ChatParticipants.FirstAsync(p => + p.ChatRoomId == input.ChatRoomId && p.UserId == input.SenderUserId + ); + + var now = DateTime.UtcNow; + + var message = new ChatMessage + { + Id = Guid.NewGuid(), + ChatRoomId = input.ChatRoomId, + SenderUserId = input.SenderUserId, + SenderDisplayName = participant.DisplayName, + Content = input.Content, + SentAt = now, + }; + + db.ChatMessages.Add(message); + + // Mark sender's own messages as read + participant.LastReadAt = now; + + await db.SaveChangesAsync(); + + logger.LogInformation( + "Message {MessageId} sent by {UserId} in room {ChatRoomId}", + message.Id, + input.SenderUserId, + input.ChatRoomId + ); + + return new SendMessageOutput + { + MessageId = message.Id, + ChatRoomId = input.ChatRoomId, + SenderUserId = input.SenderUserId, + SenderDisplayName = participant.DisplayName, + Content = input.Content, + SentAt = now, + }; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/ValidateSenderStep.cs b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/ValidateSenderStep.cs new file mode 100644 index 0000000..ab39d0b --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trains/SendMessage/Steps/ValidateSenderStep.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Trax.Core.Step; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Trains.SendMessage.Steps; + +public class ValidateSenderStep(ChatDbContext db, ILogger logger) + : Step +{ + public override async Task Run(SendMessageInput input) + { + if (string.IsNullOrWhiteSpace(input.Content)) + throw new ArgumentException("Message content cannot be empty."); + + var isParticipant = await db.ChatParticipants.AnyAsync(p => + p.ChatRoomId == input.ChatRoomId && p.UserId == input.SenderUserId + ); + + if (!isParticipant) + throw new InvalidOperationException( + $"User {input.SenderUserId} is not a participant in room {input.ChatRoomId}." + ); + + logger.LogInformation( + "Validated sender {UserId} in room {ChatRoomId}", + input.SenderUserId, + input.ChatRoomId + ); + + return input; + } +} diff --git a/samples/ChatService/Trax.Samples.ChatService/Trax.Samples.ChatService.csproj b/samples/ChatService/Trax.Samples.ChatService/Trax.Samples.ChatService.csproj new file mode 100644 index 0000000..3baf7f2 --- /dev/null +++ b/samples/ChatService/Trax.Samples.ChatService/Trax.Samples.ChatService.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + diff --git a/tests/Trax.Samples.ChatService.Tests/Fixtures/ChatDbContextFixture.cs b/tests/Trax.Samples.ChatService.Tests/Fixtures/ChatDbContextFixture.cs new file mode 100644 index 0000000..8a2d644 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/Fixtures/ChatDbContextFixture.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Trax.Samples.ChatService.Data; + +namespace Trax.Samples.ChatService.Tests.Fixtures; + +public static class ChatDbContextFixture +{ + public static ChatDbContext Create() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + return new ChatDbContext(options); + } +} diff --git a/tests/Trax.Samples.ChatService.Tests/GlobalUsings.cs b/tests/Trax.Samples.ChatService.Tests/GlobalUsings.cs new file mode 100644 index 0000000..3244567 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/CreateChatRoomTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/CreateChatRoomTests.cs new file mode 100644 index 0000000..b2de146 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/CreateChatRoomTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.CreateChatRoom; +using Trax.Samples.ChatService.Trains.CreateChatRoom.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class CreateChatRoomTests +{ + #region ValidateInputStep + + [Test] + public void ValidateInput_EmptyName_Throws() + { + var step = new ValidateInputStep(NullLogger.Instance); + var input = new CreateChatRoomInput + { + Name = "", + UserId = "alice", + DisplayName = "Alice", + }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*name*"); + } + + [Test] + public void ValidateInput_EmptyUserId_Throws() + { + var step = new ValidateInputStep(NullLogger.Instance); + var input = new CreateChatRoomInput + { + Name = "General", + UserId = "", + DisplayName = "Alice", + }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*User ID*"); + } + + [Test] + public async Task ValidateInput_ValidInput_ReturnsInput() + { + var step = new ValidateInputStep(NullLogger.Instance); + var input = new CreateChatRoomInput + { + Name = "General", + UserId = "alice", + DisplayName = "Alice", + }; + + var result = await step.Run(input); + + result.Should().Be(input); + } + + #endregion + + #region PersistRoomStep + + [Test] + public async Task PersistRoom_CreatesRoomAndParticipant() + { + using var db = ChatDbContextFixture.Create(); + var step = new PersistRoomStep(db, NullLogger.Instance); + var input = new CreateChatRoomInput + { + Name = "General", + UserId = "alice", + DisplayName = "Alice", + }; + + var result = await step.Run(input); + + result.Name.Should().Be("General"); + result.ChatRoomId.Should().NotBeEmpty(); + + var room = await db.ChatRooms.FindAsync(result.ChatRoomId); + room.Should().NotBeNull(); + room!.CreatedByUserId.Should().Be("alice"); + + db.ChatParticipants.Should() + .ContainSingle(p => p.ChatRoomId == result.ChatRoomId && p.UserId == "alice"); + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatHistoryTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatHistoryTests.cs new file mode 100644 index 0000000..c3172f5 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatHistoryTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.GetChatHistory; +using Trax.Samples.ChatService.Trains.GetChatHistory.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class GetChatHistoryTests +{ + #region FetchMessagesStep + + [Test] + public async Task FetchMessages_ReturnsMessagesInChronologicalOrder() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithMessages(db, 5); + + var step = new FetchMessagesStep(db, NullLogger.Instance); + var input = new GetChatHistoryInput { ChatRoomId = roomId, Take = 50 }; + + var result = await step.Run(input); + + result.Messages.Should().HaveCount(5); + result.Messages.Should().BeInAscendingOrder(m => m.SentAt); + } + + [Test] + public async Task FetchMessages_RespectsPageSize() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithMessages(db, 10); + + var step = new FetchMessagesStep(db, NullLogger.Instance); + var input = new GetChatHistoryInput { ChatRoomId = roomId, Take = 3 }; + + var result = await step.Run(input); + + result.Messages.Should().HaveCount(3); + } + + [Test] + public async Task FetchMessages_BeforeFilter_ReturnsOlderMessages() + { + using var db = ChatDbContextFixture.Create(); + var baseTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var roomId = await SeedRoomWithTimedMessages(db, baseTime, 5); + + var step = new FetchMessagesStep(db, NullLogger.Instance); + var input = new GetChatHistoryInput + { + ChatRoomId = roomId, + Take = 50, + Before = baseTime.AddMinutes(3), + }; + + var result = await step.Run(input); + + result.Messages.Should().HaveCount(3); + result + .Messages.Should() + .AllSatisfy(m => m.SentAt.Should().BeBefore(baseTime.AddMinutes(3))); + } + + [Test] + public async Task FetchMessages_EmptyRoom_ReturnsEmpty() + { + using var db = ChatDbContextFixture.Create(); + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = "Empty", + CreatedAt = DateTime.UtcNow, + CreatedByUserId = "alice", + }; + db.ChatRooms.Add(room); + await db.SaveChangesAsync(); + + var step = new FetchMessagesStep(db, NullLogger.Instance); + var input = new GetChatHistoryInput { ChatRoomId = room.Id, Take = 50 }; + + var result = await step.Run(input); + + result.Messages.Should().BeEmpty(); + } + + [Test] + public async Task FetchMessages_DifferentRoom_DoesNotCrossContaminate() + { + using var db = ChatDbContextFixture.Create(); + var roomId1 = await SeedRoomWithMessages(db, 3); + var roomId2 = await SeedRoomWithMessages(db, 5); + + var step = new FetchMessagesStep(db, NullLogger.Instance); + var input = new GetChatHistoryInput { ChatRoomId = roomId1, Take = 50 }; + + var result = await step.Run(input); + + result.Messages.Should().HaveCount(3); + } + + #endregion + + #region Helpers + + private static async Task SeedRoomWithMessages(ChatDbContext db, int count) + { + var baseTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + return await SeedRoomWithTimedMessages(db, baseTime, count); + } + + private static async Task SeedRoomWithTimedMessages( + ChatDbContext db, + DateTime baseTime, + int count + ) + { + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = "Test Room", + CreatedAt = DateTime.UtcNow, + CreatedByUserId = "alice", + }; + db.ChatRooms.Add(room); + + for (var i = 0; i < count; i++) + { + db.ChatMessages.Add( + new ChatMessage + { + Id = Guid.NewGuid(), + ChatRoomId = room.Id, + SenderUserId = "alice", + SenderDisplayName = "Alice", + Content = $"Message {i}", + SentAt = baseTime.AddMinutes(i), + } + ); + } + + await db.SaveChangesAsync(); + return room.Id; + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatRoomsTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatRoomsTests.cs new file mode 100644 index 0000000..247ea4b --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/GetChatRoomsTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.GetChatRooms; +using Trax.Samples.ChatService.Trains.GetChatRooms.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class GetChatRoomsTests +{ + #region FetchRoomsStep + + [Test] + public async Task FetchRooms_ReturnsOnlyRoomsUserIsIn() + { + using var db = ChatDbContextFixture.Create(); + var aliceRoomId = await SeedRoomWithParticipant(db, "alice", "Room A"); + await SeedRoomWithParticipant(db, "bob", "Room B"); + + var step = new FetchRoomsStep(db, NullLogger.Instance); + var input = new GetChatRoomsInput { UserId = "alice" }; + + var result = await step.Run(input); + + result.Rooms.Should().ContainSingle(); + result.Rooms[0].Id.Should().Be(aliceRoomId); + result.Rooms[0].Name.Should().Be("Room A"); + } + + [Test] + public async Task FetchRooms_IncludesParticipantCount() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithParticipant(db, "alice", "Room A"); + db.ChatParticipants.Add( + new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = roomId, + UserId = "bob", + DisplayName = "Bob", + JoinedAt = DateTime.UtcNow, + } + ); + await db.SaveChangesAsync(); + + var step = new FetchRoomsStep(db, NullLogger.Instance); + var input = new GetChatRoomsInput { UserId = "alice" }; + + var result = await step.Run(input); + + result.Rooms.Should().ContainSingle(); + result.Rooms[0].ParticipantCount.Should().Be(2); + } + + [Test] + public async Task FetchRooms_NoRooms_ReturnsEmpty() + { + using var db = ChatDbContextFixture.Create(); + + var step = new FetchRoomsStep(db, NullLogger.Instance); + var input = new GetChatRoomsInput { UserId = "nobody" }; + + var result = await step.Run(input); + + result.Rooms.Should().BeEmpty(); + } + + #endregion + + #region Helpers + + private static async Task SeedRoomWithParticipant( + ChatDbContext db, + string userId, + string roomName + ) + { + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = roomName, + CreatedAt = DateTime.UtcNow, + CreatedByUserId = userId, + }; + db.ChatRooms.Add(room); + + db.ChatParticipants.Add( + new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = room.Id, + UserId = userId, + DisplayName = userId, + JoinedAt = DateTime.UtcNow, + } + ); + + await db.SaveChangesAsync(); + return room.Id; + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/JoinChatRoomTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/JoinChatRoomTests.cs new file mode 100644 index 0000000..9375327 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/JoinChatRoomTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.JoinChatRoom; +using Trax.Samples.ChatService.Trains.JoinChatRoom.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class JoinChatRoomTests +{ + #region ValidateJoinStep + + [Test] + public void ValidateJoin_RoomDoesNotExist_Throws() + { + using var db = ChatDbContextFixture.Create(); + var step = new ValidateJoinStep(db, NullLogger.Instance); + var input = new JoinChatRoomInput + { + ChatRoomId = Guid.NewGuid(), + UserId = "alice", + DisplayName = "Alice", + }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*does not exist*"); + } + + [Test] + public async Task ValidateJoin_AlreadyParticipant_Throws() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoom(db, "alice"); + + db.ChatParticipants.Add( + new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = roomId, + UserId = "alice", + DisplayName = "Alice", + JoinedAt = DateTime.UtcNow, + } + ); + await db.SaveChangesAsync(); + + var step = new ValidateJoinStep(db, NullLogger.Instance); + var input = new JoinChatRoomInput + { + ChatRoomId = roomId, + UserId = "alice", + DisplayName = "Alice", + }; + + var act = () => step.Run(input); + + await act.Should() + .ThrowAsync() + .WithMessage("*already a participant*"); + } + + [Test] + public async Task ValidateJoin_ValidNewParticipant_ReturnsInput() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoom(db, "alice"); + + var step = new ValidateJoinStep(db, NullLogger.Instance); + var input = new JoinChatRoomInput + { + ChatRoomId = roomId, + UserId = "bob", + DisplayName = "Bob", + }; + + var result = await step.Run(input); + + result.Should().Be(input); + } + + #endregion + + #region AddParticipantStep + + [Test] + public async Task AddParticipant_PersistsParticipant() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoom(db, "alice"); + + var step = new AddParticipantStep(db, NullLogger.Instance); + var input = new JoinChatRoomInput + { + ChatRoomId = roomId, + UserId = "bob", + DisplayName = "Bob", + }; + + var result = await step.Run(input); + + result.ChatRoomId.Should().Be(roomId); + result.UserId.Should().Be("bob"); + result.DisplayName.Should().Be("Bob"); + result.JoinedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + + db.ChatParticipants.Should() + .ContainSingle(p => p.ChatRoomId == roomId && p.UserId == "bob"); + } + + #endregion + + #region Helpers + + private static async Task SeedRoom(ChatDbContext db, string creatorId) + { + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = "Test Room", + CreatedAt = DateTime.UtcNow, + CreatedByUserId = creatorId, + }; + db.ChatRooms.Add(room); + await db.SaveChangesAsync(); + return room.Id; + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/MarkChatAsReadTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/MarkChatAsReadTests.cs new file mode 100644 index 0000000..90f8b78 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/MarkChatAsReadTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.MarkChatAsRead; +using Trax.Samples.ChatService.Trains.MarkChatAsRead.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class MarkChatAsReadTests +{ + #region UpdateLastReadStep + + [Test] + public async Task UpdateLastRead_SetsLastReadAt() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithParticipant(db, "alice"); + + var step = new UpdateLastReadStep(db, NullLogger.Instance); + var input = new MarkChatAsReadInput { ChatRoomId = roomId, UserId = "alice" }; + + await step.Run(input); + + var participant = db.ChatParticipants.First(p => + p.ChatRoomId == roomId && p.UserId == "alice" + ); + participant.LastReadAt.Should().NotBeNull(); + participant.LastReadAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Test] + public void UpdateLastRead_UserNotParticipant_Throws() + { + using var db = ChatDbContextFixture.Create(); + var step = new UpdateLastReadStep(db, NullLogger.Instance); + var input = new MarkChatAsReadInput { ChatRoomId = Guid.NewGuid(), UserId = "nobody" }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*not a participant*"); + } + + #endregion + + #region Helpers + + private static async Task SeedRoomWithParticipant(ChatDbContext db, string userId) + { + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = "Test", + CreatedAt = DateTime.UtcNow, + CreatedByUserId = userId, + }; + db.ChatRooms.Add(room); + + db.ChatParticipants.Add( + new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = room.Id, + UserId = userId, + DisplayName = userId, + JoinedAt = DateTime.UtcNow, + } + ); + + await db.SaveChangesAsync(); + return room.Id; + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/IntegrationTests/SendMessageTests.cs b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/SendMessageTests.cs new file mode 100644 index 0000000..b91b564 --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/IntegrationTests/SendMessageTests.cs @@ -0,0 +1,137 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Trax.Samples.ChatService.Data.Entities; +using Trax.Samples.ChatService.Tests.Fixtures; +using Trax.Samples.ChatService.Trains.SendMessage; +using Trax.Samples.ChatService.Trains.SendMessage.Steps; + +namespace Trax.Samples.ChatService.Tests.IntegrationTests; + +[TestFixture] +public class SendMessageTests +{ + #region ValidateSenderStep + + [Test] + public async Task ValidateSender_UserIsParticipant_ReturnsInput() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithParticipant(db, "alice", "Alice"); + + var step = new ValidateSenderStep(db, NullLogger.Instance); + var input = new SendMessageInput + { + ChatRoomId = roomId, + SenderUserId = "alice", + Content = "Hello!", + }; + + var result = await step.Run(input); + + result.Should().Be(input); + } + + [Test] + public void ValidateSender_UserNotParticipant_Throws() + { + using var db = ChatDbContextFixture.Create(); + var step = new ValidateSenderStep(db, NullLogger.Instance); + var input = new SendMessageInput + { + ChatRoomId = Guid.NewGuid(), + SenderUserId = "unknown", + Content = "Hello!", + }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*not a participant*"); + } + + [Test] + public void ValidateSender_EmptyContent_Throws() + { + using var db = ChatDbContextFixture.Create(); + var step = new ValidateSenderStep(db, NullLogger.Instance); + var input = new SendMessageInput + { + ChatRoomId = Guid.NewGuid(), + SenderUserId = "alice", + Content = "", + }; + + var act = () => step.Run(input); + + act.Should().ThrowAsync().WithMessage("*empty*"); + } + + #endregion + + #region PersistMessageStep + + [Test] + public async Task PersistMessage_SavesMessageAndUpdatesLastRead() + { + using var db = ChatDbContextFixture.Create(); + var roomId = await SeedRoomWithParticipant(db, "alice", "Alice"); + + var step = new PersistMessageStep(db, NullLogger.Instance); + var input = new SendMessageInput + { + ChatRoomId = roomId, + SenderUserId = "alice", + Content = "Test message", + }; + + var result = await step.Run(input); + + result.MessageId.Should().NotBeEmpty(); + result.ChatRoomId.Should().Be(roomId); + result.SenderUserId.Should().Be("alice"); + result.SenderDisplayName.Should().Be("Alice"); + result.Content.Should().Be("Test message"); + + db.ChatMessages.Should().ContainSingle(m => m.Id == result.MessageId); + + var participant = db.ChatParticipants.First(p => + p.ChatRoomId == roomId && p.UserId == "alice" + ); + participant.LastReadAt.Should().NotBeNull(); + } + + #endregion + + #region Helpers + + private static async Task SeedRoomWithParticipant( + Data.ChatDbContext db, + string userId, + string displayName + ) + { + var room = new ChatRoom + { + Id = Guid.NewGuid(), + Name = "Test Room", + CreatedAt = DateTime.UtcNow, + CreatedByUserId = userId, + }; + db.ChatRooms.Add(room); + + db.ChatParticipants.Add( + new ChatParticipant + { + Id = Guid.NewGuid(), + ChatRoomId = room.Id, + UserId = userId, + DisplayName = displayName, + JoinedAt = DateTime.UtcNow, + } + ); + + await db.SaveChangesAsync(); + return room.Id; + } + + #endregion +} diff --git a/tests/Trax.Samples.ChatService.Tests/Trax.Samples.ChatService.Tests.csproj b/tests/Trax.Samples.ChatService.Tests/Trax.Samples.ChatService.Tests.csproj new file mode 100644 index 0000000..55983eb --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/Trax.Samples.ChatService.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + false + true + true + + + + + + + + + + + + + + + + + + diff --git a/tests/Trax.Samples.ChatService.Tests/UnitTests/ChatLifecycleHookTests.cs b/tests/Trax.Samples.ChatService.Tests/UnitTests/ChatLifecycleHookTests.cs new file mode 100644 index 0000000..cba798c --- /dev/null +++ b/tests/Trax.Samples.ChatService.Tests/UnitTests/ChatLifecycleHookTests.cs @@ -0,0 +1,302 @@ +using System.Text.Json; +using FluentAssertions; +using HotChocolate.Subscriptions; +using Moq; +using Trax.Effect.Models.Metadata; +using Trax.Samples.ChatService.Hooks; +using Trax.Samples.ChatService.Subscriptions; +using Trax.Samples.ChatService.Trains.CreateChatRoom; +using Trax.Samples.ChatService.Trains.JoinChatRoom; +using Trax.Samples.ChatService.Trains.MarkChatAsRead; +using Trax.Samples.ChatService.Trains.SendMessage; + +namespace Trax.Samples.ChatService.Tests.UnitTests; + +[TestFixture] +public class ChatLifecycleHookTests +{ + private Mock _eventSender = null!; + private ChatLifecycleHook _hook = null!; + + [SetUp] + public void SetUp() + { + _eventSender = new Mock(); + _hook = new ChatLifecycleHook(_eventSender.Object); + } + + #region OnCompleted — SendMessage + + [Test] + public async Task OnCompleted_SendMessageTrain_PublishesToCorrectTopic() + { + var chatRoomId = Guid.NewGuid(); + var output = new SendMessageOutput + { + MessageId = Guid.NewGuid(), + ChatRoomId = chatRoomId, + SenderUserId = "alice", + SenderDisplayName = "Alice", + Content = "Hello!", + SentAt = DateTime.UtcNow, + }; + + var metadata = CreateMetadata(typeof(ISendMessageTrain), output); + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + $"ChatRoom:{chatRoomId}", + It.Is(e => + e.ChatRoomId == chatRoomId && e.EventType == "MessageSent" + ), + It.IsAny() + ), + Times.Once + ); + } + + [Test] + public async Task OnCompleted_SendMessageTrain_EventContainsSerializedPayload() + { + var chatRoomId = Guid.NewGuid(); + var output = new SendMessageOutput + { + MessageId = Guid.NewGuid(), + ChatRoomId = chatRoomId, + SenderUserId = "bob", + SenderDisplayName = "Bob", + Content = "Test message", + SentAt = DateTime.UtcNow, + }; + + var metadata = CreateMetadata(typeof(ISendMessageTrain), output); + ChatSubscriptionEvent? capturedEvent = null; + + _eventSender + .Setup(s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .Callback( + (_, e, _) => capturedEvent = e + ); + + await _hook.OnCompleted(metadata, CancellationToken.None); + + capturedEvent.Should().NotBeNull(); + capturedEvent!.Payload.Should().Contain("Test message"); + capturedEvent.TrainExternalId.Should().Be(metadata.ExternalId); + } + + #endregion + + #region OnCompleted — CreateChatRoom + + [Test] + public async Task OnCompleted_CreateChatRoomTrain_PublishesRoomCreatedEvent() + { + var chatRoomId = Guid.NewGuid(); + var output = new CreateChatRoomOutput + { + ChatRoomId = chatRoomId, + Name = "General", + CreatedAt = DateTime.UtcNow, + }; + + var metadata = CreateMetadata(typeof(ICreateChatRoomTrain), output); + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + $"ChatRoom:{chatRoomId}", + It.Is(e => e.EventType == "RoomCreated"), + It.IsAny() + ), + Times.Once + ); + } + + #endregion + + #region OnCompleted — JoinChatRoom + + [Test] + public async Task OnCompleted_JoinChatRoomTrain_PublishesUserJoinedEvent() + { + var chatRoomId = Guid.NewGuid(); + var output = new JoinChatRoomOutput + { + ChatRoomId = chatRoomId, + UserId = "charlie", + DisplayName = "Charlie", + JoinedAt = DateTime.UtcNow, + }; + + var metadata = CreateMetadata(typeof(IJoinChatRoomTrain), output); + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + $"ChatRoom:{chatRoomId}", + It.Is(e => e.EventType == "UserJoined"), + It.IsAny() + ), + Times.Once + ); + } + + #endregion + + #region OnCompleted — Non-Chat Trains + + [Test] + public async Task OnCompleted_NonChatTrain_DoesNotPublish() + { + var metadata = new Metadata + { + Name = "SomeOther.Namespace.IUnrelatedTrain", + ExternalId = Guid.NewGuid().ToString(), + Output = """{"someField": "value"}""", + EndTime = DateTime.UtcNow, + }; + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task OnCompleted_MarkChatAsReadTrain_DoesNotPublish() + { + var metadata = new Metadata + { + Name = typeof(IMarkChatAsReadTrain).FullName!, + ExternalId = Guid.NewGuid().ToString(), + Output = """{"chatRoomId": "00000000-0000-0000-0000-000000000001"}""", + EndTime = DateTime.UtcNow, + }; + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + #endregion + + #region OnCompleted — Edge Cases + + [Test] + public async Task OnCompleted_NullOutput_DoesNotPublish() + { + var metadata = new Metadata + { + Name = typeof(ISendMessageTrain).FullName!, + ExternalId = Guid.NewGuid().ToString(), + Output = null, + EndTime = DateTime.UtcNow, + }; + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task OnCompleted_MalformedJson_DoesNotPublish() + { + var metadata = new Metadata + { + Name = typeof(ISendMessageTrain).FullName!, + ExternalId = Guid.NewGuid().ToString(), + Output = "not valid json", + EndTime = DateTime.UtcNow, + }; + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Test] + public async Task OnCompleted_JsonWithoutChatRoomId_DoesNotPublish() + { + var metadata = new Metadata + { + Name = typeof(ISendMessageTrain).FullName!, + ExternalId = Guid.NewGuid().ToString(), + Output = """{"someOtherField": "value"}""", + EndTime = DateTime.UtcNow, + }; + + await _hook.OnCompleted(metadata, CancellationToken.None); + + _eventSender.Verify( + s => + s.SendAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + #endregion + + #region Helpers + + private static Metadata CreateMetadata(Type trainInterface, T output) + { + return new Metadata + { + Name = trainInterface.FullName!, + ExternalId = Guid.NewGuid().ToString(), + Output = JsonSerializer.Serialize(output), + EndTime = DateTime.UtcNow, + }; + } + + #endregion +}