mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-23 02:21:17 +00:00
Compare commits
28 Commits
fc08f828f2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0ac1cd4e | |||
| 35c6371d48 | |||
| 7909211c1b | |||
| 99c097e503 | |||
| a52addebd8 | |||
| 4cef7f2ea1 | |||
| dd2b37938f | |||
| a6be7b31e7 | |||
| b24d879d31 | |||
| 7862a6d367 | |||
| 0095d9cd72 | |||
| ca2664b617 | |||
| 21750582b1 | |||
| 61c1ade631 | |||
| 9ac88d921c | |||
| 0b51b97f6c | |||
| 8eedd1f4fd | |||
| 4b824c66ce | |||
| 272fb9b9b3 | |||
| 5ea24b0a13 | |||
| bed033652c | |||
| ec083c3c77 | |||
| 45fa539d3e | |||
| b4ad910a14 | |||
| d3452dd452 | |||
| c78ad25f0d | |||
| e07157eba0 | |||
| 3036f4ad3f |
42
.github/workflows/build-waitlist-website.yml
vendored
Normal file
42
.github/workflows/build-waitlist-website.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Build waitlist website
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- apps/waitlist-website/**
|
||||||
|
- .github/workflows/build-waitlist-website.yml
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: cr.nym.sh
|
||||||
|
IMAGE_NAME: aelis-waitlist-website
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: apps/waitlist-website
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -21,4 +21,4 @@ jobs:
|
|||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun test
|
run: bun run test
|
||||||
|
|||||||
@@ -6,3 +6,14 @@ services:
|
|||||||
- postDevcontainerStart
|
- postDevcontainerStart
|
||||||
commands:
|
commands:
|
||||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
||||||
|
|
||||||
|
drizzle-studio:
|
||||||
|
name: Drizzle Studio
|
||||||
|
description: Drizzle Studio database browser for aelis-backend
|
||||||
|
triggeredBy:
|
||||||
|
- manual
|
||||||
|
commands:
|
||||||
|
start: |
|
||||||
|
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
|
||||||
|
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
|
||||||
|
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
|
||||||
|
|||||||
24
apps/admin-dashboard/.gitignore
vendored
Normal file
24
apps/admin-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
7
apps/admin-dashboard/.prettierignore
Normal file
7
apps/admin-dashboard/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
.pnpm-store/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
11
apps/admin-dashboard/.prettierrc
Normal file
11
apps/admin-dashboard/.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tailwindStylesheet": "src/index.css",
|
||||||
|
"tailwindFunctions": ["cn", "cva"]
|
||||||
|
}
|
||||||
21
apps/admin-dashboard/README.md
Normal file
21
apps/admin-dashboard/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# React + TypeScript + Vite + shadcn/ui
|
||||||
|
|
||||||
|
This is a template for a new Vite project with React, TypeScript, and shadcn/ui.
|
||||||
|
|
||||||
|
## Adding components
|
||||||
|
|
||||||
|
To add components to your app, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button
|
||||||
|
```
|
||||||
|
|
||||||
|
This will place the ui components in the `src/components` directory.
|
||||||
|
|
||||||
|
## Using components
|
||||||
|
|
||||||
|
To use the components in your app, import them as follows:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
```
|
||||||
25
apps/admin-dashboard/components.json
Normal file
25
apps/admin-dashboard/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-mira",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
23
apps/admin-dashboard/eslint.config.js
Normal file
23
apps/admin-dashboard/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
apps/admin-dashboard/index.html
Normal file
13
apps/admin-dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>vite-app</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
apps/admin-dashboard/package.json
Normal file
48
apps/admin-dashboard/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@tanstack/react-query": "^5.95.0",
|
||||||
|
"@tanstack/react-router": "^1.168.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"shadcn": "^4.0.8",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/admin-dashboard/public/vite.svg
Normal file
1
apps/admin-dashboard/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
25
apps/admin-dashboard/src/App.tsx
Normal file
25
apps/admin-dashboard/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||||
|
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import { routeTree } from "./route-tree.gen"
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
context: {
|
||||||
|
queryClient: undefined! as QueryClient,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return <RouterProvider router={router} context={{ queryClient }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
apps/admin-dashboard/src/assets/react.svg
Normal file
1
apps/admin-dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
146
apps/admin-dashboard/src/components/feed-panel.tsx
Normal file
146
apps/admin-dashboard/src/components/feed-panel.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Loader2, RefreshCw, TriangleAlert } from "lucide-react"
|
||||||
|
|
||||||
|
import type { FeedItem } from "@/lib/api"
|
||||||
|
import { fetchFeed } from "@/lib/api"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export function FeedPanel() {
|
||||||
|
const {
|
||||||
|
data: feed,
|
||||||
|
error: feedError,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["feed"],
|
||||||
|
queryFn: fetchFeed,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = feedError?.message ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Feed</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Query the feed as the current user.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{feed ? "Refresh" : "Fetch"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="-mx-4 border-destructive">
|
||||||
|
<CardContent className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<TriangleAlert className="size-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feed && feed.errors.length > 0 && (
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm">Source Errors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{feed.errors.map((e) => (
|
||||||
|
<div key={e.sourceId} className="flex items-start gap-2 text-sm">
|
||||||
|
<Badge variant="outline" className="shrink-0 font-mono text-xs">
|
||||||
|
{e.sourceId}
|
||||||
|
</Badge>
|
||||||
|
<span className="select-text text-muted-foreground">{e.error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feed && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{feed.items.length} {feed.items.length === 1 ? "item" : "items"}
|
||||||
|
</p>
|
||||||
|
{feed.items.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No items in feed.</p>
|
||||||
|
)}
|
||||||
|
{feed.items.map((item) => (
|
||||||
|
<FeedItemCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedItemCard({ item }: { item: FeedItem }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-sm">{item.type}</CardTitle>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{item.sourceId}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.signals?.timeRelevance && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{item.signals.timeRelevance}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.signals?.urgency !== undefined && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
urgency: {item.signals.urgency}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="select-text font-mono text-xs text-muted-foreground">{item.id}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{item.slots && Object.keys(item.slots).length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{Object.entries(item.slots).map(([name, slot]) => (
|
||||||
|
<div key={name} className="text-sm">
|
||||||
|
<span className="font-medium">{name}: </span>
|
||||||
|
<span className="select-text text-muted-foreground">
|
||||||
|
{slot.content ?? <span className="italic">pending</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-0 text-xs text-muted-foreground"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : "Show"} raw data
|
||||||
|
</Button>
|
||||||
|
{expanded && (
|
||||||
|
<pre className="select-text overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
|
||||||
|
{JSON.stringify(item.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { CircleCheck, CircleX, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { getServerUrl } from "@/lib/server-url"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
async function checkHealth(serverUrl: string): Promise<boolean> {
|
||||||
|
const res = await fetch(`${serverUrl}/health`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = (await res.json()) as { status: string }
|
||||||
|
if (data.status !== "ok") throw new Error("Unexpected response")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralSettingsPanel() {
|
||||||
|
const serverUrl = getServerUrl()
|
||||||
|
|
||||||
|
const { isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["health", serverUrl],
|
||||||
|
queryFn: () => checkHealth(serverUrl),
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = isLoading ? "checking" : isError ? "error" : "ok"
|
||||||
|
const errorMsg = error instanceof Error ? error.message : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">General</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Backend server information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-sm">Server</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Connected backend instance.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="shrink-0 text-muted-foreground">URL</span>
|
||||||
|
<span className="select-text truncate font-mono text-xs">{serverUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Status</span>
|
||||||
|
{status === "checking" && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
Checking…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === "ok" && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<CircleCheck className="size-3.5 text-primary" />
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||||||
|
<CircleX className="size-3.5" />
|
||||||
|
{errorMsg ?? "Unreachable"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
apps/admin-dashboard/src/components/login-page.tsx
Normal file
100
apps/admin-dashboard/src/components/login-page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Loader2, Settings2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import type { AuthSession } from "@/lib/auth"
|
||||||
|
import { signIn } from "@/lib/auth"
|
||||||
|
import { getServerUrl, setServerUrl } from "@/lib/server-url"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: (session: AuthSession) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage({ onLogin }: LoginPageProps) {
|
||||||
|
const [serverUrlInput, setServerUrlInput] = useState(getServerUrl)
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
setServerUrl(serverUrlInput)
|
||||||
|
return signIn(email, password)
|
||||||
|
},
|
||||||
|
onSuccess(session) {
|
||||||
|
onLogin(session)
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
loginMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = loginMutation.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Settings2 className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Admin Dashboard</CardTitle>
|
||||||
|
<CardDescription>Sign in to manage source configuration.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">Server URL</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
type="url"
|
||||||
|
value={serverUrlInput}
|
||||||
|
onChange={(e) => setServerUrlInput(e.target.value)}
|
||||||
|
placeholder="http://localhost:3000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="admin@aelis.local"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
464
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
464
apps/admin-dashboard/src/components/source-config-panel.tsx
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Info, Loader2, MapPin, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import type { ConfigFieldDef, SourceDefinition } from "@/lib/api"
|
||||||
|
import { fetchSourceConfig, pushLocation, replaceSource, updateProviderConfig } from "@/lib/api"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
interface SourceConfigPanelProps {
|
||||||
|
source: SourceDefinition
|
||||||
|
onUpdate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [dirty, setDirty] = useState<Record<string, unknown>>({})
|
||||||
|
|
||||||
|
const { data: serverConfig, isLoading } = useQuery({
|
||||||
|
queryKey: ["sourceConfig", source.id],
|
||||||
|
queryFn: () => fetchSourceConfig(source.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
const enabled = serverConfig?.enabled ?? false
|
||||||
|
const serverValues = buildInitialValues(source.fields, serverConfig?.config)
|
||||||
|
const formValues = { ...serverValues, ...dirty }
|
||||||
|
|
||||||
|
function isCredentialField(field: ConfigFieldDef): boolean {
|
||||||
|
return !!(field.secret && field.required)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserConfig(): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [name, value] of Object.entries(formValues)) {
|
||||||
|
const field = source.fields[name]
|
||||||
|
if (field && !isCredentialField(field)) {
|
||||||
|
result[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCredentialFields(): Record<string, unknown> {
|
||||||
|
const creds: Record<string, unknown> = {}
|
||||||
|
for (const [name, value] of Object.entries(formValues)) {
|
||||||
|
const field = source.fields[name]
|
||||||
|
if (field && isCredentialField(field)) {
|
||||||
|
creds[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["sourceConfig", source.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["configs"] })
|
||||||
|
onUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const promises: Promise<void>[] = [
|
||||||
|
replaceSource(source.id, { enabled, config: getUserConfig() }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const credentialFields = getCredentialFields()
|
||||||
|
const hasCredentials = Object.values(credentialFields).some(
|
||||||
|
(v) => typeof v === "string" && v.length > 0,
|
||||||
|
)
|
||||||
|
if (hasCredentials) {
|
||||||
|
promises.push(
|
||||||
|
updateProviderConfig(source.id, { credentials: credentialFields }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
setDirty({})
|
||||||
|
invalidate()
|
||||||
|
toast.success("Configuration saved")
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (checked: boolean) =>
|
||||||
|
replaceSource(source.id, { enabled: checked, config: getUserConfig() }),
|
||||||
|
onSuccess(_data, checked) {
|
||||||
|
invalidate()
|
||||||
|
toast.success(`Source ${checked ? "enabled" : "disabled"}`)
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => replaceSource(source.id, { enabled: false, config: {} }),
|
||||||
|
onSuccess() {
|
||||||
|
setDirty({})
|
||||||
|
invalidate()
|
||||||
|
toast.success("Configuration deleted")
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleFieldChange(fieldName: string, value: unknown) {
|
||||||
|
setDirty((prev) => ({ ...prev, [fieldName]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldEntries = Object.entries(source.fields)
|
||||||
|
const hasFields = fieldEntries.length > 0
|
||||||
|
const busy = saveMutation.isPending || toggleMutation.isPending || deleteMutation.isPending
|
||||||
|
|
||||||
|
const requiredFields = fieldEntries.filter(([, f]) => f.required)
|
||||||
|
const optionalFields = fieldEntries.filter(([, f]) => !f.required)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">{source.name}</h2>
|
||||||
|
{source.alwaysEnabled ? (
|
||||||
|
<Badge variant="secondary">Always on</Badge>
|
||||||
|
) : enabled ? (
|
||||||
|
<Badge className="bg-primary/10 text-primary">Enabled</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Disabled</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{source.description}</p>
|
||||||
|
</div>
|
||||||
|
{!source.alwaysEnabled && (
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config form */}
|
||||||
|
{hasFields && !source.alwaysEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Required fields */}
|
||||||
|
{requiredFields.length > 0 && (
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-sm">Credentials</CardTitle>
|
||||||
|
<CardDescription>Required fields to connect this source.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{requiredFields.map(([name, field]) => (
|
||||||
|
<FieldInput
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
field={field}
|
||||||
|
value={formValues[name]}
|
||||||
|
onChange={(v) => handleFieldChange(name, v)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional fields */}
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-sm">Options</CardTitle>
|
||||||
|
<CardDescription>Optional configuration for this source.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`grid gap-4 ${optionalFields.length > 1 ? "grid-cols-2" : ""}`}>
|
||||||
|
{optionalFields.map(([name, field]) => (
|
||||||
|
<FieldInput
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
field={field}
|
||||||
|
value={formValues[name]}
|
||||||
|
onChange={(v) => handleFieldChange(name, v)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
{serverConfig && (
|
||||||
|
<Button
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={busy}
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
)}
|
||||||
|
{deleteMutation.isPending ? "Deleting…" : "Delete configuration"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => saveMutation.mutate()} disabled={busy}>
|
||||||
|
{saveMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{saveMutation.isPending ? "Saving…" : "Save configuration"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Always-on sources */}
|
||||||
|
{source.alwaysEnabled && source.id !== "aelis.location" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This source is always enabled and requires no configuration.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source.id === "aelis.location" && <LocationCard />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationCard() {
|
||||||
|
const [lat, setLat] = useState("")
|
||||||
|
const [lng, setLng] = useState("")
|
||||||
|
|
||||||
|
const locationMutation = useMutation({
|
||||||
|
mutationFn: (coords: { lat: number; lng: number }) =>
|
||||||
|
pushLocation({ lat: coords.lat, lng: coords.lng, accuracy: 10 }),
|
||||||
|
onSuccess() {
|
||||||
|
toast.success("Location updated")
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePush() {
|
||||||
|
const latNum = parseFloat(lat)
|
||||||
|
const lngNum = parseFloat(lng)
|
||||||
|
if (isNaN(latNum) || isNaN(lngNum)) return
|
||||||
|
locationMutation.mutate({ lat: latNum, lng: lngNum })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUseDevice() {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setLat(String(pos.coords.latitude))
|
||||||
|
setLng(String(pos.coords.longitude))
|
||||||
|
locationMutation.mutate({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
locationMutation.reset()
|
||||||
|
alert(`Geolocation error: ${err.message}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="-mx-4">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-sm">Push Location</CardTitle>
|
||||||
|
<CardDescription>Send a location update to the backend.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-lat" className="text-xs font-medium">Latitude</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-lat"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={lat}
|
||||||
|
onChange={(e) => setLat(e.target.value)}
|
||||||
|
placeholder="51.5074"
|
||||||
|
disabled={locationMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loc-lng" className="text-xs font-medium">Longitude</Label>
|
||||||
|
<Input
|
||||||
|
id="loc-lng"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={lng}
|
||||||
|
onChange={(e) => setLng(e.target.value)}
|
||||||
|
placeholder="-0.1278"
|
||||||
|
disabled={locationMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseDevice}
|
||||||
|
disabled={locationMutation.isPending}
|
||||||
|
>
|
||||||
|
<MapPin className="size-3.5" />
|
||||||
|
Use device location
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePush}
|
||||||
|
disabled={locationMutation.isPending || !lat || !lng}
|
||||||
|
>
|
||||||
|
{locationMutation.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Push
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldInput({
|
||||||
|
name,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
field: ConfigFieldDef
|
||||||
|
value: unknown
|
||||||
|
onChange: (value: unknown) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const labelContent = (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
{field.required && <span className="text-destructive">*</span>}
|
||||||
|
{field.description && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="size-3 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||||
|
{field.description}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (field.type === "select" && field.options) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={name} className="text-xs font-medium">
|
||||||
|
{labelContent}
|
||||||
|
</Label>
|
||||||
|
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||||||
|
<SelectTrigger id={name}>
|
||||||
|
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "number") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={name} className="text-xs font-medium">
|
||||||
|
{labelContent}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
type="number"
|
||||||
|
value={value === undefined || value === null ? "" : String(value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onChange(v === "" ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={name} className="text-xs font-medium">
|
||||||
|
{labelContent}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
type={field.secret ? "password" : "text"}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialValues(
|
||||||
|
fields: Record<string, ConfigFieldDef>,
|
||||||
|
saved: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {}
|
||||||
|
for (const [name, field] of Object.entries(fields)) {
|
||||||
|
if (saved && name in saved) {
|
||||||
|
values[name] = saved[name]
|
||||||
|
} else if (field.defaultValue !== undefined) {
|
||||||
|
values[name] = field.defaultValue
|
||||||
|
} else {
|
||||||
|
values[name] = field.type === "number" ? undefined : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
230
apps/admin-dashboard/src/components/theme-provider.tsx
Normal file
230
apps/admin-dashboard/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
type Theme = "dark" | "light" | "system"
|
||||||
|
type ResolvedTheme = "dark" | "light"
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultTheme?: Theme
|
||||||
|
storageKey?: string
|
||||||
|
disableTransitionOnChange?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
|
||||||
|
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
|
||||||
|
|
||||||
|
const ThemeProviderContext = React.createContext<
|
||||||
|
ThemeProviderState | undefined
|
||||||
|
>(undefined)
|
||||||
|
|
||||||
|
function isTheme(value: string | null): value is Theme {
|
||||||
|
if (value === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return THEME_VALUES.includes(value as Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme(): ResolvedTheme {
|
||||||
|
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
||||||
|
return "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableTransitionsTemporarily() {
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.appendChild(
|
||||||
|
document.createTextNode(
|
||||||
|
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document.head.appendChild(style)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.getComputedStyle(document.body)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
style.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableParent = target.closest(
|
||||||
|
"input, textarea, select, [contenteditable='true']"
|
||||||
|
)
|
||||||
|
if (editableParent) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = "system",
|
||||||
|
storageKey = "theme",
|
||||||
|
disableTransitionOnChange = true,
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setThemeState] = React.useState<Theme>(() => {
|
||||||
|
const storedTheme = localStorage.getItem(storageKey)
|
||||||
|
if (isTheme(storedTheme)) {
|
||||||
|
return storedTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultTheme
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTheme = React.useCallback(
|
||||||
|
(nextTheme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, nextTheme)
|
||||||
|
setThemeState(nextTheme)
|
||||||
|
},
|
||||||
|
[storageKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyTheme = React.useCallback(
|
||||||
|
(nextTheme: Theme) => {
|
||||||
|
const root = document.documentElement
|
||||||
|
const resolvedTheme =
|
||||||
|
nextTheme === "system" ? getSystemTheme() : nextTheme
|
||||||
|
const restoreTransitions = disableTransitionOnChange
|
||||||
|
? disableTransitionsTemporarily()
|
||||||
|
: null
|
||||||
|
|
||||||
|
root.classList.remove("light", "dark")
|
||||||
|
root.classList.add(resolvedTheme)
|
||||||
|
|
||||||
|
if (restoreTransitions) {
|
||||||
|
restoreTransitions()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disableTransitionOnChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
applyTheme(theme)
|
||||||
|
|
||||||
|
if (theme !== "system") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
|
||||||
|
const handleChange = () => {
|
||||||
|
applyTheme("system")
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange)
|
||||||
|
}
|
||||||
|
}, [theme, applyTheme])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.repeat) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditableTarget(event.target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key.toLowerCase() !== "d") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeState((currentTheme) => {
|
||||||
|
const nextTheme =
|
||||||
|
currentTheme === "dark"
|
||||||
|
? "light"
|
||||||
|
: currentTheme === "light"
|
||||||
|
? "dark"
|
||||||
|
: getSystemTheme() === "dark"
|
||||||
|
? "light"
|
||||||
|
: "dark"
|
||||||
|
|
||||||
|
localStorage.setItem(storageKey, nextTheme)
|
||||||
|
return nextTheme
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
|
if (event.storageArea !== localStorage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== storageKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTheme(event.newValue)) {
|
||||||
|
setThemeState(event.newValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeState(defaultTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorageChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", handleStorageChange)
|
||||||
|
}
|
||||||
|
}, [defaultTheme, storageKey])
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
}),
|
||||||
|
[theme, setTheme]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = React.useContext(ThemeProviderContext)
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
84
apps/admin-dashboard/src/components/ui/accordion.tsx
Normal file
84
apps/admin-dashboard/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col overflow-hidden rounded-md border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("not-last:border-b data-open:bg-muted/50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-6 border border-transparent p-2 text-left text-xs/relaxed font-medium transition-all outline-none hover:underline disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||||
|
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="overflow-hidden px-2 text-xs/relaxed data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-(--radix-accordion-content-height) pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
76
apps/admin-dashboard/src/components/ui/alert.tsx
Normal file
76
apps/admin-dashboard/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-xs/relaxed text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-1.5 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
49
apps/admin-dashboard/src/components/ui/badge.tsx
Normal file
49
apps/admin-dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
65
apps/admin-dashboard/src/components/ui/button.tsx
Normal file
65
apps/admin-dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
|
||||||
|
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
|
||||||
|
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
100
apps/admin-dashboard/src/components/ui/card.tsx
Normal file
100
apps/admin-dashboard/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
33
apps/admin-dashboard/src/components/ui/collapsible.tsx
Normal file
33
apps/admin-dashboard/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
19
apps/admin-dashboard/src/components/ui/input.tsx
Normal file
19
apps/admin-dashboard/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
22
apps/admin-dashboard/src/components/ui/label.tsx
Normal file
22
apps/admin-dashboard/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
193
apps/admin-dashboard/src/components/ui/select.tsx
Normal file
193
apps/admin-dashboard/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
26
apps/admin-dashboard/src/components/ui/separator.tsx
Normal file
26
apps/admin-dashboard/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
142
apps/admin-dashboard/src/components/ui/sheet.tsx
Normal file
142
apps/admin-dashboard/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-4 right-4"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-sm font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
708
apps/admin-dashboard/src/components/ui/sidebar.tsx
Normal file
708
apps/admin-dashboard/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
dir,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
dir={dir}
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full border-input bg-muted/20 dark:bg-muted/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full min-w-0 flex-col px-2 py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-xs",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const [width] = React.useState(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
13
apps/admin-dashboard/src/components/ui/skeleton.tsx
Normal file
13
apps/admin-dashboard/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
49
apps/admin-dashboard/src/components/ui/sonner.tsx
Normal file
49
apps/admin-dashboard/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
33
apps/admin-dashboard/src/components/ui/switch.tsx
Normal file
33
apps/admin-dashboard/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
57
apps/admin-dashboard/src/components/ui/tooltip.tsx
Normal file
57
apps/admin-dashboard/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
19
apps/admin-dashboard/src/hooks/use-mobile.ts
Normal file
19
apps/admin-dashboard/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
129
apps/admin-dashboard/src/index.css
Normal file
129
apps/admin-dashboard/src/index.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "@fontsource-variable/inter";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.511 0.096 186.391);
|
||||||
|
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.855 0.138 181.071);
|
||||||
|
--chart-2: oklch(0.704 0.14 182.503);
|
||||||
|
--chart-3: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-4: oklch(0.511 0.096 186.391);
|
||||||
|
--chart-5: oklch(0.437 0.078 188.216);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.6 0.118 184.704);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.437 0.078 188.216);
|
||||||
|
--primary-foreground: oklch(0.984 0.014 180.72);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.855 0.138 181.071);
|
||||||
|
--chart-2: oklch(0.704 0.14 182.503);
|
||||||
|
--chart-3: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-4: oklch(0.511 0.096 186.391);
|
||||||
|
--chart-5: oklch(0.437 0.078 188.216);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.704 0.14 182.503);
|
||||||
|
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: 'Inter Variable', sans-serif;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground select-none;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
apps/admin-dashboard/src/lib/api.ts
Normal file
164
apps/admin-dashboard/src/lib/api.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { getServerUrl } from "./server-url"
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
return `${getServerUrl()}/api/admin`
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverBase() {
|
||||||
|
return `${getServerUrl()}/api`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigFieldDef {
|
||||||
|
type: "string" | "number" | "select"
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
description?: string
|
||||||
|
secret?: boolean
|
||||||
|
defaultValue?: string | number
|
||||||
|
options?: { label: string; value: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
alwaysEnabled?: boolean
|
||||||
|
fields: Record<string, ConfigFieldDef>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceConfig {
|
||||||
|
sourceId: string
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDefinitions: SourceDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "aelis.location",
|
||||||
|
name: "Location",
|
||||||
|
description: "Device location provider. Always enabled as a dependency for other sources.",
|
||||||
|
alwaysEnabled: true,
|
||||||
|
fields: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "aelis.weather",
|
||||||
|
name: "WeatherKit",
|
||||||
|
description: "Apple WeatherKit weather data. Requires Apple Developer credentials.",
|
||||||
|
fields: {
|
||||||
|
privateKey: { type: "string", label: "Private Key", required: true, secret: true, description: "Apple WeatherKit private key (PEM format)" },
|
||||||
|
keyId: { type: "string", label: "Key ID", required: true, secret: true },
|
||||||
|
teamId: { type: "string", label: "Team ID", required: true, secret: true },
|
||||||
|
serviceId: { type: "string", label: "Service ID", required: true, secret: true },
|
||||||
|
units: { type: "select", label: "Units", options: [{ label: "Metric", value: "metric" }, { label: "Imperial", value: "imperial" }], defaultValue: "metric" },
|
||||||
|
hourlyLimit: { type: "number", label: "Hourly Forecast Limit", defaultValue: 12, description: "Number of hourly forecasts to include" },
|
||||||
|
dailyLimit: { type: "number", label: "Daily Forecast Limit", defaultValue: 7, description: "Number of daily forecasts to include" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function fetchSources(): Promise<SourceDefinition[]> {
|
||||||
|
return Promise.resolve(sourceDefinitions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSourceConfig(
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<SourceConfig | null> {
|
||||||
|
const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status === 404) return null
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch source config: ${res.status}`)
|
||||||
|
const data = (await res.json()) as { enabled: boolean; config: Record<string, unknown> }
|
||||||
|
return { sourceId, enabled: data.enabled, config: data.config }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigs(): Promise<SourceConfig[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
sourceDefinitions.map((s) => fetchSourceConfig(s.id)),
|
||||||
|
)
|
||||||
|
return results.filter((c): c is SourceConfig => c !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceSource(
|
||||||
|
sourceId: string,
|
||||||
|
body: { enabled: boolean; config: unknown },
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json()) as { error?: string }
|
||||||
|
throw new Error(data.error ?? `Failed to replace source config: ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProviderConfig(
|
||||||
|
sourceId: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${apiBase()}/${sourceId}/config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json()) as { error?: string }
|
||||||
|
throw new Error(data.error ?? `Failed to update provider config: ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationInput {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushLocation(location: LocationInput): Promise<void> {
|
||||||
|
const res = await fetch(`${serverBase()}/location`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...location,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json()) as { error?: string }
|
||||||
|
throw new Error(data.error ?? `Failed to push location: ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedItemSlot {
|
||||||
|
description: string
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedItem {
|
||||||
|
id: string
|
||||||
|
sourceId: string
|
||||||
|
type: string
|
||||||
|
timestamp: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
signals?: {
|
||||||
|
urgency?: number
|
||||||
|
timeRelevance?: string
|
||||||
|
}
|
||||||
|
slots?: Record<string, FeedItemSlot>
|
||||||
|
ui?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedResponse {
|
||||||
|
items: FeedItem[]
|
||||||
|
errors: { sourceId: string; error: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeed(): Promise<FeedResponse> {
|
||||||
|
const res = await fetch(`${serverBase()}/feed`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch feed: ${res.status}`)
|
||||||
|
return res.json() as Promise<FeedResponse>
|
||||||
|
}
|
||||||
47
apps/admin-dashboard/src/lib/auth.ts
Normal file
47
apps/admin-dashboard/src/lib/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { getServerUrl } from "./server-url"
|
||||||
|
|
||||||
|
function authBase() {
|
||||||
|
return `${getServerUrl()}/api/auth`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
image: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
user: AuthUser
|
||||||
|
session: { id: string; token: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<AuthSession | null> {
|
||||||
|
const res = await fetch(`${authBase()}/get-session`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = (await res.json()) as AuthSession | null
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
||||||
|
const res = await fetch(`${authBase()}/sign-in/email`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json()) as { message?: string }
|
||||||
|
throw new Error(data.message ?? `Sign in failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as AuthSession
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut(): Promise<void> {
|
||||||
|
await fetch(`${authBase()}/sign-out`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
}
|
||||||
10
apps/admin-dashboard/src/lib/server-url.ts
Normal file
10
apps/admin-dashboard/src/lib/server-url.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const STORAGE_KEY = "aelis-server-url"
|
||||||
|
const DEFAULT_URL = "https://3000--019cf276-6ed6-7529-a425-210182693908.eu-runner.flex.doptig.cloud"
|
||||||
|
|
||||||
|
export function getServerUrl(): string {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setServerUrl(url: string): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, url.replace(/\/+$/, ""))
|
||||||
|
}
|
||||||
6
apps/admin-dashboard/src/lib/utils.ts
Normal file
6
apps/admin-dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
28
apps/admin-dashboard/src/main.tsx
Normal file
28
apps/admin-dashboard/src/main.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { StrictMode } from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
|
||||||
|
import "./index.css"
|
||||||
|
import App from "./App.tsx"
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
||||||
|
import { Toaster } from "@/components/ui/sonner.tsx"
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
15
apps/admin-dashboard/src/route-tree.gen.ts
Normal file
15
apps/admin-dashboard/src/route-tree.gen.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Route as rootRoute } from "./routes/__root"
|
||||||
|
import { Route as loginRoute } from "./routes/login"
|
||||||
|
import { Route as dashboardRoute } from "./routes/_dashboard"
|
||||||
|
import { Route as dashboardIndexRoute } from "./routes/_dashboard/index"
|
||||||
|
import { Route as dashboardFeedRoute } from "./routes/_dashboard/feed"
|
||||||
|
import { Route as dashboardSourceRoute } from "./routes/_dashboard/sources.$sourceId"
|
||||||
|
|
||||||
|
export const routeTree = rootRoute.addChildren([
|
||||||
|
loginRoute,
|
||||||
|
dashboardRoute.addChildren([
|
||||||
|
dashboardIndexRoute,
|
||||||
|
dashboardFeedRoute,
|
||||||
|
dashboardSourceRoute,
|
||||||
|
]),
|
||||||
|
])
|
||||||
13
apps/admin-dashboard/src/routes/__root.tsx
Normal file
13
apps/admin-dashboard/src/routes/__root.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"
|
||||||
|
import type { QueryClient } from "@tanstack/react-query"
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
|
component: function RootLayout() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Outlet />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
201
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal file
201
apps/admin-dashboard/src/routes/_dashboard.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { createRoute, Outlet, redirect, useMatchRoute, useNavigate, Link } from "@tanstack/react-router"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CalendarDays,
|
||||||
|
CircleDot,
|
||||||
|
CloudSun,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
MapPin,
|
||||||
|
Rss,
|
||||||
|
Server,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { fetchConfigs, fetchSources } from "@/lib/api"
|
||||||
|
import { getSession, signOut } from "@/lib/auth"
|
||||||
|
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import { Route as rootRoute } from "./__root"
|
||||||
|
|
||||||
|
const SOURCE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
"aelis.location": MapPin,
|
||||||
|
"aelis.weather": CloudSun,
|
||||||
|
"aelis.caldav": CalendarDays,
|
||||||
|
"aelis.google-calendar": Calendar,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
id: "dashboard",
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const session = await context.queryClient.ensureQueryData({
|
||||||
|
queryKey: ["session"],
|
||||||
|
queryFn: getSession,
|
||||||
|
})
|
||||||
|
if (!session?.user) {
|
||||||
|
throw redirect({ to: "/login" })
|
||||||
|
}
|
||||||
|
return { user: session.user }
|
||||||
|
},
|
||||||
|
component: DashboardLayout,
|
||||||
|
pendingComponent: () => (
|
||||||
|
<div className="flex min-h-svh items-center justify-center">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardLayout() {
|
||||||
|
const { user } = Route.useRouteContext()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const matchRoute = useMatchRoute()
|
||||||
|
|
||||||
|
const { data: sources = [] } = useQuery({
|
||||||
|
queryKey: ["sources"],
|
||||||
|
queryFn: fetchSources,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: configs = [],
|
||||||
|
error: configsError,
|
||||||
|
refetch: refetchConfigs,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["configs"],
|
||||||
|
queryFn: fetchConfigs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: signOut,
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.setQueryData(["session"], null)
|
||||||
|
queryClient.clear()
|
||||||
|
navigate({ to: "/login" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = configsError?.message ?? null
|
||||||
|
const configMap = new Map(configs.map((c) => [c.sourceId, c]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => logoutMutation.mutate()}>
|
||||||
|
<LogOut className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>General</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={!!matchRoute({ to: "/" })}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to="/">
|
||||||
|
<Server className="size-4" />
|
||||||
|
<span>Server</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={!!matchRoute({ to: "/feed" })}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to="/feed">
|
||||||
|
<Rss className="size-4" />
|
||||||
|
<span>Feed</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Sources</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{sources.map((source) => {
|
||||||
|
const Icon = SOURCE_ICONS[source.id] ?? CircleDot
|
||||||
|
const cfg = configMap.get(source.id)
|
||||||
|
const isEnabled = source.alwaysEnabled || cfg?.enabled
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={source.id}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={!!matchRoute({ to: "/sources/$sourceId", params: { sourceId: source.id } })}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to="/sources/$sourceId" params={{ sourceId: source.id }}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{source.name}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
{isEnabled && (
|
||||||
|
<SidebarMenuBadge>
|
||||||
|
<CircleDot className="size-2.5 text-primary" />
|
||||||
|
</SidebarMenuBadge>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-12 items-center gap-2 border-b px-4">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 !h-4" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-6">
|
||||||
|
<TriangleAlert className="size-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => refetchConfigs()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
apps/admin-dashboard/src/routes/_dashboard/feed.tsx
Normal file
10
apps/admin-dashboard/src/routes/_dashboard/feed.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { FeedPanel } from "@/components/feed-panel"
|
||||||
|
import { Route as dashboardRoute } from "../_dashboard"
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => dashboardRoute,
|
||||||
|
path: "/feed",
|
||||||
|
component: FeedPanel,
|
||||||
|
})
|
||||||
10
apps/admin-dashboard/src/routes/_dashboard/index.tsx
Normal file
10
apps/admin-dashboard/src/routes/_dashboard/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { GeneralSettingsPanel } from "@/components/general-settings-panel"
|
||||||
|
import { Route as dashboardRoute } from "../_dashboard"
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => dashboardRoute,
|
||||||
|
path: "/",
|
||||||
|
component: GeneralSettingsPanel,
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createRoute } from "@tanstack/react-router"
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import { fetchSources } from "@/lib/api"
|
||||||
|
import { SourceConfigPanel } from "@/components/source-config-panel"
|
||||||
|
import { Route as dashboardRoute } from "../_dashboard"
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => dashboardRoute,
|
||||||
|
path: "/sources/$sourceId",
|
||||||
|
component: SourceRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SourceRoute() {
|
||||||
|
const { sourceId } = Route.useParams()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: sources = [] } = useQuery({
|
||||||
|
queryKey: ["sources"],
|
||||||
|
queryFn: fetchSources,
|
||||||
|
})
|
||||||
|
const source = sources.find((s) => s.id === sourceId)
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Source not found.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SourceConfigPanel
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
onUpdate={() => queryClient.invalidateQueries({ queryKey: ["configs"] })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
apps/admin-dashboard/src/routes/login.tsx
Normal file
22
apps/admin-dashboard/src/routes/login.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createRoute, useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import type { AuthSession } from "@/lib/auth"
|
||||||
|
import { LoginPage } from "@/components/login-page"
|
||||||
|
import { Route as rootRoute } from "./__root"
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/login",
|
||||||
|
component: function LoginRoute() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
function handleLogin(session: AuthSession) {
|
||||||
|
queryClient.setQueryData(["session"], session)
|
||||||
|
navigate({ to: "/" })
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginPage onLogin={handleLogin} />
|
||||||
|
},
|
||||||
|
})
|
||||||
32
apps/admin-dashboard/tsconfig.app.json
Normal file
32
apps/admin-dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
apps/admin-dashboard/tsconfig.json
Normal file
13
apps/admin-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/admin-dashboard/tsconfig.node.json
Normal file
26
apps/admin-dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
apps/admin-dashboard/vite.config.ts
Normal file
18
apps/admin-dashboard/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import path from "path"
|
||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,6 +4,9 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
|
|||||||
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
|
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
|
||||||
|
CREDENTIALS_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Base URL of the backend
|
# Base URL of the backend
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
|||||||
20
apps/aelis-backend/auth.ts
Normal file
20
apps/aelis-backend/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Used by Better Auth CLI for schema generation.
|
||||||
|
// Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
|
||||||
|
import { betterAuth } from "better-auth"
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||||
|
import { admin } from "better-auth/plugins"
|
||||||
|
import { SQL } from "bun"
|
||||||
|
import { drizzle } from "drizzle-orm/bun-sql"
|
||||||
|
|
||||||
|
const client = new SQL({ url: process.env.DATABASE_URL })
|
||||||
|
const db = drizzle({ client })
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, { provider: "pg" }),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
plugins: [admin()],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default auth
|
||||||
10
apps/aelis-backend/drizzle.config.ts
Normal file
10
apps/aelis-backend/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: "./drizzle",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
})
|
||||||
66
apps/aelis-backend/drizzle/0000_wakeful_scorpion.sql
Normal file
66
apps/aelis-backend/drizzle/0000_wakeful_scorpion.sql
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_sources" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"source_id" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb,
|
||||||
|
"credentials" "bytea",
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||||
1
apps/aelis-backend/drizzle/0001_misty_white_tiger.sql
Normal file
1
apps/aelis-backend/drizzle/0001_misty_white_tiger.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
|
||||||
457
apps/aelis-backend/drizzle/meta/0000_snapshot.json
Normal file
457
apps/aelis-backend/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
{
|
||||||
|
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_sources": {
|
||||||
|
"name": "user_sources",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"name": "source_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"name": "credentials",
|
||||||
|
"type": "bytea",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_sources_user_id_user_id_fk": {
|
||||||
|
"name": "user_sources_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_sources",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_sources_user_id_source_id_unique": {
|
||||||
|
"name": "user_sources_user_id_source_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"source_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
479
apps/aelis-backend/drizzle/meta/0001_snapshot.json
Normal file
479
apps/aelis-backend/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
{
|
||||||
|
"id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35",
|
||||||
|
"prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_sources": {
|
||||||
|
"name": "user_sources",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"name": "source_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"name": "credentials",
|
||||||
|
"type": "bytea",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_sources_user_id_enabled_idx": {
|
||||||
|
"name": "user_sources_user_id_enabled_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "enabled",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_sources_user_id_user_id_fk": {
|
||||||
|
"name": "user_sources_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_sources",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_sources_user_id_source_id_unique": {
|
||||||
|
"name": "user_sources_user_id_source_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"source_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/aelis-backend/drizzle/meta/_journal.json
Normal file
20
apps/aelis-backend/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773620066366,
|
||||||
|
"tag": "0000_wakeful_scorpion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773624297794,
|
||||||
|
"tag": "0001_misty_white_tiger",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,7 +6,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/server.ts",
|
"dev": "bun run --watch src/server.ts",
|
||||||
"start": "bun run src/server.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/"
|
"test": "bun test src/",
|
||||||
|
"db:generate": "bunx drizzle-kit generate",
|
||||||
|
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
|
||||||
|
"db:push": "bunx drizzle-kit push",
|
||||||
|
"db:migrate": "bunx drizzle-kit migrate",
|
||||||
|
"db:studio": "bunx drizzle-kit studio",
|
||||||
|
"create-admin": "bun run src/scripts/create-admin.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
@@ -18,10 +24,12 @@
|
|||||||
"@openrouter/sdk": "^0.9.11",
|
"@openrouter/sdk": "^0.9.11",
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
"pg": "^8"
|
"lodash.merge": "^4.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pg": "^8"
|
"@types/lodash.merge": "^4.6.9",
|
||||||
|
"drizzle-kit": "^0.31.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
195
apps/aelis-backend/src/admin/http.test.ts
Normal file
195
apps/aelis-backend/src/admin/http.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import type { AdminMiddleware } from "../auth/admin-middleware.ts"
|
||||||
|
import type { AuthSession, AuthUser } from "../auth/session.ts"
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
import { UserSessionManager } from "../session/user-session-manager.ts"
|
||||||
|
import { registerAdminHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
|
let mockEnabledSourceIds: string[] = []
|
||||||
|
|
||||||
|
mock.module("../sources/user-sources.ts", () => ({
|
||||||
|
sources: (_db: Database, _userId: string) => ({
|
||||||
|
async enabled() {
|
||||||
|
const now = new Date()
|
||||||
|
return mockEnabledSourceIds.map((sourceId) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: _userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async find(sourceId: string) {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: _userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createStubSource(id: string): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<FeedItem[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubProvider(sourceId: string): FeedSourceProvider {
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return createStubSource(sourceId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passthrough admin middleware for testing (assumes admin). */
|
||||||
|
function passthroughAdminMiddleware(): AdminMiddleware {
|
||||||
|
const now = new Date()
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set("user", {
|
||||||
|
id: "admin-1",
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@test.com",
|
||||||
|
emailVerified: true,
|
||||||
|
image: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
role: "admin",
|
||||||
|
banned: false,
|
||||||
|
banReason: null,
|
||||||
|
banExpires: null,
|
||||||
|
} as AuthUser)
|
||||||
|
c.set("session", { id: "sess-1" } as AuthSession)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeDb = {} as Database
|
||||||
|
|
||||||
|
function createApp(providers: FeedSourceProvider[]) {
|
||||||
|
mockEnabledSourceIds = providers.map((p) => p.sourceId)
|
||||||
|
const sessionManager = new UserSessionManager({ db: fakeDb, providers })
|
||||||
|
const app = new Hono()
|
||||||
|
registerAdminHttpHandlers(app, {
|
||||||
|
sessionManager,
|
||||||
|
adminMiddleware: passthroughAdminMiddleware(),
|
||||||
|
db: fakeDb,
|
||||||
|
})
|
||||||
|
return { app, sessionManager }
|
||||||
|
}
|
||||||
|
|
||||||
|
const validWeatherConfig = {
|
||||||
|
credentials: {
|
||||||
|
privateKey: "pk-123",
|
||||||
|
keyId: "key-456",
|
||||||
|
teamId: "team-789",
|
||||||
|
serviceId: "svc-abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PUT /api/admin/:sourceId/config", () => {
|
||||||
|
test("returns 404 for unknown provider", async () => {
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")])
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/aelis.nonexistent/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ key: "value" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 for provider without runtime config support", async () => {
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")])
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/aelis.location/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ key: "value" }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON body", async () => {
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather")])
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/aelis.weather/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("Invalid JSON")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when weather config fails validation", async () => {
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather")])
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/aelis.weather/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ credentials: { privateKey: 123 } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 and applies valid weather config", async () => {
|
||||||
|
const { app, sessionManager } = createApp([createStubProvider("aelis.weather")])
|
||||||
|
|
||||||
|
const originalProvider = sessionManager.getProvider("aelis.weather")
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/aelis.weather/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(validWeatherConfig),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
|
||||||
|
// Provider was replaced with a new instance
|
||||||
|
const provider = sessionManager.getProvider("aelis.weather")
|
||||||
|
expect(provider).toBeDefined()
|
||||||
|
expect(provider!.sourceId).toBe("aelis.weather")
|
||||||
|
expect(provider).not.toBe(originalProvider)
|
||||||
|
})
|
||||||
|
})
|
||||||
86
apps/aelis-backend/src/admin/http.ts
Normal file
86
apps/aelis-backend/src/admin/http.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { type } from "arktype"
|
||||||
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AdminMiddleware } from "../auth/admin-middleware.ts"
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
db: Database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
adminMiddleware: AdminMiddleware
|
||||||
|
db: Database
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAdminHttpHandlers(
|
||||||
|
app: Hono,
|
||||||
|
{ sessionManager, adminMiddleware, db }: AdminHttpHandlersDeps,
|
||||||
|
) {
|
||||||
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
|
c.set("sessionManager", sessionManager)
|
||||||
|
c.set("db", db)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/api/admin/:sourceId/config", inject, adminMiddleware, handleUpdateProviderConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeatherKitSourceProviderConfig = type({
|
||||||
|
credentials: {
|
||||||
|
privateKey: "string",
|
||||||
|
keyId: "string",
|
||||||
|
teamId: "string",
|
||||||
|
serviceId: "string",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleUpdateProviderConfig(c: Context<Env>) {
|
||||||
|
const sourceId = c.req.param("sourceId")
|
||||||
|
if (!sourceId) {
|
||||||
|
return c.body(null, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await c.req.json()
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sourceId) {
|
||||||
|
case "aelis.weather": {
|
||||||
|
const parsed = WeatherKitSourceProviderConfig(body)
|
||||||
|
if (parsed instanceof type.errors) {
|
||||||
|
return c.json({ error: parsed.summary }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = new WeatherSourceProvider({
|
||||||
|
credentials: parsed.credentials,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionManager.replaceProvider(updated)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[admin] replaceProvider("${sourceId}") failed:`, err)
|
||||||
|
return c.json({ error: "Failed to apply config" }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return c.json({ error: `Provider "${sourceId}" not found` }, 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/aelis-backend/src/auth/admin-middleware.test.ts
Normal file
95
apps/aelis-backend/src/auth/admin-middleware.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { Auth } from "./index.ts"
|
||||||
|
import type { AuthSession, AuthUser } from "./session.ts"
|
||||||
|
|
||||||
|
import { createRequireAdmin } from "./admin-middleware.ts"
|
||||||
|
|
||||||
|
function makeUser(role: string | null): AuthUser {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: true,
|
||||||
|
image: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
role,
|
||||||
|
banned: false,
|
||||||
|
banReason: null,
|
||||||
|
banExpires: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSession(): AuthSession {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
id: "sess-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "tok-1",
|
||||||
|
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "test",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAuth(sessionResult: { user: AuthUser; session: AuthSession } | null): Auth {
|
||||||
|
return {
|
||||||
|
api: {
|
||||||
|
getSession: async () => sessionResult,
|
||||||
|
},
|
||||||
|
} as unknown as Auth
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp(auth: Auth) {
|
||||||
|
const app = new Hono()
|
||||||
|
const middleware = createRequireAdmin(auth)
|
||||||
|
app.get("/api/admin/test", middleware, (c) => c.json({ ok: true }))
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRequireAdmin", () => {
|
||||||
|
test("returns 401 when no session", async () => {
|
||||||
|
const app = createApp(mockAuth(null))
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/test")
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBe("Unauthorized")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 403 when user is not admin", async () => {
|
||||||
|
const app = createApp(mockAuth({ user: makeUser("user"), session: makeSession() }))
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/test")
|
||||||
|
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBe("Forbidden")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 403 when role is null", async () => {
|
||||||
|
const app = createApp(mockAuth({ user: makeUser(null), session: makeSession() }))
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/test")
|
||||||
|
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows admin users through and sets context", async () => {
|
||||||
|
const user = makeUser("admin")
|
||||||
|
const session = makeSession()
|
||||||
|
const app = createApp(mockAuth({ user, session }))
|
||||||
|
|
||||||
|
const res = await app.request("/api/admin/test")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { ok: boolean }
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
28
apps/aelis-backend/src/auth/admin-middleware.ts
Normal file
28
apps/aelis-backend/src/auth/admin-middleware.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||||
|
|
||||||
|
import type { Auth } from "./index.ts"
|
||||||
|
import type { AuthSessionEnv } from "./session-middleware.ts"
|
||||||
|
|
||||||
|
export type AdminMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a middleware that requires a valid session with admin role.
|
||||||
|
* Returns 401 if not authenticated, 403 if not admin.
|
||||||
|
*/
|
||||||
|
export function createRequireAdmin(auth: Auth): AdminMiddleware {
|
||||||
|
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
return c.json({ error: "Forbidden" }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Hono } from "hono"
|
import type { Hono } from "hono"
|
||||||
|
|
||||||
import { auth } from "./index.ts"
|
import type { Auth } from "./index.ts"
|
||||||
|
|
||||||
export function registerAuthHandlers(app: Hono): void {
|
export function registerAuthHandlers(app: Hono, auth: Auth): void {
|
||||||
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { betterAuth } from "better-auth"
|
import { betterAuth } from "better-auth"
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||||
|
import { admin } from "better-auth/plugins"
|
||||||
|
|
||||||
import { pool } from "../db.ts"
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
export const auth = betterAuth({
|
import * as schema from "../db/schema.ts"
|
||||||
database: pool,
|
|
||||||
emailAndPassword: {
|
export function createAuth(db: Database) {
|
||||||
enabled: true,
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
},
|
throw new Error("BETTER_AUTH_SECRET is not set")
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return betterAuth({
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: "pg",
|
||||||
|
schema,
|
||||||
|
}),
|
||||||
|
advanced: {
|
||||||
|
disableCSRFCheck: process.env.NODE_ENV !== "production",
|
||||||
|
},
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
plugins: [admin()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Auth = ReturnType<typeof createAuth>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||||
|
|
||||||
|
import type { Auth } from "./index.ts"
|
||||||
import type { AuthSession, AuthUser } from "./session.ts"
|
import type { AuthSession, AuthUser } from "./session.ts"
|
||||||
|
|
||||||
import { auth } from "./index.ts"
|
|
||||||
|
|
||||||
export interface SessionVariables {
|
export interface SessionVariables {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
@@ -18,46 +17,50 @@ declare module "hono" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware that attaches session and user to the context.
|
* Creates a middleware that attaches session and user to the context.
|
||||||
* Does not reject unauthenticated requests - use requireSession for that.
|
* Does not reject unauthenticated requests - use createRequireSession for that.
|
||||||
*/
|
*/
|
||||||
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
|
export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
return async (c: Context, next: Next): Promise<void> => {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
} else {
|
||||||
|
c.set("user", null)
|
||||||
|
c.set("session", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a middleware that requires a valid session. Returns 401 if not authenticated.
|
||||||
|
*/
|
||||||
|
export function createRequireSession(auth: Auth): AuthSessionMiddleware {
|
||||||
|
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
if (session) {
|
|
||||||
c.set("user", session.user)
|
c.set("user", session.user)
|
||||||
c.set("session", session.session)
|
c.set("session", session.session)
|
||||||
} else {
|
await next()
|
||||||
c.set("user", null)
|
|
||||||
c.set("session", null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware that requires a valid session. Returns 401 if not authenticated.
|
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
|
||||||
*/
|
*/
|
||||||
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
|
export function createGetSessionFromHeaders(auth: Auth) {
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
||||||
|
const session = await auth.api.getSession({ headers })
|
||||||
if (!session) {
|
return session
|
||||||
return c.json({ error: "Unauthorized" }, 401)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.set("user", session.user)
|
|
||||||
c.set("session", session.session)
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get session from headers. Useful for WebSocket upgrade validation.
|
|
||||||
*/
|
|
||||||
export async function getSessionFromHeaders(
|
|
||||||
headers: Headers,
|
|
||||||
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
|
||||||
const session = await auth.api.getSession({ headers })
|
|
||||||
return session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +84,10 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
|
|||||||
image: null,
|
image: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
role: "admin",
|
||||||
|
banned: false,
|
||||||
|
banReason: null,
|
||||||
|
banExpires: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: AuthSession = {
|
const session: AuthSession = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { auth } from "./index.ts"
|
import type { Auth } from "./index.ts"
|
||||||
|
|
||||||
export type AuthUser = typeof auth.$Infer.Session.user
|
export type AuthUser = Auth["$Infer"]["Session"]["user"]
|
||||||
export type AuthSession = typeof auth.$Infer.Session.session
|
export type AuthSession = Auth["$Infer"]["Session"]["session"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Pool } from "pg"
|
|
||||||
|
|
||||||
export const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
})
|
|
||||||
96
apps/aelis-backend/src/db/auth-schema.ts
Normal file
96
apps/aelis-backend/src/db/auth-schema.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { relations } from "drizzle-orm"
|
||||||
|
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: timestamp("created_at").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
role: text("role"),
|
||||||
|
banned: boolean("banned").default(false),
|
||||||
|
banReason: text("ban_reason"),
|
||||||
|
banExpires: timestamp("ban_expires"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const session = pgTable(
|
||||||
|
"session",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
impersonatedBy: text("impersonated_by"),
|
||||||
|
},
|
||||||
|
(table) => [index("session_userId_idx").on(table.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const account = pgTable(
|
||||||
|
"account",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("created_at").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const verification = pgTable(
|
||||||
|
"verification",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
|
sessions: many(session),
|
||||||
|
accounts: many(account),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [session.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [account.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
23
apps/aelis-backend/src/db/index.ts
Normal file
23
apps/aelis-backend/src/db/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { SQL } from "bun"
|
||||||
|
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
|
||||||
|
|
||||||
|
import * as schema from "./schema.ts"
|
||||||
|
|
||||||
|
export type Database = BunSQLDatabase<typeof schema>
|
||||||
|
|
||||||
|
export interface DatabaseConnection {
|
||||||
|
db: Database
|
||||||
|
close: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDatabase(url: string): DatabaseConnection {
|
||||||
|
if (!url) {
|
||||||
|
throw new Error("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new SQL({ url })
|
||||||
|
return {
|
||||||
|
db: drizzle({ client, schema }),
|
||||||
|
close: () => client.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/aelis-backend/src/db/schema.ts
Normal file
62
apps/aelis-backend/src/db/schema.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
customType,
|
||||||
|
index,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
unique,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Better Auth core tables
|
||||||
|
// Re-exported from CLI-generated schema.
|
||||||
|
// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
user,
|
||||||
|
session,
|
||||||
|
account,
|
||||||
|
verification,
|
||||||
|
userRelations,
|
||||||
|
sessionRelations,
|
||||||
|
accountRelations,
|
||||||
|
} from "./auth-schema.ts"
|
||||||
|
|
||||||
|
import { user } from "./auth-schema.ts"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AELIS — per-user source configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const bytea = customType<{ data: Buffer }>({
|
||||||
|
dataType() {
|
||||||
|
return "bytea"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const userSources = pgTable(
|
||||||
|
"user_sources",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
sourceId: text("source_id").notNull(),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
config: jsonb("config").default({}),
|
||||||
|
credentials: bytea("credentials"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId),
|
||||||
|
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
|
||||||
|
],
|
||||||
|
)
|
||||||
418
apps/aelis-backend/src/engine/http.test.ts
Normal file
418
apps/aelis-backend/src/engine/http.test.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
|
import { describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
|
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import { UserSessionManager } from "../session/index.ts"
|
||||||
|
import { registerFeedHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
|
interface FeedResponse {
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
priority: number
|
||||||
|
timestamp: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
errors: Array<{ sourceId: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubSource(
|
||||||
|
id: string,
|
||||||
|
items: FeedItem[] = [],
|
||||||
|
contextEntries: readonly ContextEntry[] | null = null,
|
||||||
|
): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return contextEntries
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||||
|
const app = new Hono()
|
||||||
|
registerFeedHttpHandlers(app, {
|
||||||
|
sessionManager,
|
||||||
|
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||||
|
})
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockEnabledSourceIds: string[] = []
|
||||||
|
|
||||||
|
mock.module("../sources/user-sources.ts", () => ({
|
||||||
|
sources: (_db: Database, _userId: string) => ({
|
||||||
|
async enabled() {
|
||||||
|
const now = new Date()
|
||||||
|
return mockEnabledSourceIds.map((sourceId) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: _userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async find(sourceId: string) {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: _userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fakeDb = {} as Database
|
||||||
|
|
||||||
|
describe("GET /api/feed", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
mockEnabledSourceIds = []
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||||
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns cached feed when available", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.8,
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockEnabledSourceIds = ["test"]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "test",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return createStubSource("test", items)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// Prime the cache
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
expect(session.engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("item-1")
|
||||||
|
expect(body.items[0]!.type).toBe("test")
|
||||||
|
expect(body.items[0]!.priority).toBe(0.8)
|
||||||
|
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forces refresh when no cached feed", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "fresh-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||||
|
data: { fresh: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockEnabledSourceIds = ["test"]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "test",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return createStubSource("test", items)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("fresh-1")
|
||||||
|
expect(body.items[0]!.data.fresh).toBe(true)
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes source errors as message strings", async () => {
|
||||||
|
const failingSource: FeedSource = {
|
||||||
|
id: "failing",
|
||||||
|
async listActions() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
throw new Error("connection timeout")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockEnabledSourceIds = ["failing"]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "failing",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return failingSource
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(0)
|
||||||
|
expect(body.errors).toHaveLength(1)
|
||||||
|
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||||
|
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 503 when all providers fail", async () => {
|
||||||
|
mockEnabledSourceIds = ["test"]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "test",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
throw new Error("provider down")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
const spy = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(503)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBe("Service unavailable")
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GET /api/context", () => {
|
||||||
|
const weatherKey = contextKey("aelis.weather", "weather")
|
||||||
|
const weatherData = { temperature: 20, condition: "Clear" }
|
||||||
|
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
|
||||||
|
|
||||||
|
// The mock auth middleware always injects this hardcoded user ID
|
||||||
|
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
||||||
|
|
||||||
|
async function buildContextApp(userId?: string) {
|
||||||
|
mockEnabledSourceIds = ["weather"]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "weather",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return createStubSource("weather", [], contextEntries)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, userId)
|
||||||
|
const session = await manager.getOrCreate(mockUserId)
|
||||||
|
return { app, session }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
mockEnabledSourceIds = []
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||||
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key param is missing", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is invalid JSON", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=notjson")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is not an array", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key="string"')
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key contains invalid element types", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when key is an empty array", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/context?key=[]")
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("key")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when match param is invalid", async () => {
|
||||||
|
const { app } = await buildContextApp("user-1")
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("match")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns exact match with match=exact", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { match: string; value: unknown }
|
||||||
|
expect(body.match).toBe("exact")
|
||||||
|
expect(body.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 with match=exact when only prefix would match", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns prefix match with match=prefix", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
match: string
|
||||||
|
entries: Array<{ key: unknown[]; value: unknown }>
|
||||||
|
}
|
||||||
|
expect(body.match).toBe("prefix")
|
||||||
|
expect(body.entries).toHaveLength(1)
|
||||||
|
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
|
||||||
|
expect(body.entries[0]!.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default mode returns exact match when available", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { match: string; value: unknown }
|
||||||
|
expect(body.match).toBe("exact")
|
||||||
|
expect(body.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("default mode falls back to prefix when no exact match", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["aelis.weather"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
match: string
|
||||||
|
entries: Array<{ key: unknown[]; value: unknown }>
|
||||||
|
}
|
||||||
|
expect(body.match).toBe("prefix")
|
||||||
|
expect(body.entries).toHaveLength(1)
|
||||||
|
expect(body.entries[0]!.value).toEqual(weatherData)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 when neither exact nor prefix matches", async () => {
|
||||||
|
const { app, session } = await buildContextApp("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
|
||||||
|
const res = await app.request('/api/context?key=["nonexistent"]')
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toBe("Context key not found")
|
||||||
|
})
|
||||||
|
})
|
||||||
133
apps/aelis-backend/src/engine/http.ts
Normal file
133
apps/aelis-backend/src/engine/http.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { contextKey } from "@aelis/core"
|
||||||
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFeedHttpHandlers(
|
||||||
|
app: Hono,
|
||||||
|
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||||
|
) {
|
||||||
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
|
c.set("sessionManager", sessionManager)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||||
|
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetFeed(c: Context<Env>) {
|
||||||
|
const user = c.get("user")!
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
|
||||||
|
let session
|
||||||
|
try {
|
||||||
|
session = await sessionManager.getOrCreate(user.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[handleGetFeed] Failed to create session:", err)
|
||||||
|
return c.json({ error: "Service unavailable" }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await session.feed()
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items: feed.items,
|
||||||
|
errors: feed.errors.map((e) => ({
|
||||||
|
sourceId: e.sourceId,
|
||||||
|
error: e.error.message,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetContext(c: Context<Env>) {
|
||||||
|
const keyParam = c.req.query("key")
|
||||||
|
if (!keyParam) {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(keyParam)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
|
||||||
|
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchParam = c.req.query("match")
|
||||||
|
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
|
||||||
|
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = c.get("user")!
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
|
||||||
|
let session
|
||||||
|
try {
|
||||||
|
session = await sessionManager.getOrCreate(user.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[handleGetContext] Failed to create session:", err)
|
||||||
|
return c.json({ error: "Service unavailable" }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = session.engine.currentContext()
|
||||||
|
const key = contextKey(...parsed)
|
||||||
|
|
||||||
|
if (matchParam === "exact") {
|
||||||
|
const value = context.get(key)
|
||||||
|
if (value === undefined) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
return c.json({ match: "exact", value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchParam === "prefix") {
|
||||||
|
const entries = context.find(key)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
return c.json({ match: "prefix", entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: single find() covers both exact and prefix matches
|
||||||
|
const entries = context.find(key)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return c.json({ error: "Context key not found" }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exactly one result with the same key length, treat as exact match
|
||||||
|
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
|
||||||
|
return c.json({ match: "exact", value: entries[0]!.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ match: "prefix", entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
|
||||||
|
function isContextKeyPart(value: unknown): boolean {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
return Object.values(value).every(
|
||||||
|
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -50,11 +50,13 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
|
|||||||
schema: enhancementResultJsonSchema,
|
schema: enhancementResultJsonSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reasoning: { effort: "none" },
|
||||||
stream: false,
|
stream: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const content = response.choices?.[0]?.message?.content
|
const message = response.choices?.[0]?.message
|
||||||
|
const content = message?.content ?? message?.reasoning
|
||||||
if (typeof content !== "string") {
|
if (typeof content !== "string") {
|
||||||
console.warn("[enhancement] LLM returned no content in response")
|
console.warn("[enhancement] LLM returned no content in response")
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { mergeEnhancement } from "./merge.ts"
|
|||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||||
return {
|
return {
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { FeedItem } from "@aelis/core"
|
|||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
import type { EnhancementResult } from "./schema.ts"
|
||||||
|
|
||||||
|
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges an EnhancementResult into feed items.
|
* Merges an EnhancementResult into feed items.
|
||||||
*
|
*
|
||||||
@@ -10,7 +12,11 @@ import type { EnhancementResult } from "./schema.ts"
|
|||||||
* - Returns a new array (no mutation)
|
* - Returns a new array (no mutation)
|
||||||
* - Ignores fills for items/slots that don't exist
|
* - Ignores fills for items/slots that don't exist
|
||||||
*/
|
*/
|
||||||
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
|
export function mergeEnhancement(
|
||||||
|
items: FeedItem[],
|
||||||
|
result: EnhancementResult,
|
||||||
|
currentTime: Date,
|
||||||
|
): FeedItem[] {
|
||||||
const merged = items.map((item) => {
|
const merged = items.map((item) => {
|
||||||
const fills = result.slotFills[item.id]
|
const fills = result.slotFills[item.id]
|
||||||
if (!fills || !item.slots) return item
|
if (!fills || !item.slots) return item
|
||||||
@@ -31,6 +37,7 @@ export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, c
|
|||||||
for (const synthetic of result.syntheticItems) {
|
for (const synthetic of result.syntheticItems) {
|
||||||
merged.push({
|
merged.push({
|
||||||
id: synthetic.id,
|
id: synthetic.id,
|
||||||
|
sourceId: ENHANCEMENT_SOURCE_ID,
|
||||||
type: synthetic.type,
|
type: synthetic.type,
|
||||||
timestamp: currentTime,
|
timestamp: currentTime,
|
||||||
data: { text: synthetic.text },
|
data: { text: synthetic.text },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||||
return {
|
return {
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -60,7 +61,9 @@ describe("buildPrompt", () => {
|
|||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
expect(parsed.items).toHaveLength(1)
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
|
||||||
|
insight: "Weather insight",
|
||||||
|
})
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
||||||
expect(parsed.context).toHaveLength(0)
|
expect(parsed.context).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Hono } from "hono"
|
|
||||||
|
|
||||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import { UserSessionManager } from "../session/index.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./http.ts"
|
|
||||||
|
|
||||||
interface FeedResponse {
|
|
||||||
items: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
priority: number
|
|
||||||
timestamp: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
errors: Array<{ sourceId: string; error: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction(): Promise<unknown> {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
|
||||||
})
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("GET /api/feed", () => {
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns cached feed when available", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.8,
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// Prime the cache
|
|
||||||
const session = manager.getOrCreate("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
expect(session.engine.lastFeed()).not.toBeNull()
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("item-1")
|
|
||||||
expect(body.items[0]!.type).toBe("test")
|
|
||||||
expect(body.items[0]!.priority).toBe(0.8)
|
|
||||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forces refresh when no cached feed", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "fresh-1",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
|
||||||
data: { fresh: true },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [() => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("fresh-1")
|
|
||||||
expect(body.items[0]!.data.fresh).toBe(true)
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes source errors as message strings", async () => {
|
|
||||||
const failingSource: FeedSource = {
|
|
||||||
id: "failing",
|
|
||||||
async listActions() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction() {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
throw new Error("connection timeout")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(0)
|
|
||||||
expect(body.errors).toHaveLength(1)
|
|
||||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
|
||||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Context, Hono } from "hono"
|
|
||||||
|
|
||||||
import { createMiddleware } from "hono/factory"
|
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
|
||||||
|
|
||||||
type Env = {
|
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerFeedHttpHandlers(
|
|
||||||
app: Hono,
|
|
||||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
|
||||||
) {
|
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
|
||||||
c.set("sessionManager", sessionManager)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetFeed(c: Context<Env>) {
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
|
||||||
|
|
||||||
const feed = await session.feed()
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
items: feed.items,
|
|
||||||
errors: feed.errors.map((e) => ({
|
|
||||||
sourceId: e.sourceId,
|
|
||||||
error: e.error.message,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
62
apps/aelis-backend/src/lib/crypto.test.ts
Normal file
62
apps/aelis-backend/src/lib/crypto.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { randomBytes } from "node:crypto"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { CredentialEncryptor } from "./crypto.ts"
|
||||||
|
|
||||||
|
const TEST_KEY = randomBytes(32).toString("base64")
|
||||||
|
|
||||||
|
describe("CredentialEncryptor", () => {
|
||||||
|
const encryptor = new CredentialEncryptor(TEST_KEY)
|
||||||
|
|
||||||
|
test("round-trip with simple string", () => {
|
||||||
|
const plaintext = "hello world"
|
||||||
|
const encrypted = encryptor.encrypt(plaintext)
|
||||||
|
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("round-trip with JSON credentials", () => {
|
||||||
|
const credentials = JSON.stringify({
|
||||||
|
accessToken: "ya29.a0AfH6SMB...",
|
||||||
|
refreshToken: "1//0dx...",
|
||||||
|
expiresAt: "2025-12-01T00:00:00Z",
|
||||||
|
})
|
||||||
|
const encrypted = encryptor.encrypt(credentials)
|
||||||
|
expect(encryptor.decrypt(encrypted)).toBe(credentials)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("round-trip with empty string", () => {
|
||||||
|
const encrypted = encryptor.encrypt("")
|
||||||
|
expect(encryptor.decrypt(encrypted)).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("round-trip with unicode", () => {
|
||||||
|
const plaintext = "日本語テスト 🔐"
|
||||||
|
const encrypted = encryptor.encrypt(plaintext)
|
||||||
|
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("each encryption produces different ciphertext (unique IV)", () => {
|
||||||
|
const plaintext = "same input"
|
||||||
|
const a = encryptor.encrypt(plaintext)
|
||||||
|
const b = encryptor.encrypt(plaintext)
|
||||||
|
expect(a).not.toEqual(b)
|
||||||
|
expect(encryptor.decrypt(a)).toBe(plaintext)
|
||||||
|
expect(encryptor.decrypt(b)).toBe(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tampered ciphertext throws", () => {
|
||||||
|
const encrypted = encryptor.encrypt("secret")
|
||||||
|
encrypted[13]! ^= 0xff
|
||||||
|
expect(() => encryptor.decrypt(encrypted)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("truncated data throws", () => {
|
||||||
|
expect(() => encryptor.decrypt(Buffer.alloc(10))).toThrow("Encrypted data is too short")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws when key is wrong length", () => {
|
||||||
|
expect(() => new CredentialEncryptor(Buffer.from("too-short").toString("base64"))).toThrow(
|
||||||
|
"must be 32 bytes",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
60
apps/aelis-backend/src/lib/crypto.ts
Normal file
60
apps/aelis-backend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm"
|
||||||
|
const IV_LENGTH = 12
|
||||||
|
const AUTH_TAG_LENGTH = 16
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM encryption for credential storage.
|
||||||
|
*
|
||||||
|
* Caches the parsed key on construction to avoid repeated
|
||||||
|
* env reads and Buffer allocations.
|
||||||
|
*/
|
||||||
|
export class CredentialEncryptor {
|
||||||
|
private readonly key: Buffer
|
||||||
|
|
||||||
|
constructor(base64Key: string) {
|
||||||
|
const key = Buffer.from(base64Key, "base64")
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`Encryption key must be 32 bytes (got ${key.length}). Generate with: openssl rand -base64 32`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.key = key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts plaintext using AES-256-GCM.
|
||||||
|
*
|
||||||
|
* Output format: [12-byte IV][ciphertext][16-byte auth tag]
|
||||||
|
*/
|
||||||
|
encrypt(plaintext: string): Buffer {
|
||||||
|
const iv = randomBytes(IV_LENGTH)
|
||||||
|
const cipher = createCipheriv(ALGORITHM, this.key, iv)
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
|
||||||
|
const authTag = cipher.getAuthTag()
|
||||||
|
|
||||||
|
return Buffer.concat([iv, encrypted, authTag])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a buffer produced by `encrypt`.
|
||||||
|
*
|
||||||
|
* Expects format: [12-byte IV][ciphertext][16-byte auth tag]
|
||||||
|
*/
|
||||||
|
decrypt(data: Buffer): string {
|
||||||
|
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||||
|
throw new Error("Encrypted data is too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = data.subarray(0, IV_LENGTH)
|
||||||
|
const authTag = data.subarray(data.length - AUTH_TAG_LENGTH)
|
||||||
|
const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH)
|
||||||
|
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, this.key, iv)
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,9 @@ import type { Context, Hono } from "hono"
|
|||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { createMiddleware } from "hono/factory"
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
import { requireSession } from "../auth/session-middleware.ts"
|
|
||||||
|
|
||||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||||
|
|
||||||
const locationInput = type({
|
const locationInput = type({
|
||||||
@@ -16,16 +15,21 @@ const locationInput = type({
|
|||||||
timestamp: "string.date.iso",
|
timestamp: "string.date.iso",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface LocationHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
export function registerLocationHttpHandlers(
|
export function registerLocationHttpHandlers(
|
||||||
app: Hono,
|
app: Hono,
|
||||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
{ sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps,
|
||||||
) {
|
) {
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
c.set("sessionManager", sessionManager)
|
c.set("sessionManager", sessionManager)
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateLocation(c: Context<Env>) {
|
async function handleUpdateLocation(c: Context<Env>) {
|
||||||
@@ -44,7 +48,15 @@ async function handleUpdateLocation(c: Context<Env>) {
|
|||||||
|
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const sessionManager = c.get("sessionManager")
|
const sessionManager = c.get("sessionManager")
|
||||||
const session = sessionManager.getOrCreate(user.id)
|
|
||||||
|
let session
|
||||||
|
try {
|
||||||
|
session = await sessionManager.getOrCreate(user.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[handleUpdateLocation] Failed to create session:", err)
|
||||||
|
return c.json({ error: "Service unavailable" }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: result.lat,
|
lat: result.lat,
|
||||||
lng: result.lng,
|
lng: result.lng,
|
||||||
|
|||||||
11
apps/aelis-backend/src/location/provider.ts
Normal file
11
apps/aelis-backend/src/location/provider.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { LocationSource } from "@aelis/source-location"
|
||||||
|
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
export class LocationSourceProvider implements FeedSourceProvider {
|
||||||
|
readonly sourceId = "aelis.location"
|
||||||
|
|
||||||
|
async feedSourceForUser(_userId: string, _config: unknown): Promise<LocationSource> {
|
||||||
|
return new LocationSource()
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/aelis-backend/src/scripts/create-admin.ts
Normal file
63
apps/aelis-backend/src/scripts/create-admin.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Creates an admin user account via Better Auth's server-side API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run src/scripts/create-admin.ts --name "Admin" --email admin@example.com --password secret123
|
||||||
|
*
|
||||||
|
* Requires DATABASE_URL and BETTER_AUTH_SECRET to be set (reads .env automatically).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseArgs } from "util"
|
||||||
|
|
||||||
|
import { createAuth } from "../auth/index.ts"
|
||||||
|
import { createDatabase } from "../db/index.ts"
|
||||||
|
|
||||||
|
function parseCliArgs(): { name: string; email: string; password: string } {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: Bun.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
name: { type: "string" },
|
||||||
|
email: { type: "string" },
|
||||||
|
password: { type: "string" },
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!values.name || !values.email || !values.password) {
|
||||||
|
console.error(
|
||||||
|
"Usage: bun run src/scripts/create-admin.ts --name <name> --email <email> --password <password>",
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: values.name, email: values.email, password: values.password }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { name, email, password } = parseCliArgs()
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("DATABASE_URL is not set")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, close } = createDatabase(databaseUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = createAuth(db)
|
||||||
|
|
||||||
|
const result = await auth.api.createUser({
|
||||||
|
body: { name, email, password, role: "admin" },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Admin account created: ${result.user.id} (${result.user.email})`)
|
||||||
|
} finally {
|
||||||
|
await close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Failed to create admin account:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { LocationSource } from "@aelis/source-location"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
import { cors } from "hono/cors"
|
||||||
|
|
||||||
|
import { registerAdminHttpHandlers } from "./admin/http.ts"
|
||||||
|
import { createRequireAdmin } from "./auth/admin-middleware.ts"
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
|
import { createAuth } from "./auth/index.ts"
|
||||||
|
import { createRequireSession } from "./auth/session-middleware.ts"
|
||||||
|
import { createDatabase } from "./db/index.ts"
|
||||||
|
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
||||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
|
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||||
|
const auth = createAuth(db)
|
||||||
|
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||||
const feedEnhancer = openrouterApiKey
|
const feedEnhancer = openrouterApiKey
|
||||||
? createFeedEnhancer({
|
? createFeedEnhancer({
|
||||||
@@ -25,8 +34,9 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
const sessionManager = new UserSessionManager({
|
||||||
|
db,
|
||||||
providers: [
|
providers: [
|
||||||
() => new LocationSource(),
|
new LocationSourceProvider(),
|
||||||
new WeatherSourceProvider({
|
new WeatherSourceProvider({
|
||||||
credentials: {
|
credentials: {
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
@@ -41,20 +51,53 @@ function main() {
|
|||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== "production"
|
||||||
|
const allowedOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()) ?? []
|
||||||
|
|
||||||
|
function resolveOrigin(origin: string): string | undefined {
|
||||||
|
if (isDev) return origin
|
||||||
|
return allowedOrigins.includes(origin) ? origin : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"/api/auth/*",
|
||||||
|
cors({
|
||||||
|
origin: resolveOrigin,
|
||||||
|
allowHeaders: ["Content-Type", "Authorization"],
|
||||||
|
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||||
|
exposeHeaders: ["Content-Length"],
|
||||||
|
maxAge: 600,
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"*",
|
||||||
|
cors({
|
||||||
|
origin: resolveOrigin,
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== "production"
|
const authSessionMiddleware = createRequireSession(auth)
|
||||||
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
|
const adminMiddleware = createRequireAdmin(auth)
|
||||||
|
|
||||||
if (!isDev) {
|
registerAuthHandlers(app, auth)
|
||||||
registerAuthHandlers(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware,
|
authSessionMiddleware,
|
||||||
})
|
})
|
||||||
registerLocationHttpHandlers(app, { sessionManager })
|
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
||||||
|
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
||||||
|
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
await closeDb()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
import type { FeedSource } from "@aelis/core"
|
||||||
|
import type { type } from "arktype"
|
||||||
|
|
||||||
|
export type ConfigSchema = ReturnType<typeof type>
|
||||||
|
|
||||||
export interface FeedSourceProvider {
|
export interface FeedSourceProvider {
|
||||||
feedSourceForUser(userId: string): FeedSource
|
/** The source ID this provider is responsible for (e.g., "aelis.location"). */
|
||||||
|
readonly sourceId: string
|
||||||
|
/** Arktype schema for validating user-provided config. Omit if the source has no config. */
|
||||||
|
readonly configSchema?: ConfigSchema
|
||||||
|
feedSourceForUser(userId: string, config: unknown): Promise<FeedSource>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
|
||||||
|
|
||||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
export type {
|
export type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||||
FeedSourceProvider,
|
|
||||||
FeedSourceProviderFn,
|
|
||||||
FeedSourceProviderInput,
|
|
||||||
} from "./feed-source-provider.ts"
|
|
||||||
export { UserSession } from "./user-session.ts"
|
export { UserSession } from "./user-session.ts"
|
||||||
export { UserSessionManager } from "./user-session-manager.ts"
|
export { UserSessionManager } from "./user-session-manager.ts"
|
||||||
|
|||||||
@@ -1,48 +1,160 @@
|
|||||||
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aelis/source-location"
|
import { LocationSource } from "@aelis/source-location"
|
||||||
import { describe, expect, mock, test } from "bun:test"
|
import { WeatherSource } from "@aelis/source-weatherkit"
|
||||||
|
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
import type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
import { WeatherSourceProvider } from "../weather/provider.ts"
|
|
||||||
import { UserSessionManager } from "./user-session-manager.ts"
|
import { UserSessionManager } from "./user-session-manager.ts"
|
||||||
|
|
||||||
const mockWeatherClient: WeatherKitClient = {
|
/**
|
||||||
fetch: async () => ({}) as WeatherKitResponse,
|
* Per-user enabled source IDs used by the mocked `sources` module.
|
||||||
|
* Tests configure this before calling getOrCreate.
|
||||||
|
* Key = userId (or "*" for a default), value = array of enabled sourceIds.
|
||||||
|
*/
|
||||||
|
const enabledByUser = new Map<string, string[]>()
|
||||||
|
|
||||||
|
/** Set which sourceIds are enabled for all users. */
|
||||||
|
function setEnabledSources(sourceIds: string[]) {
|
||||||
|
enabledByUser.clear()
|
||||||
|
enabledByUser.set("*", sourceIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("UserSessionManager", () => {
|
/** Set which sourceIds are enabled for a specific user. */
|
||||||
test("getOrCreate creates session on first call", () => {
|
function setEnabledSourcesForUser(userId: string, sourceIds: string[]) {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
enabledByUser.set(userId, sourceIds)
|
||||||
|
}
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
function getEnabledSourceIds(userId: string): string[] {
|
||||||
|
return enabledByUser.get(userId) ?? enabledByUser.get("*") ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls what `find()` returns in the mock. When `undefined` (the default),
|
||||||
|
* `find()` returns a standard enabled row. Set to a specific value (including
|
||||||
|
* `null`) to override the return value for all `find()` calls.
|
||||||
|
*/
|
||||||
|
let mockFindResult: unknown | undefined
|
||||||
|
|
||||||
|
// Mock the sources module so UserSessionManager's DB query returns controlled data.
|
||||||
|
mock.module("../sources/user-sources.ts", () => ({
|
||||||
|
sources: (_db: Database, userId: string) => ({
|
||||||
|
async enabled() {
|
||||||
|
const now = new Date()
|
||||||
|
return getEnabledSourceIds(userId).map((sourceId) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async find(sourceId: string) {
|
||||||
|
if (mockFindResult !== undefined) return mockFindResult
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
credentials: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fakeDb = {} as Database
|
||||||
|
|
||||||
|
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubProvider(
|
||||||
|
sourceId: string,
|
||||||
|
factory: (userId: string, config: Record<string, unknown>) => Promise<FeedSource> = async () =>
|
||||||
|
createStubSource(sourceId),
|
||||||
|
): FeedSourceProvider {
|
||||||
|
return { sourceId, feedSourceForUser: factory }
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationProvider: FeedSourceProvider = {
|
||||||
|
sourceId: "aelis.location",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return new LocationSource()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const weatherProvider: FeedSourceProvider = {
|
||||||
|
sourceId: "aelis.weather",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return new WeatherSource({ client: { fetch: async () => ({}) as never } })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
enabledByUser.clear()
|
||||||
|
mockFindResult = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("UserSessionManager", () => {
|
||||||
|
test("getOrCreate creates session on first call", async () => {
|
||||||
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session).toBeDefined()
|
expect(session).toBeDefined()
|
||||||
expect(session.engine).toBeDefined()
|
expect(session.engine).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", () => {
|
test("getOrCreate returns same session for same user", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session1).toBe(session2)
|
expect(session1).toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", () => {
|
test("getOrCreate returns different sessions for different users", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = await manager.getOrCreate("user-2")
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
expect(session1).not.toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("each user gets independent source instances", () => {
|
test("each user gets independent source instances", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
const session2 = manager.getOrCreate("user-2")
|
const session2 = await manager.getOrCreate("user-2")
|
||||||
|
|
||||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||||
@@ -50,58 +162,42 @@ describe("UserSessionManager", () => {
|
|||||||
expect(source1).not.toBe(source2)
|
expect(source1).not.toBe(source2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", () => {
|
test("remove destroys session and allows re-creation", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session1 = manager.getOrCreate("user-1")
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
expect(session1).not.toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
test("remove is no-op for unknown user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
test("registers multiple providers", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location", "aelis.weather"])
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
|
||||||
const result = await session.engine.refresh()
|
|
||||||
|
|
||||||
expect(result.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts object providers", () => {
|
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager({
|
||||||
providers: [() => new LocationSource(), provider],
|
db: fakeDb,
|
||||||
|
providers: [locationProvider, weatherProvider],
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts mixed providers", () => {
|
|
||||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [() => new LocationSource(), provider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session.getSource("aelis.location")).toBeDefined()
|
expect(session.getSource("aelis.location")).toBeDefined()
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
test("refresh returns feed result through session", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = await manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
expect(result).toHaveProperty("context")
|
expect(result).toHaveProperty("context")
|
||||||
@@ -111,9 +207,10 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
test("location update via executeAction works", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = await manager.getOrCreate("user-1")
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
lng: -0.1278,
|
lng: -0.1278,
|
||||||
@@ -126,10 +223,11 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
test("subscribe receives updates after location push", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = await manager.getOrCreate("user-1")
|
||||||
session.engine.subscribe(callback)
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
@@ -146,16 +244,17 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
test("remove stops reactive updates", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = manager.getOrCreate("user-1")
|
const session = await manager.getOrCreate("user-1")
|
||||||
session.engine.subscribe(callback)
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
|
|
||||||
// Create new session and push location — old callback should not fire
|
// Create new session and push location — old callback should not fire
|
||||||
const session2 = manager.getOrCreate("user-1")
|
const session2 = await manager.getOrCreate("user-1")
|
||||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
await session2.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
lng: -0.1278,
|
lng: -0.1278,
|
||||||
@@ -167,4 +266,418 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
expect(callback).not.toHaveBeenCalled()
|
expect(callback).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("creates session with successful providers when some fail", async () => {
|
||||||
|
setEnabledSources(["aelis.location", "aelis.failing"])
|
||||||
|
const failingProvider: FeedSourceProvider = {
|
||||||
|
sourceId: "aelis.failing",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
throw new Error("provider failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [locationProvider, failingProvider],
|
||||||
|
})
|
||||||
|
|
||||||
|
const spy = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session).toBeDefined()
|
||||||
|
expect(session.getSource("aelis.location")).toBeDefined()
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws AggregateError when all providers fail", async () => {
|
||||||
|
setEnabledSources(["aelis.fail-1", "aelis.fail-2"])
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "aelis.fail-1",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
throw new Error("first failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceId: "aelis.fail-2",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
throw new Error("second failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent getOrCreate for same user returns same session", async () => {
|
||||||
|
setEnabledSources(["aelis.location"])
|
||||||
|
let callCount = 0
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "aelis.location",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
callCount++
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
return new LocationSource()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const [session1, session2] = await Promise.all([
|
||||||
|
manager.getOrCreate("user-1"),
|
||||||
|
manager.getOrCreate("user-1"),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(session1).toBe(session2)
|
||||||
|
expect(callCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
|
||||||
|
setEnabledSources(["aelis.location"])
|
||||||
|
let resolveProvider: () => void
|
||||||
|
const providerGate = new Promise<void>((r) => {
|
||||||
|
resolveProvider = r
|
||||||
|
})
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
sourceId: "aelis.location",
|
||||||
|
async feedSourceForUser() {
|
||||||
|
await providerGate
|
||||||
|
return new LocationSource()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionPromise = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
// remove() while provider is still resolving
|
||||||
|
manager.remove("user-1")
|
||||||
|
|
||||||
|
// Let the provider finish
|
||||||
|
resolveProvider!()
|
||||||
|
|
||||||
|
await expect(sessionPromise).rejects.toThrow("removed during creation")
|
||||||
|
|
||||||
|
// A fresh getOrCreate should produce a new session, not the cancelled one
|
||||||
|
const freshSession = await manager.getOrCreate("user-1")
|
||||||
|
expect(freshSession).toBeDefined()
|
||||||
|
expect(freshSession.engine).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only invokes providers for sources enabled for the user", async () => {
|
||||||
|
setEnabledSources(["aelis.location"])
|
||||||
|
const locationFactory = mock(async () => createStubSource("aelis.location"))
|
||||||
|
const weatherFactory = mock(async () => createStubSource("aelis.weather"))
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [
|
||||||
|
{ sourceId: "aelis.location", feedSourceForUser: locationFactory },
|
||||||
|
{ sourceId: "aelis.weather", feedSourceForUser: weatherFactory },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(locationFactory).toHaveBeenCalledTimes(1)
|
||||||
|
expect(weatherFactory).not.toHaveBeenCalled()
|
||||||
|
expect(session.getSource("aelis.location")).toBeDefined()
|
||||||
|
expect(session.getSource("aelis.weather")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates empty session when no sources are enabled", async () => {
|
||||||
|
setEnabledSources([])
|
||||||
|
const factory = mock(async () => createStubSource("aelis.location"))
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [{ sourceId: "aelis.location", feedSourceForUser: factory }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(factory).not.toHaveBeenCalled()
|
||||||
|
expect(session).toBeDefined()
|
||||||
|
expect(session.getSource("aelis.location")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("per-user enabled sources are respected", async () => {
|
||||||
|
enabledByUser.clear()
|
||||||
|
setEnabledSourcesForUser("user-1", ["aelis.location"])
|
||||||
|
setEnabledSourcesForUser("user-2", ["aelis.weather"])
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
db: fakeDb,
|
||||||
|
providers: [createStubProvider("aelis.location"), createStubProvider("aelis.weather")],
|
||||||
|
})
|
||||||
|
|
||||||
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
|
const session2 = await manager.getOrCreate("user-2")
|
||||||
|
|
||||||
|
expect(session1.getSource("aelis.location")).toBeDefined()
|
||||||
|
expect(session1.getSource("aelis.weather")).toBeUndefined()
|
||||||
|
expect(session2.getSource("aelis.location")).toBeUndefined()
|
||||||
|
expect(session2.getSource("aelis.weather")).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("UserSessionManager.replaceProvider", () => {
|
||||||
|
test("replaces source in all active sessions", async () => {
|
||||||
|
setEnabledSources(["test"])
|
||||||
|
const itemsV1: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const itemsV2: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const providerV1 = createStubProvider("test", async () => createStubSource("test", itemsV1))
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerV1] })
|
||||||
|
|
||||||
|
const session1 = await manager.getOrCreate("user-1")
|
||||||
|
const session2 = await manager.getOrCreate("user-2")
|
||||||
|
|
||||||
|
// Verify v1 items
|
||||||
|
const feed1 = await session1.feed()
|
||||||
|
expect(feed1.items[0]!.data.version).toBe(1)
|
||||||
|
|
||||||
|
// Replace provider
|
||||||
|
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
|
||||||
|
await manager.replaceProvider(providerV2)
|
||||||
|
|
||||||
|
// Both sessions should now serve v2 items
|
||||||
|
const feed1After = await session1.feed()
|
||||||
|
const feed2After = await session2.feed()
|
||||||
|
expect(feed1After.items[0]!.data.version).toBe(2)
|
||||||
|
expect(feed2After.items[0]!.data.version).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for unknown provider sourceId", async () => {
|
||||||
|
setEnabledSources(["aelis.location"])
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||||
|
|
||||||
|
const unknownProvider = createStubProvider("aelis.unknown")
|
||||||
|
|
||||||
|
await expect(manager.replaceProvider(unknownProvider)).rejects.toThrow(
|
||||||
|
"no existing provider with that sourceId",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps existing source when new provider fails for a user", async () => {
|
||||||
|
setEnabledSources(["test"])
|
||||||
|
const providerV1 = createStubProvider("test", async () => createStubSource("test"))
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerV1] })
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
expect(session.getSource("test")).toBeDefined()
|
||||||
|
|
||||||
|
const spy = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const failingProvider = createStubProvider("test", async () => {
|
||||||
|
throw new Error("source disabled")
|
||||||
|
})
|
||||||
|
await manager.replaceProvider(failingProvider)
|
||||||
|
|
||||||
|
expect(session.getSource("test")).toBeDefined()
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("new sessions use the replaced provider", async () => {
|
||||||
|
setEnabledSources(["test"])
|
||||||
|
const itemsV1: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const itemsV2: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const providerV1 = createStubProvider("test", async () => createStubSource("test", itemsV1))
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerV1] })
|
||||||
|
|
||||||
|
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
|
||||||
|
await manager.replaceProvider(providerV2)
|
||||||
|
|
||||||
|
// New session should use v2
|
||||||
|
const session = await manager.getOrCreate("user-new")
|
||||||
|
const feed = await session.feed()
|
||||||
|
expect(feed.items[0]!.data.version).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not affect other providers' sources", async () => {
|
||||||
|
setEnabledSources(["source-a", "source-b"])
|
||||||
|
const providerA = createStubProvider("source-a", async () =>
|
||||||
|
createStubSource("source-a", [
|
||||||
|
{
|
||||||
|
id: "a-1",
|
||||||
|
sourceId: "source-a",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "a" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const providerB = createStubProvider("source-b", async () =>
|
||||||
|
createStubSource("source-b", [
|
||||||
|
{
|
||||||
|
id: "b-1",
|
||||||
|
sourceId: "source-b",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "b" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerA, providerB] })
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
// Replace only source-a
|
||||||
|
const providerA2 = createStubProvider("source-a", async () =>
|
||||||
|
createStubSource("source-a", [
|
||||||
|
{
|
||||||
|
id: "a-2",
|
||||||
|
sourceId: "source-a",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "a-new" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await manager.replaceProvider(providerA2)
|
||||||
|
|
||||||
|
// source-b should be unaffected
|
||||||
|
expect(session.getSource("source-b")).toBeDefined()
|
||||||
|
const feed = await session.feed()
|
||||||
|
const ids = feed.items.map((i) => i.id).sort()
|
||||||
|
expect(ids).toEqual(["a-2", "b-1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates sessions that are still being created", async () => {
|
||||||
|
setEnabledSources(["test"])
|
||||||
|
const itemsV1: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const itemsV2: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let resolveCreation: () => void
|
||||||
|
const creationGate = new Promise<void>((r) => {
|
||||||
|
resolveCreation = r
|
||||||
|
})
|
||||||
|
|
||||||
|
const providerV1 = createStubProvider("test", async () => {
|
||||||
|
await creationGate
|
||||||
|
return createStubSource("test", itemsV1)
|
||||||
|
})
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerV1] })
|
||||||
|
|
||||||
|
// Start session creation but don't let it finish yet
|
||||||
|
const sessionPromise = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
// Replace provider while session is still pending
|
||||||
|
const providerV2 = createStubProvider("test", async () => createStubSource("test", itemsV2))
|
||||||
|
const replacePromise = manager.replaceProvider(providerV2)
|
||||||
|
|
||||||
|
// Let the original creation finish
|
||||||
|
resolveCreation!()
|
||||||
|
|
||||||
|
const session = await sessionPromise
|
||||||
|
await replacePromise
|
||||||
|
|
||||||
|
// Session should have been updated to v2
|
||||||
|
const feed = await session.feed()
|
||||||
|
expect(feed.items[0]!.data.version).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips source replacement when source was disabled between creation and replace", async () => {
|
||||||
|
setEnabledSources(["test"])
|
||||||
|
const itemsV1: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const providerV1 = createStubProvider("test", async () => createStubSource("test", itemsV1))
|
||||||
|
const manager = new UserSessionManager({ db: fakeDb, providers: [providerV1] })
|
||||||
|
|
||||||
|
const session = await manager.getOrCreate("user-1")
|
||||||
|
const feedBefore = await session.feed()
|
||||||
|
expect(feedBefore.items[0]!.data.version).toBe(1)
|
||||||
|
|
||||||
|
// Simulate the source being disabled/deleted between session creation and replace
|
||||||
|
mockFindResult = null
|
||||||
|
|
||||||
|
const providerV2 = createStubProvider("test", async () =>
|
||||||
|
createStubSource("test", [
|
||||||
|
{
|
||||||
|
id: "v2",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await manager.replaceProvider(providerV2)
|
||||||
|
|
||||||
|
// Session should still have v1 — the replace was skipped
|
||||||
|
const feedAfter = await session.feed()
|
||||||
|
expect(feedAfter.items[0]!.data.version).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,33 +1,86 @@
|
|||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
import type { FeedSource } from "@aelis/core"
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
|
||||||
|
|
||||||
|
import { type } from "arktype"
|
||||||
|
import merge from "lodash.merge"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
|
import type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
|
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts"
|
||||||
|
import { sources } from "../sources/user-sources.ts"
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
export interface UserSessionManagerConfig {
|
export interface UserSessionManagerConfig {
|
||||||
providers: FeedSourceProviderInput[]
|
db: Database
|
||||||
|
providers: FeedSourceProvider[]
|
||||||
feedEnhancer?: FeedEnhancer | null
|
feedEnhancer?: FeedEnhancer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
private sessions = new Map<string, UserSession>()
|
private sessions = new Map<string, UserSession>()
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
private pending = new Map<string, Promise<UserSession>>()
|
||||||
|
private readonly db: Database
|
||||||
|
private readonly providers = new Map<string, FeedSourceProvider>()
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
private readonly feedEnhancer: FeedEnhancer | null
|
||||||
|
private readonly db: Database
|
||||||
|
|
||||||
constructor(config: UserSessionManagerConfig) {
|
constructor(config: UserSessionManagerConfig) {
|
||||||
this.providers = config.providers
|
this.db = config.db
|
||||||
|
for (const provider of config.providers) {
|
||||||
|
this.providers.set(provider.sourceId, provider)
|
||||||
|
}
|
||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
this.feedEnhancer = config.feedEnhancer ?? null
|
||||||
|
this.db = config.db
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrCreate(userId: string): UserSession {
|
getProvider(sourceId: string): FeedSourceProvider | undefined {
|
||||||
let session = this.sessions.get(userId)
|
return this.providers.get(sourceId)
|
||||||
if (!session) {
|
}
|
||||||
const sources = this.providers.map((p) =>
|
|
||||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
/**
|
||||||
)
|
* Returns the user's config for a source, or defaults if no row exists.
|
||||||
session = new UserSession(sources, this.feedEnhancer)
|
*
|
||||||
this.sessions.set(userId, session)
|
* @throws {SourceNotFoundError} if the sourceId has no registered provider
|
||||||
|
*/
|
||||||
|
async fetchSourceConfig(
|
||||||
|
userId: string,
|
||||||
|
sourceId: string,
|
||||||
|
): Promise<{ enabled: boolean; config: unknown }> {
|
||||||
|
const provider = this.providers.get(sourceId)
|
||||||
|
if (!provider) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await sources(this.db, userId).find(sourceId)
|
||||||
|
return {
|
||||||
|
enabled: row?.enabled ?? false,
|
||||||
|
config: row?.config ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreate(userId: string): Promise<UserSession> {
|
||||||
|
const existing = this.sessions.get(userId)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const inflight = this.pending.get(userId)
|
||||||
|
if (inflight) return inflight
|
||||||
|
|
||||||
|
const promise = this.createSession(userId)
|
||||||
|
this.pending.set(userId, promise)
|
||||||
|
try {
|
||||||
|
const session = await promise
|
||||||
|
// If remove() was called while we were awaiting, it clears the
|
||||||
|
// pending entry. Detect that and destroy the session immediately.
|
||||||
|
if (!this.pending.has(userId)) {
|
||||||
|
session.destroy()
|
||||||
|
throw new Error(`Session for user ${userId} was removed during creation`)
|
||||||
|
}
|
||||||
|
this.sessions.set(userId, session)
|
||||||
|
return session
|
||||||
|
} finally {
|
||||||
|
this.pending.delete(userId)
|
||||||
}
|
}
|
||||||
return session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(userId: string): void {
|
remove(userId: string): void {
|
||||||
@@ -36,5 +89,217 @@ export class UserSessionManager {
|
|||||||
session.destroy()
|
session.destroy()
|
||||||
this.sessions.delete(userId)
|
this.sessions.delete(userId)
|
||||||
}
|
}
|
||||||
|
// Cancel any in-flight creation so getOrCreate won't store the session
|
||||||
|
this.pending.delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges, validates, and persists a user's source config and/or enabled
|
||||||
|
* state, then invalidates the cached session.
|
||||||
|
*
|
||||||
|
* @throws {SourceNotFoundError} if the source row doesn't exist
|
||||||
|
* @throws {InvalidSourceConfigError} if the merged config fails schema validation
|
||||||
|
*/
|
||||||
|
async updateSourceConfig(
|
||||||
|
userId: string,
|
||||||
|
sourceId: string,
|
||||||
|
update: { enabled?: boolean; config?: unknown },
|
||||||
|
): Promise<void> {
|
||||||
|
const provider = this.providers.get(sourceId)
|
||||||
|
if (!provider) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to update
|
||||||
|
if (update.enabled === undefined && update.config === undefined) {
|
||||||
|
// Still validate existence — updateConfig would throw, but
|
||||||
|
// we can avoid the DB write entirely.
|
||||||
|
if (!(await sources(this.db, userId).find(sourceId))) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// When config is provided, fetch existing to deep-merge before validating.
|
||||||
|
// NOTE: find + updateConfig is not atomic. A concurrent update could
|
||||||
|
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
|
||||||
|
// this becomes a problem.
|
||||||
|
let mergedConfig: Record<string, unknown> | undefined
|
||||||
|
if (update.config !== undefined && provider.configSchema) {
|
||||||
|
const existing = await sources(this.db, userId).find(sourceId)
|
||||||
|
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
|
||||||
|
mergedConfig = merge({}, existingConfig, update.config)
|
||||||
|
|
||||||
|
const validated = provider.configSchema(mergedConfig)
|
||||||
|
if (validated instanceof type.errors) {
|
||||||
|
throw new InvalidSourceConfigError(sourceId, validated.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throws SourceNotFoundError if the row doesn't exist
|
||||||
|
await sources(this.db, userId).updateConfig(sourceId, {
|
||||||
|
enabled: update.enabled,
|
||||||
|
config: mergedConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh the specific source in the active session instead of
|
||||||
|
// destroying the entire session.
|
||||||
|
const session = this.sessions.get(userId)
|
||||||
|
if (session) {
|
||||||
|
if (update.enabled === false) {
|
||||||
|
session.removeSource(sourceId)
|
||||||
|
} else {
|
||||||
|
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {})
|
||||||
|
session.replaceSource(sourceId, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates, persists, and upserts a user's source config, then
|
||||||
|
* refreshes the cached session. Unlike updateSourceConfig, this
|
||||||
|
* inserts a new row if one doesn't exist and fully replaces config
|
||||||
|
* (no merge).
|
||||||
|
*
|
||||||
|
* @throws {SourceNotFoundError} if the sourceId has no registered provider
|
||||||
|
* @throws {InvalidSourceConfigError} if config fails schema validation
|
||||||
|
*/
|
||||||
|
async upsertSourceConfig(
|
||||||
|
userId: string,
|
||||||
|
sourceId: string,
|
||||||
|
data: { enabled: boolean; config?: unknown },
|
||||||
|
): Promise<void> {
|
||||||
|
const provider = this.providers.get(sourceId)
|
||||||
|
if (!provider) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.configSchema && data.config !== undefined) {
|
||||||
|
const validated = provider.configSchema(data.config)
|
||||||
|
if (validated instanceof type.errors) {
|
||||||
|
throw new InvalidSourceConfigError(sourceId, validated.summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = data.config ?? {}
|
||||||
|
await sources(this.db, userId).upsertConfig(sourceId, {
|
||||||
|
enabled: data.enabled,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = this.sessions.get(userId)
|
||||||
|
if (session) {
|
||||||
|
if (!data.enabled) {
|
||||||
|
session.removeSource(sourceId)
|
||||||
|
} else {
|
||||||
|
const source = await provider.feedSourceForUser(userId, config)
|
||||||
|
if (session.hasSource(sourceId)) {
|
||||||
|
session.replaceSource(sourceId, source)
|
||||||
|
} else {
|
||||||
|
session.addSource(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a provider and updates all active sessions.
|
||||||
|
* The new provider must have the same sourceId as an existing one.
|
||||||
|
* For each active session, queries the user's source config from the DB
|
||||||
|
* and re-resolves the source. If the provider fails for a user, the
|
||||||
|
* existing source is kept.
|
||||||
|
*/
|
||||||
|
async replaceProvider(provider: FeedSourceProvider): Promise<void> {
|
||||||
|
if (!this.providers.has(provider.sourceId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot replace provider "${provider.sourceId}": no existing provider with that sourceId`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.providers.set(provider.sourceId, provider)
|
||||||
|
|
||||||
|
const updates: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (const [, session] of this.sessions) {
|
||||||
|
updates.push(this.refreshSessionSource(session, provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update sessions that are currently being created so they
|
||||||
|
// don't land in this.sessions with a stale source.
|
||||||
|
for (const [, pendingPromise] of this.pending) {
|
||||||
|
updates.push(
|
||||||
|
pendingPromise
|
||||||
|
.then((session) => this.refreshSessionSource(session, provider))
|
||||||
|
.catch(() => {
|
||||||
|
// Session creation itself failed — nothing to update.
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolves a single source for a session by querying the user's config
|
||||||
|
* from the DB and calling the provider. If the provider fails, the existing
|
||||||
|
* source is kept.
|
||||||
|
*/
|
||||||
|
private async refreshSessionSource(
|
||||||
|
session: UserSession,
|
||||||
|
provider: FeedSourceProvider,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!session.hasSource(provider.sourceId)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await sources(this.db, session.userId).find(provider.sourceId)
|
||||||
|
if (!row?.enabled) return
|
||||||
|
|
||||||
|
const newSource = await provider.feedSourceForUser(session.userId, row.config ?? {})
|
||||||
|
session.replaceSource(provider.sourceId, newSource)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[UserSessionManager] refreshSource("${provider.sourceId}") failed for user ${session.userId}:`,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSession(userId: string): Promise<UserSession> {
|
||||||
|
const enabledRows = await sources(this.db, userId).enabled()
|
||||||
|
|
||||||
|
const promises: Promise<FeedSource>[] = []
|
||||||
|
for (const row of enabledRows) {
|
||||||
|
const provider = this.providers.get(row.sourceId)
|
||||||
|
if (provider) {
|
||||||
|
promises.push(provider.feedSourceForUser(userId, row.config ?? {}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promises.length === 0) {
|
||||||
|
return new UserSession(userId, [], this.feedEnhancer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises)
|
||||||
|
|
||||||
|
const feedSources: FeedSource[] = []
|
||||||
|
const errors: unknown[] = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
feedSources.push(result.value)
|
||||||
|
} else {
|
||||||
|
errors.push(result.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedSources.length === 0 && errors.length > 0) {
|
||||||
|
throw new AggregateError(errors, "All feed source providers failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const error of errors) {
|
||||||
|
console.error("[UserSessionManager] Feed source provider failed:", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserSession(userId, feedSources, this.feedEnhancer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aelis/source-location"
|
import { LocationSource } from "@aelis/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
@@ -25,7 +25,10 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
|
|
||||||
describe("UserSession", () => {
|
describe("UserSession", () => {
|
||||||
test("registers sources and starts engine", async () => {
|
test("registers sources and starts engine", async () => {
|
||||||
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
|
const session = new UserSession("test-user", [
|
||||||
|
createStubSource("test-a"),
|
||||||
|
createStubSource("test-b"),
|
||||||
|
])
|
||||||
|
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ describe("UserSession", () => {
|
|||||||
|
|
||||||
test("getSource returns registered source", () => {
|
test("getSource returns registered source", () => {
|
||||||
const location = new LocationSource()
|
const location = new LocationSource()
|
||||||
const session = new UserSession([location])
|
const session = new UserSession("test-user", [location])
|
||||||
|
|
||||||
const result = session.getSource<LocationSource>("aelis.location")
|
const result = session.getSource<LocationSource>("aelis.location")
|
||||||
|
|
||||||
@@ -42,13 +45,13 @@ describe("UserSession", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("getSource returns undefined for unknown source", () => {
|
test("getSource returns undefined for unknown source", () => {
|
||||||
const session = new UserSession([createStubSource("test")])
|
const session = new UserSession("test-user", [createStubSource("test")])
|
||||||
|
|
||||||
expect(session.getSource("unknown")).toBeUndefined()
|
expect(session.getSource("unknown")).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("destroy stops engine and clears sources", () => {
|
test("destroy stops engine and clears sources", () => {
|
||||||
const session = new UserSession([createStubSource("test")])
|
const session = new UserSession("test-user", [createStubSource("test")])
|
||||||
|
|
||||||
session.destroy()
|
session.destroy()
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ describe("UserSession", () => {
|
|||||||
|
|
||||||
test("engine.executeAction routes to correct source", async () => {
|
test("engine.executeAction routes to correct source", async () => {
|
||||||
const location = new LocationSource()
|
const location = new LocationSource()
|
||||||
const session = new UserSession([location])
|
const session = new UserSession("test-user", [location])
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
@@ -76,12 +79,13 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const session = new UserSession([createStubSource("test", items)])
|
const session = new UserSession("test-user", [createStubSource("test", items)])
|
||||||
|
|
||||||
const result = await session.feed()
|
const result = await session.feed()
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -101,7 +106,7 @@ describe("UserSession.feed", () => {
|
|||||||
const enhancer = async (feedItems: FeedItem[]) =>
|
const enhancer = async (feedItems: FeedItem[]) =>
|
||||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
const result = await session.feed()
|
const result = await session.feed()
|
||||||
|
|
||||||
@@ -113,6 +118,7 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -124,7 +130,7 @@ describe("UserSession.feed", () => {
|
|||||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
const result1 = await session.feed()
|
const result1 = await session.feed()
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||||
@@ -139,6 +145,7 @@ describe("UserSession.feed", () => {
|
|||||||
let currentItems: FeedItem[] = [
|
let currentItems: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { version: 1 },
|
data: { version: 1 },
|
||||||
@@ -158,7 +165,7 @@ describe("UserSession.feed", () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new UserSession([source], enhancer)
|
const session = new UserSession("test-user", [source], enhancer)
|
||||||
|
|
||||||
// First feed triggers refresh + enhancement
|
// First feed triggers refresh + enhancement
|
||||||
const result1 = await session.feed()
|
const result1 = await session.feed()
|
||||||
@@ -169,6 +176,7 @@ describe("UserSession.feed", () => {
|
|||||||
currentItems = [
|
currentItems = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
||||||
data: { version: 2 },
|
data: { version: 2 },
|
||||||
@@ -190,6 +198,7 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -199,7 +208,7 @@ describe("UserSession.feed", () => {
|
|||||||
throw new Error("enhancement exploded")
|
throw new Error("enhancement exploded")
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
const result = await session.feed()
|
const result = await session.feed()
|
||||||
|
|
||||||
@@ -208,3 +217,178 @@ describe("UserSession.feed", () => {
|
|||||||
expect(result.items[0]!.data.value).toBe(42)
|
expect(result.items[0]!.data.value).toBe(42)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("UserSession.replaceSource", () => {
|
||||||
|
test("replaces source and invalidates feed cache", async () => {
|
||||||
|
const itemsA: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "a-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { from: "a" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const itemsB: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "b-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { from: "b" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceA = createStubSource("test", itemsA)
|
||||||
|
const session = new UserSession("test-user", [sourceA])
|
||||||
|
|
||||||
|
const result1 = await session.feed()
|
||||||
|
expect(result1.items).toHaveLength(1)
|
||||||
|
expect(result1.items[0]!.data.from).toBe("a")
|
||||||
|
|
||||||
|
const sourceB = createStubSource("test", itemsB)
|
||||||
|
session.replaceSource("test", sourceB)
|
||||||
|
|
||||||
|
const result2 = await session.feed()
|
||||||
|
expect(result2.items).toHaveLength(1)
|
||||||
|
expect(result2.items[0]!.data.from).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getSource returns new source after replace", () => {
|
||||||
|
const sourceA = createStubSource("test")
|
||||||
|
const session = new UserSession("test-user", [sourceA])
|
||||||
|
|
||||||
|
const sourceB = createStubSource("test")
|
||||||
|
session.replaceSource("test", sourceB)
|
||||||
|
|
||||||
|
expect(session.getSource("test")).toBe(sourceB)
|
||||||
|
expect(session.getSource("test")).not.toBe(sourceA)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws when replacing a source that is not registered", () => {
|
||||||
|
const session = new UserSession("test-user", [createStubSource("test")])
|
||||||
|
|
||||||
|
expect(() => session.replaceSource("nonexistent", createStubSource("other"))).toThrow(
|
||||||
|
'Cannot replace source "nonexistent": not registered',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("other sources are unaffected by replace", async () => {
|
||||||
|
const sourceA = createStubSource("source-a", [
|
||||||
|
{
|
||||||
|
id: "a-1",
|
||||||
|
sourceId: "source-a",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "a" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const sourceB = createStubSource("source-b", [
|
||||||
|
{
|
||||||
|
id: "b-1",
|
||||||
|
sourceId: "source-b",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "b" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const session = new UserSession("test-user", [sourceA, sourceB])
|
||||||
|
|
||||||
|
const replacement = createStubSource("source-a", [
|
||||||
|
{
|
||||||
|
id: "a-2",
|
||||||
|
sourceId: "source-a",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { from: "a-new" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
session.replaceSource("source-a", replacement)
|
||||||
|
|
||||||
|
const result = await session.feed()
|
||||||
|
expect(result.items).toHaveLength(2)
|
||||||
|
|
||||||
|
const ids = result.items.map((i) => i.id).sort()
|
||||||
|
expect(ids).toEqual(["a-2", "b-1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalidates enhancement cache on replace", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let enhanceCount = 0
|
||||||
|
const enhancer = async (feedItems: FeedItem[]) => {
|
||||||
|
enhanceCount++
|
||||||
|
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||||
|
|
||||||
|
await session.feed()
|
||||||
|
expect(enhanceCount).toBe(1)
|
||||||
|
|
||||||
|
const newItems: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-2",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: { version: 2 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
session.replaceSource("test", createStubSource("test", newItems))
|
||||||
|
|
||||||
|
const result = await session.feed()
|
||||||
|
expect(enhanceCount).toBe(2)
|
||||||
|
expect(result.items[0]!.id).toBe("item-2")
|
||||||
|
expect(result.items[0]!.data.enhanced).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("UserSession.removeSource", () => {
|
||||||
|
test("removes source from engine and sources map", () => {
|
||||||
|
const session = new UserSession("test-user", [
|
||||||
|
createStubSource("test-a"),
|
||||||
|
createStubSource("test-b"),
|
||||||
|
])
|
||||||
|
|
||||||
|
session.removeSource("test-a")
|
||||||
|
|
||||||
|
expect(session.getSource("test-a")).toBeUndefined()
|
||||||
|
expect(session.getSource("test-b")).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalidates feed cache on remove", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
sourceId: "test",
|
||||||
|
type: "test",
|
||||||
|
timestamp: new Date(),
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const session = new UserSession("test-user", [createStubSource("test", items)])
|
||||||
|
|
||||||
|
const result1 = await session.feed()
|
||||||
|
expect(result1.items).toHaveLength(1)
|
||||||
|
|
||||||
|
session.removeSource("test")
|
||||||
|
|
||||||
|
const result2 = await session.feed()
|
||||||
|
expect(result2.items).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is a no-op for unknown source", () => {
|
||||||
|
const session = new UserSession("test-user", [createStubSource("test")])
|
||||||
|
|
||||||
|
expect(() => session.removeSource("unknown")).not.toThrow()
|
||||||
|
expect(session.getSource("test")).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@ae
|
|||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
|
|
||||||
export class UserSession {
|
export class UserSession {
|
||||||
|
readonly userId: string
|
||||||
readonly engine: FeedEngine
|
readonly engine: FeedEngine
|
||||||
private sources = new Map<string, FeedSource>()
|
private sources = new Map<string, FeedSource>()
|
||||||
private readonly enhancer: FeedEnhancer | null
|
private readonly enhancer: FeedEnhancer | null
|
||||||
@@ -12,7 +13,8 @@ export class UserSession {
|
|||||||
private enhancingPromise: Promise<void> | null = null
|
private enhancingPromise: Promise<void> | null = null
|
||||||
private unsubscribe: (() => void) | null = null
|
private unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
||||||
|
this.userId = userId
|
||||||
this.engine = new FeedEngine()
|
this.engine = new FeedEngine()
|
||||||
this.enhancer = enhancer ?? null
|
this.enhancer = enhancer ?? null
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
@@ -67,6 +69,89 @@ export class UserSession {
|
|||||||
return this.sources.get(sourceId) as T | undefined
|
return this.sources.get(sourceId) as T | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasSource(sourceId: string): boolean {
|
||||||
|
return this.sources.has(sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new source in the engine and invalidates all caches.
|
||||||
|
* Stops and restarts the engine to establish reactive subscriptions.
|
||||||
|
*/
|
||||||
|
addSource(source: FeedSource): void {
|
||||||
|
if (this.sources.has(source.id)) {
|
||||||
|
throw new Error(`Cannot add source "${source.id}": already registered`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasStarted = this.engine.isStarted()
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.engine.register(source)
|
||||||
|
this.sources.set(source.id, source)
|
||||||
|
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
this.enhancingPromise = null
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a source in the engine and invalidates all caches.
|
||||||
|
* Stops and restarts the engine to re-establish reactive subscriptions.
|
||||||
|
*/
|
||||||
|
replaceSource(oldSourceId: string, newSource: FeedSource): void {
|
||||||
|
if (!this.sources.has(oldSourceId)) {
|
||||||
|
throw new Error(`Cannot replace source "${oldSourceId}": not registered`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasStarted = this.engine.isStarted()
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.engine.unregister(oldSourceId)
|
||||||
|
this.sources.delete(oldSourceId)
|
||||||
|
|
||||||
|
this.engine.register(newSource)
|
||||||
|
this.sources.set(newSource.id, newSource)
|
||||||
|
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
this.enhancingPromise = null
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a source from the engine and invalidates all caches.
|
||||||
|
* Stops and restarts the engine to clean up reactive subscriptions.
|
||||||
|
*/
|
||||||
|
removeSource(sourceId: string): void {
|
||||||
|
if (!this.sources.has(sourceId)) return
|
||||||
|
|
||||||
|
const wasStarted = this.engine.isStarted()
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.engine.unregister(sourceId)
|
||||||
|
this.sources.delete(sourceId)
|
||||||
|
|
||||||
|
this.invalidateEnhancement()
|
||||||
|
this.enhancingPromise = null
|
||||||
|
|
||||||
|
if (wasStarted) {
|
||||||
|
this.engine.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribe?.()
|
this.unsubscribe?.()
|
||||||
this.unsubscribe = null
|
this.unsubscribe = null
|
||||||
|
|||||||
26
apps/aelis-backend/src/sources/errors.ts
Normal file
26
apps/aelis-backend/src/sources/errors.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Thrown when an operation targets a user source that doesn't exist.
|
||||||
|
*/
|
||||||
|
export class SourceNotFoundError extends Error {
|
||||||
|
readonly sourceId: string
|
||||||
|
readonly userId: string
|
||||||
|
|
||||||
|
constructor(sourceId: string, userId: string) {
|
||||||
|
super(`Source "${sourceId}" not found for user "${userId}"`)
|
||||||
|
this.name = "SourceNotFoundError"
|
||||||
|
this.sourceId = sourceId
|
||||||
|
this.userId = userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a source config update fails schema validation.
|
||||||
|
*/
|
||||||
|
export class InvalidSourceConfigError extends Error {
|
||||||
|
readonly sourceId: string
|
||||||
|
|
||||||
|
constructor(sourceId: string, summary: string) {
|
||||||
|
super(summary)
|
||||||
|
this.sourceId = sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
710
apps/aelis-backend/src/sources/http.test.ts
Normal file
710
apps/aelis-backend/src/sources/http.test.ts
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import { UserSessionManager } from "../session/user-session-manager.ts"
|
||||||
|
import { tflConfig } from "../tfl/provider.ts"
|
||||||
|
import { weatherConfig } from "../weather/provider.ts"
|
||||||
|
import { SourceNotFoundError } from "./errors.ts"
|
||||||
|
import { registerSourcesHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStubSource(id: string): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems(): Promise<FeedItem[]> {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubProvider(sourceId: string, configSchema?: ConfigSchema): FeedSourceProvider {
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
configSchema,
|
||||||
|
async feedSourceForUser() {
|
||||||
|
return createStubSource(sourceId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_USER_ID = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
||||||
|
|
||||||
|
type SourceRow = {
|
||||||
|
userId: string
|
||||||
|
sourceId: string
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInMemoryStore() {
|
||||||
|
const rows = new Map<string, SourceRow>()
|
||||||
|
|
||||||
|
function key(userId: string, sourceId: string) {
|
||||||
|
return `${userId}:${sourceId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
seed(userId: string, sourceId: string, data: Partial<SourceRow> = {}) {
|
||||||
|
rows.set(key(userId, sourceId), {
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: data.enabled ?? true,
|
||||||
|
config: data.config ?? {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
forUser(userId: string) {
|
||||||
|
return {
|
||||||
|
async enabled() {
|
||||||
|
return [...rows.values()].filter((r) => r.userId === userId && r.enabled)
|
||||||
|
},
|
||||||
|
async find(sourceId: string) {
|
||||||
|
return rows.get(key(userId, sourceId))
|
||||||
|
},
|
||||||
|
async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
|
||||||
|
const existing = rows.get(key(userId, sourceId))
|
||||||
|
if (!existing) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
if (update.enabled !== undefined) {
|
||||||
|
existing.enabled = update.enabled
|
||||||
|
}
|
||||||
|
if (update.config !== undefined) {
|
||||||
|
existing.config = update.config as Record<string, unknown>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async upsertConfig(sourceId: string, data: { enabled: boolean; config: unknown }) {
|
||||||
|
const existing = rows.get(key(userId, sourceId))
|
||||||
|
if (existing) {
|
||||||
|
existing.enabled = data.enabled
|
||||||
|
existing.config = data.config as Record<string, unknown>
|
||||||
|
} else {
|
||||||
|
rows.set(key(userId, sourceId), {
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: data.enabled,
|
||||||
|
config: (data.config ?? {}) as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeStore: ReturnType<typeof createInMemoryStore>
|
||||||
|
|
||||||
|
mock.module("../sources/user-sources.ts", () => ({
|
||||||
|
sources(_db: unknown, userId: string) {
|
||||||
|
return activeStore.forUser(userId)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fakeDb = {} as Database
|
||||||
|
|
||||||
|
function createApp(providers: FeedSourceProvider[], userId?: string) {
|
||||||
|
const sessionManager = new UserSessionManager({ providers, db: fakeDb })
|
||||||
|
const app = new Hono()
|
||||||
|
registerSourcesHttpHandlers(app, {
|
||||||
|
sessionManager,
|
||||||
|
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||||
|
})
|
||||||
|
return { app, sessionManager }
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(app: Hono, sourceId: string, body: unknown) {
|
||||||
|
return app.request(`/api/sources/${sourceId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(app: Hono, sourceId: string) {
|
||||||
|
return app.request(`/api/sources/${sourceId}`, { method: "GET" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function put(app: Hono, sourceId: string, body: unknown) {
|
||||||
|
return app.request(`/api/sources/${sourceId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("GET /api/sources/:sourceId", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
|
||||||
|
|
||||||
|
const res = await get(app, "aelis.weather")
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 for unknown source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await get(app, "unknown.source")
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns enabled and config for existing source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await get(app, "aelis.weather")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { enabled: boolean; config: unknown }
|
||||||
|
expect(body.enabled).toBe(true)
|
||||||
|
expect(body.config).toEqual({ units: "metric" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns defaults when user has no row for source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await get(app, "aelis.weather")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { enabled: boolean; config: unknown }
|
||||||
|
expect(body.enabled).toBe(false)
|
||||||
|
expect(body.config).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns disabled source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: false,
|
||||||
|
config: { units: "imperial" },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await get(app, "aelis.weather")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { enabled: boolean; config: unknown }
|
||||||
|
expect(body.enabled).toBe(false)
|
||||||
|
expect(body.config).toEqual({ units: "imperial" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("PATCH /api/sources/:sourceId", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", { enabled: true })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 for unknown source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "unknown.source", { enabled: true })
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 when user has no existing row for source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", { enabled: true })
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 when body is empty object (no-op) on existing source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 when body is empty object on nonexistent user source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {})
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON body", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await app.request("/api/sources/aelis.weather", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("Invalid JSON")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when request body contains unknown fields", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
unknownField: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when weather config contains unknown fields", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
config: { units: "metric", unknownField: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when weather config fails validation", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
config: { units: "invalid" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 and updates enabled", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", { enabled: false })
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
|
||||||
|
expect(row!.enabled).toBe(false)
|
||||||
|
expect(row!.config).toEqual({ units: "metric" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 and updates config", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
config: { units: "imperial" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
|
||||||
|
expect(row!.config).toEqual({ units: "imperial" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves config when only updating enabled", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.tfl", {
|
||||||
|
enabled: true,
|
||||||
|
config: { lines: ["bakerloo"] },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.tfl", tflConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.tfl", { enabled: false })
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.tfl`)
|
||||||
|
expect(row!.enabled).toBe(false)
|
||||||
|
expect(row!.config).toEqual({ lines: ["bakerloo"] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deep-merges config on update", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
config: { units: "metric", hourlyLimit: 12 },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
config: { dailyLimit: 5 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
|
||||||
|
expect(row!.config).toEqual({
|
||||||
|
units: "metric",
|
||||||
|
hourlyLimit: 12,
|
||||||
|
dailyLimit: 5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes source in active session after config update", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app, sessionManager } = createApp(
|
||||||
|
[createStubProvider("aelis.weather", weatherConfig)],
|
||||||
|
MOCK_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
|
||||||
|
const replaceSpy = spyOn(session, "replaceSource")
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", {
|
||||||
|
config: { units: "imperial" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(replaceSpy).toHaveBeenCalled()
|
||||||
|
replaceSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes source from session when disabled", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app, sessionManager } = createApp(
|
||||||
|
[createStubProvider("aelis.weather", weatherConfig)],
|
||||||
|
MOCK_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
|
||||||
|
const removeSpy = spyOn(session, "removeSource")
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.weather", { enabled: false })
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith("aelis.weather")
|
||||||
|
removeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when config is provided for source without schema", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.location")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.location", {
|
||||||
|
config: { something: "value" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when empty config is provided for source without schema", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.location")
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.location", {
|
||||||
|
config: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates enabled on location source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true })
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await patch(app, "aelis.location", { enabled: false })
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.location`)
|
||||||
|
expect(row!.enabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/sources/:sourceId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PUT /api/sources/:sourceId", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", { enabled: true, config: {} })
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 404 for unknown source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "unknown.source", { enabled: true, config: {} })
|
||||||
|
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 for invalid JSON", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await app.request("/api/sources/aelis.weather", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
const body = (await res.json()) as { error: string }
|
||||||
|
expect(body.error).toContain("Invalid JSON")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when enabled is missing", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", { config: {} })
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when config is missing", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", { enabled: true })
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when request body contains unknown fields", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
unknownField: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when weather config contains unknown fields", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric", unknownField: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when config fails schema validation", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "invalid" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 and inserts when row does not exist", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
|
||||||
|
expect(row).toBeDefined()
|
||||||
|
expect(row!.enabled).toBe(true)
|
||||||
|
expect(row!.config).toEqual({ units: "metric" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 and fully replaces existing row", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric", hourlyLimit: 12 },
|
||||||
|
})
|
||||||
|
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: false,
|
||||||
|
config: { units: "imperial" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
|
||||||
|
expect(row!.enabled).toBe(false)
|
||||||
|
// hourlyLimit should be gone — full replace, not merge
|
||||||
|
expect(row!.config).toEqual({ units: "imperial" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes source in active session after upsert", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app, sessionManager } = createApp(
|
||||||
|
[createStubProvider("aelis.weather", weatherConfig)],
|
||||||
|
MOCK_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
|
||||||
|
const replaceSpy = spyOn(session, "replaceSource")
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "imperial" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(replaceSpy).toHaveBeenCalled()
|
||||||
|
replaceSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes source from session when disabled via upsert", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
const { app, sessionManager } = createApp(
|
||||||
|
[createStubProvider("aelis.weather", weatherConfig)],
|
||||||
|
MOCK_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
|
||||||
|
const removeSpy = spyOn(session, "removeSource")
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: false,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith("aelis.weather")
|
||||||
|
removeSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds source to active session when inserting a new source", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
// Seed a different source so the session can be created
|
||||||
|
activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true })
|
||||||
|
const { app, sessionManager } = createApp(
|
||||||
|
[createStubProvider("aelis.location"), createStubProvider("aelis.weather", weatherConfig)],
|
||||||
|
MOCK_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create session — only has aelis.location
|
||||||
|
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
|
||||||
|
expect(session.hasSource("aelis.weather")).toBe(false)
|
||||||
|
|
||||||
|
// PUT a new source that didn't exist before
|
||||||
|
const res = await put(app, "aelis.weather", {
|
||||||
|
enabled: true,
|
||||||
|
config: { units: "metric" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(session.hasSource("aelis.weather")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when config is provided for source without schema", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.location", {
|
||||||
|
enabled: true,
|
||||||
|
config: { something: "value" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 400 when empty config is provided for source without schema", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.location", {
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 204 without config field for source without schema", async () => {
|
||||||
|
activeStore = createInMemoryStore()
|
||||||
|
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
|
||||||
|
|
||||||
|
const res = await put(app, "aelis.location", {
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
})
|
||||||
|
})
|
||||||
173
apps/aelis-backend/src/sources/http.ts
Normal file
173
apps/aelis-backend/src/sources/http.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { type } from "arktype"
|
||||||
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
import { InvalidSourceConfigError, SourceNotFoundError } from "./errors.ts"
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourcesHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateSourceConfigRequestBody = type({
|
||||||
|
"+": "reject",
|
||||||
|
"enabled?": "boolean",
|
||||||
|
"config?": "unknown",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ReplaceSourceConfigRequestBody = type({
|
||||||
|
"+": "reject",
|
||||||
|
enabled: "boolean",
|
||||||
|
config: "unknown",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ReplaceSourceConfigNoConfigRequestBody = type({
|
||||||
|
"+": "reject",
|
||||||
|
enabled: "boolean",
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSourcesHttpHandlers(
|
||||||
|
app: Hono,
|
||||||
|
{ sessionManager, authSessionMiddleware }: SourcesHttpHandlersDeps,
|
||||||
|
) {
|
||||||
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
|
c.set("sessionManager", sessionManager)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
|
||||||
|
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
|
||||||
|
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetSource(c: Context<Env>) {
|
||||||
|
const sourceId = c.req.param("sourceId")
|
||||||
|
if (!sourceId) {
|
||||||
|
return c.body(null, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
const user = c.get("user")!
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sessionManager.fetchSourceConfig(user.id, sourceId)
|
||||||
|
return c.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SourceNotFoundError) {
|
||||||
|
return c.json({ error: err.message }, 404)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateSource(c: Context<Env>) {
|
||||||
|
const sourceId = c.req.param("sourceId")
|
||||||
|
if (!sourceId) {
|
||||||
|
return c.body(null, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
|
||||||
|
// Validate source exists as a registered provider
|
||||||
|
const provider = sessionManager.getProvider(sourceId)
|
||||||
|
if (!provider) {
|
||||||
|
return c.json({ error: `Source "${sourceId}" not found` }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await c.req.json()
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UpdateSourceConfigRequestBody(body)
|
||||||
|
if (parsed instanceof type.errors) {
|
||||||
|
return c.json({ error: parsed.summary }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.configSchema && "config" in parsed) {
|
||||||
|
return c.json({ error: `Source "${sourceId}" does not accept config` }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled, config: newConfig } = parsed
|
||||||
|
const user = c.get("user")!
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionManager.updateSourceConfig(user.id, sourceId, {
|
||||||
|
enabled,
|
||||||
|
config: newConfig,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SourceNotFoundError) {
|
||||||
|
return c.json({ error: err.message }, 404)
|
||||||
|
}
|
||||||
|
if (err instanceof InvalidSourceConfigError) {
|
||||||
|
return c.json({ error: err.message }, 400)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReplaceSource(c: Context<Env>) {
|
||||||
|
const sourceId = c.req.param("sourceId")
|
||||||
|
if (!sourceId) {
|
||||||
|
return c.body(null, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
|
||||||
|
const provider = sessionManager.getProvider(sourceId)
|
||||||
|
if (!provider) {
|
||||||
|
return c.json({ error: `Source "${sourceId}" not found` }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await c.req.json()
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid JSON" }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = provider.configSchema
|
||||||
|
? ReplaceSourceConfigRequestBody
|
||||||
|
: ReplaceSourceConfigNoConfigRequestBody
|
||||||
|
const parsed = schema(body)
|
||||||
|
if (parsed instanceof type.errors) {
|
||||||
|
return c.json({ error: parsed.summary }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled } = parsed
|
||||||
|
const config = "config" in parsed ? parsed.config : undefined
|
||||||
|
const user = c.get("user")!
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionManager.upsertSourceConfig(user.id, sourceId, {
|
||||||
|
enabled,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SourceNotFoundError) {
|
||||||
|
return c.json({ error: err.message }, 404)
|
||||||
|
}
|
||||||
|
if (err instanceof InvalidSourceConfigError) {
|
||||||
|
return c.json({ error: err.message }, 400)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204)
|
||||||
|
}
|
||||||
111
apps/aelis-backend/src/sources/user-sources.ts
Normal file
111
apps/aelis-backend/src/sources/user-sources.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
|
import { userSources } from "../db/schema.ts"
|
||||||
|
import { SourceNotFoundError } from "./errors.ts"
|
||||||
|
|
||||||
|
export function sources(db: Database, userId: string) {
|
||||||
|
return {
|
||||||
|
/** Returns all enabled sources for the user. */
|
||||||
|
async enabled() {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(userSources)
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.enabled, true)))
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Returns a specific source by ID, or undefined. */
|
||||||
|
async find(sourceId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(userSources)
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Enables a source for the user. Throws if the source row doesn't exist. */
|
||||||
|
async enableSource(sourceId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.update(userSources)
|
||||||
|
.set({ enabled: true, updatedAt: new Date() })
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
||||||
|
.returning({ id: userSources.id })
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disables a source for the user. Throws if the source row doesn't exist. */
|
||||||
|
async disableSource(sourceId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.update(userSources)
|
||||||
|
.set({ enabled: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
||||||
|
.returning({ id: userSources.id })
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates an existing user source row. Throws if the row doesn't exist. */
|
||||||
|
async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
|
||||||
|
const set: Record<string, unknown> = { updatedAt: new Date() }
|
||||||
|
if (update.enabled !== undefined) {
|
||||||
|
set.enabled = update.enabled
|
||||||
|
}
|
||||||
|
if (update.config !== undefined) {
|
||||||
|
set.config = update.config
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.update(userSources)
|
||||||
|
.set(set)
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
||||||
|
.returning({ id: userSources.id })
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inserts a new user source row or fully replaces enabled/config on an existing one. */
|
||||||
|
async upsertConfig(sourceId: string, data: { enabled: boolean; config: unknown }) {
|
||||||
|
const now = new Date()
|
||||||
|
await db
|
||||||
|
.insert(userSources)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: data.enabled,
|
||||||
|
config: data.config,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userSources.userId, userSources.sourceId],
|
||||||
|
set: {
|
||||||
|
enabled: data.enabled,
|
||||||
|
config: data.config,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Updates the encrypted credentials for a source. Throws if the source row doesn't exist. */
|
||||||
|
async updateCredentials(sourceId: string, credentials: Buffer) {
|
||||||
|
const rows = await db
|
||||||
|
.update(userSources)
|
||||||
|
.set({ credentials, updatedAt: new Date() })
|
||||||
|
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
||||||
|
.returning({ id: userSources.id })
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new SourceNotFoundError(sourceId, userId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TflSource, type ITflApi } from "@aelis/source-tfl"
|
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
|
||||||
|
import { type } from "arktype"
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
@@ -6,14 +7,32 @@ export type TflSourceProviderOptions =
|
|||||||
| { apiKey: string; client?: never }
|
| { apiKey: string; client?: never }
|
||||||
| { apiKey?: never; client: ITflApi }
|
| { apiKey?: never; client: ITflApi }
|
||||||
|
|
||||||
|
export const tflConfig = type({
|
||||||
|
"+": "reject",
|
||||||
|
"lines?": "string[]",
|
||||||
|
})
|
||||||
|
|
||||||
export class TflSourceProvider implements FeedSourceProvider {
|
export class TflSourceProvider implements FeedSourceProvider {
|
||||||
private readonly options: TflSourceProviderOptions
|
readonly sourceId = "aelis.tfl"
|
||||||
|
readonly configSchema = tflConfig
|
||||||
|
private readonly apiKey: string | undefined
|
||||||
|
private readonly client: ITflApi | undefined
|
||||||
|
|
||||||
constructor(options: TflSourceProviderOptions) {
|
constructor(options: TflSourceProviderOptions) {
|
||||||
this.options = options
|
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
||||||
|
this.client = "client" in options ? options.client : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
feedSourceForUser(_userId: string): TflSource {
|
async feedSourceForUser(_userId: string, config: unknown): Promise<TflSource> {
|
||||||
return new TflSource(this.options)
|
const parsed = tflConfig(config)
|
||||||
|
if (parsed instanceof type.errors) {
|
||||||
|
throw new Error(`Invalid TFL config: ${parsed.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TflSource({
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
client: this.client,
|
||||||
|
lines: parsed.lines as TflLineId[] | undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
||||||
|
import { type } from "arktype"
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
export class WeatherSourceProvider implements FeedSourceProvider {
|
export interface WeatherSourceProviderOptions {
|
||||||
private readonly options: WeatherSourceOptions
|
credentials: WeatherSourceOptions["credentials"]
|
||||||
|
client?: WeatherSourceOptions["client"]
|
||||||
|
}
|
||||||
|
|
||||||
constructor(options: WeatherSourceOptions) {
|
export const weatherConfig = type({
|
||||||
this.options = options
|
"+": "reject",
|
||||||
|
"units?": "'metric' | 'imperial'",
|
||||||
|
"hourlyLimit?": "number",
|
||||||
|
"dailyLimit?": "number",
|
||||||
|
})
|
||||||
|
|
||||||
|
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||||
|
readonly sourceId = "aelis.weather"
|
||||||
|
readonly configSchema = weatherConfig
|
||||||
|
private readonly credentials: WeatherSourceOptions["credentials"]
|
||||||
|
private readonly client: WeatherSourceOptions["client"]
|
||||||
|
|
||||||
|
constructor(options: WeatherSourceProviderOptions) {
|
||||||
|
this.credentials = options.credentials
|
||||||
|
this.client = options.client
|
||||||
}
|
}
|
||||||
|
|
||||||
feedSourceForUser(_userId: string): WeatherSource {
|
async feedSourceForUser(_userId: string, config: unknown): Promise<WeatherSource> {
|
||||||
return new WeatherSource(this.options)
|
const parsed = weatherConfig(config)
|
||||||
|
if (parsed instanceof type.errors) {
|
||||||
|
throw new Error(`Invalid weather config: ${parsed.summary}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeatherSource({
|
||||||
|
credentials: this.credentials,
|
||||||
|
client: this.client,
|
||||||
|
units: parsed.units,
|
||||||
|
hourlyLimit: parsed.hourlyLimit,
|
||||||
|
dailyLimit: parsed.dailyLimit,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user