mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-23 10:31:18 +00:00
Compare commits
17 Commits
8eedd1f4fd
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
39ced53900
|
|||
|
c1d9ec9399
|
|||
|
34214f5f3e
|
|||
| 7909211c1b | |||
| 99c097e503 | |||
| a52addebd8 | |||
| 4cef7f2ea1 | |||
| dd2b37938f | |||
| a6be7b31e7 | |||
| b24d879d31 | |||
| 7862a6d367 | |||
| 0095d9cd72 | |||
| ca2664b617 | |||
| 21750582b1 | |||
| 61c1ade631 | |||
| 9ac88d921c | |||
| 0b51b97f6c |
@@ -6,3 +6,14 @@ services:
|
||||
- postDevcontainerStart
|
||||
commands:
|
||||
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)
|
||||
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
|
||||
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": {
|
||||
"dev": "bun run --watch 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": {
|
||||
"@aelis/core": "workspace:*",
|
||||
@@ -18,10 +24,12 @@
|
||||
"@openrouter/sdk": "^0.9.11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4",
|
||||
"pg": "^8"
|
||||
"lodash.merge": "^4.6.2"
|
||||
},
|
||||
"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 { 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))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
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({
|
||||
database: pool,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
import * as schema from "../db/schema.ts"
|
||||
|
||||
export function createAuth(db: Database) {
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is not set")
|
||||
}
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [admin()],
|
||||
})
|
||||
}
|
||||
|
||||
export type Auth = ReturnType<typeof createAuth>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { Auth } from "./index.ts"
|
||||
import type { AuthSession, AuthUser } from "./session.ts"
|
||||
|
||||
import { auth } from "./index.ts"
|
||||
|
||||
export interface SessionVariables {
|
||||
user: AuthUser | null
|
||||
session: AuthSession | null
|
||||
@@ -18,46 +17,50 @@ declare module "hono" {
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that attaches session and user to the context.
|
||||
* Does not reject unauthenticated requests - use requireSession for that.
|
||||
* Creates a middleware that attaches session and user to the context.
|
||||
* Does not reject unauthenticated requests - use createRequireSession for that.
|
||||
*/
|
||||
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
|
||||
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("session", session.session)
|
||||
} else {
|
||||
c.set("user", null)
|
||||
c.set("session", null)
|
||||
await next()
|
||||
}
|
||||
|
||||
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> {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
export function createGetSessionFromHeaders(auth: Auth) {
|
||||
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
}
|
||||
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
role: "admin",
|
||||
banned: false,
|
||||
banReason: null,
|
||||
banExpires: null,
|
||||
}
|
||||
|
||||
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 AuthSession = typeof auth.$Infer.Session.session
|
||||
export type AuthUser = Auth["$Infer"]["Session"]["user"]
|
||||
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),
|
||||
],
|
||||
)
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
|
||||
import { contextKey } from "@aelis/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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"
|
||||
@@ -50,9 +52,45 @@ function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||
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 () => {
|
||||
const manager = new UserSessionManager({ providers: [] })
|
||||
mockEnabledSourceIds = []
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
@@ -71,13 +109,22 @@ describe("GET /api/feed", () => {
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
mockEnabledSourceIds = ["test"]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "test",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("test", items)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
@@ -104,8 +151,17 @@ describe("GET /api/feed", () => {
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
mockEnabledSourceIds = ["test"]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("test", items)],
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "test",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("test", items)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
@@ -136,7 +192,18 @@ describe("GET /api/feed", () => {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
||||
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")
|
||||
@@ -148,6 +215,32 @@ describe("GET /api/feed", () => {
|
||||
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", () => {
|
||||
@@ -158,17 +251,27 @@ describe("GET /api/context", () => {
|
||||
// The mock auth middleware always injects this hardcoded user ID
|
||||
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
||||
|
||||
function buildContextApp(userId?: string) {
|
||||
async function buildContextApp(userId?: string) {
|
||||
mockEnabledSourceIds = ["weather"]
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => createStubSource("weather", [], contextEntries)],
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "weather",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("weather", [], contextEntries)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const app = buildTestApp(manager, userId)
|
||||
const session = manager.getOrCreate(mockUserId)
|
||||
const session = await manager.getOrCreate(mockUserId)
|
||||
return { app, session }
|
||||
}
|
||||
|
||||
test("returns 401 without auth", async () => {
|
||||
const manager = new UserSessionManager({ providers: [] })
|
||||
mockEnabledSourceIds = []
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||
@@ -177,7 +280,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key param is missing", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context")
|
||||
|
||||
@@ -187,7 +290,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is invalid JSON", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=notjson")
|
||||
|
||||
@@ -197,7 +300,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is not an array", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request('/api/context?key="string"')
|
||||
|
||||
@@ -207,7 +310,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key contains invalid element types", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
||||
|
||||
@@ -217,7 +320,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is an empty array", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=[]")
|
||||
|
||||
@@ -227,7 +330,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when match param is invalid", async () => {
|
||||
const { app } = buildContextApp("user-1")
|
||||
const { app } = await buildContextApp("user-1")
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
||||
|
||||
@@ -237,7 +340,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns exact match with match=exact", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
||||
@@ -249,7 +352,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 404 with match=exact when only prefix would match", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
||||
@@ -258,7 +361,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns prefix match with match=prefix", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
||||
@@ -275,7 +378,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("default mode returns exact match when available", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||
@@ -287,7 +390,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("default mode falls back to prefix when no exact match", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]')
|
||||
@@ -303,7 +406,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 404 when neither exact nor prefix matches", async () => {
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["nonexistent"]')
|
||||
|
||||
@@ -33,7 +33,14 @@ export function registerFeedHttpHandlers(
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
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()
|
||||
|
||||
@@ -46,7 +53,7 @@ async function handleGetFeed(c: Context<Env>) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleGetContext(c: Context<Env>) {
|
||||
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)
|
||||
@@ -70,7 +77,15 @@ function handleGetContext(c: Context<Env>) {
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -50,11 +50,13 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
|
||||
schema: enhancementResultJsonSchema,
|
||||
},
|
||||
},
|
||||
reasoning: { effort: "none" },
|
||||
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") {
|
||||
console.warn("[enhancement] LLM returned no content in response")
|
||||
return null
|
||||
|
||||
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 { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
import { requireSession } from "../auth/session-middleware.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
const locationInput = type({
|
||||
@@ -16,16 +15,21 @@ const locationInput = type({
|
||||
timestamp: "string.date.iso",
|
||||
})
|
||||
|
||||
interface LocationHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerLocationHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
{ sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps,
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||
app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation)
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(c: Context<Env>) {
|
||||
@@ -44,7 +48,15 @@ async function handleUpdateLocation(c: Context<Env>) {
|
||||
|
||||
const user = c.get("user")!
|
||||
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", {
|
||||
lat: result.lat,
|
||||
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,24 @@
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAdminHttpHandlers } from "./admin/http.ts"
|
||||
import { createRequireAdmin } from "./auth/admin-middleware.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 { createLlmClient } from "./enhancement/llm-client.ts"
|
||||
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { LocationSourceProvider } from "./location/provider.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
|
||||
function main() {
|
||||
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||
const auth = createAuth(db)
|
||||
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||
const feedEnhancer = openrouterApiKey
|
||||
? createFeedEnhancer({
|
||||
@@ -25,8 +33,9 @@ function main() {
|
||||
}
|
||||
|
||||
const sessionManager = new UserSessionManager({
|
||||
db,
|
||||
providers: [
|
||||
() => new LocationSource(),
|
||||
new LocationSourceProvider(),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
@@ -43,18 +52,23 @@ function main() {
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
|
||||
const authSessionMiddleware = createRequireSession(auth)
|
||||
const adminMiddleware = createRequireAdmin(auth)
|
||||
|
||||
if (!isDev) {
|
||||
registerAuthHandlers(app)
|
||||
}
|
||||
registerAuthHandlers(app, auth)
|
||||
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
import type { type } from "arktype"
|
||||
|
||||
export type ConfigSchema = ReturnType<typeof type>
|
||||
|
||||
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 {
|
||||
FeedSourceProvider,
|
||||
FeedSourceProviderFn,
|
||||
FeedSourceProviderInput,
|
||||
} from "./feed-source-provider.ts"
|
||||
export type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||
export { UserSession } from "./user-session.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 { 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"
|
||||
|
||||
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", () => {
|
||||
test("getOrCreate creates session on first call", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
/** Set which sourceIds are enabled for a specific user. */
|
||||
function setEnabledSourcesForUser(userId: string, sourceIds: string[]) {
|
||||
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.engine).toBeDefined()
|
||||
})
|
||||
|
||||
test("getOrCreate returns same session for same user", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
test("getOrCreate returns same session for same user", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
})
|
||||
|
||||
test("getOrCreate returns different sessions for different users", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
test("getOrCreate returns different sessions for different users", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("each user gets independent source instances", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
test("each user gets independent source instances", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
|
||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||
@@ -50,58 +162,42 @@ describe("UserSessionManager", () => {
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("remove destroys session and allows re-creation", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
test("remove destroys session and allows re-creation", async () => {
|
||||
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")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test("accepts function providers", async () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
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 })
|
||||
test("registers multiple providers", async () => {
|
||||
setEnabledSources(["aelis.location", "aelis.weather"])
|
||||
const manager = new UserSessionManager({
|
||||
providers: [() => new LocationSource(), provider],
|
||||
db: fakeDb,
|
||||
providers: [locationProvider, weatherProvider],
|
||||
})
|
||||
|
||||
const session = 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")
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aelis.location")).toBeDefined()
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
expect(result).toHaveProperty("context")
|
||||
@@ -111,9 +207,10 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
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", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
@@ -126,10 +223,11 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
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 session = manager.getOrCreate("user-1")
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
@@ -146,16 +244,17 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
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 session = manager.getOrCreate("user-1")
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
manager.remove("user-1")
|
||||
|
||||
// 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", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
@@ -167,4 +266,418 @@ describe("UserSessionManager", () => {
|
||||
|
||||
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 { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
|
||||
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"
|
||||
|
||||
export interface UserSessionManagerConfig {
|
||||
providers: FeedSourceProviderInput[]
|
||||
db: Database
|
||||
providers: FeedSourceProvider[]
|
||||
feedEnhancer?: FeedEnhancer | null
|
||||
}
|
||||
|
||||
export class UserSessionManager {
|
||||
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 db: Database
|
||||
|
||||
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.db = config.db
|
||||
}
|
||||
|
||||
getOrCreate(userId: string): UserSession {
|
||||
let session = this.sessions.get(userId)
|
||||
if (!session) {
|
||||
const sources = this.providers.map((p) =>
|
||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||
)
|
||||
session = new UserSession(sources, this.feedEnhancer)
|
||||
this.sessions.set(userId, session)
|
||||
getProvider(sourceId: string): FeedSourceProvider | undefined {
|
||||
return this.providers.get(sourceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's config for a source, or defaults if no row exists.
|
||||
*
|
||||
* @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 {
|
||||
@@ -36,5 +89,217 @@ export class UserSessionManager {
|
||||
session.destroy()
|
||||
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 { 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"
|
||||
|
||||
@@ -25,7 +25,10 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
|
||||
describe("UserSession", () => {
|
||||
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()
|
||||
|
||||
@@ -34,7 +37,7 @@ describe("UserSession", () => {
|
||||
|
||||
test("getSource returns registered source", () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
const session = new UserSession("test-user", [location])
|
||||
|
||||
const result = session.getSource<LocationSource>("aelis.location")
|
||||
|
||||
@@ -42,13 +45,13 @@ describe("UserSession", () => {
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
test("destroy stops engine and clears sources", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
const session = new UserSession("test-user", [createStubSource("test")])
|
||||
|
||||
session.destroy()
|
||||
|
||||
@@ -57,7 +60,7 @@ describe("UserSession", () => {
|
||||
|
||||
test("engine.executeAction routes to correct source", async () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
const session = new UserSession("test-user", [location])
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5,
|
||||
@@ -82,7 +85,7 @@ describe("UserSession.feed", () => {
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const session = new UserSession([createStubSource("test", items)])
|
||||
const session = new UserSession("test-user", [createStubSource("test", items)])
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
@@ -103,7 +106,7 @@ describe("UserSession.feed", () => {
|
||||
const enhancer = async (feedItems: FeedItem[]) =>
|
||||
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()
|
||||
|
||||
@@ -127,7 +130,7 @@ describe("UserSession.feed", () => {
|
||||
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()
|
||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||
@@ -162,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
|
||||
const result1 = await session.feed()
|
||||
@@ -205,7 +208,7 @@ describe("UserSession.feed", () => {
|
||||
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()
|
||||
|
||||
@@ -214,3 +217,178 @@ describe("UserSession.feed", () => {
|
||||
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"
|
||||
|
||||
export class UserSession {
|
||||
readonly userId: string
|
||||
readonly engine: FeedEngine
|
||||
private sources = new Map<string, FeedSource>()
|
||||
private readonly enhancer: FeedEnhancer | null
|
||||
@@ -12,7 +13,8 @@ export class UserSession {
|
||||
private enhancingPromise: Promise<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.enhancer = enhancer ?? null
|
||||
for (const source of sources) {
|
||||
@@ -67,6 +69,89 @@ export class UserSession {
|
||||
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 {
|
||||
this.unsubscribe?.()
|
||||
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"
|
||||
|
||||
@@ -6,14 +7,32 @@ export type TflSourceProviderOptions =
|
||||
| { apiKey: string; client?: never }
|
||||
| { apiKey?: never; client: ITflApi }
|
||||
|
||||
export const tflConfig = type({
|
||||
"+": "reject",
|
||||
"lines?": "string[]",
|
||||
})
|
||||
|
||||
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) {
|
||||
this.options = options
|
||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
||||
this.client = "client" in options ? options.client : undefined
|
||||
}
|
||||
|
||||
feedSourceForUser(_userId: string): TflSource {
|
||||
return new TflSource(this.options)
|
||||
async feedSourceForUser(_userId: string, config: unknown): Promise<TflSource> {
|
||||
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 { type } from "arktype"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||
private readonly options: WeatherSourceOptions
|
||||
export interface WeatherSourceProviderOptions {
|
||||
credentials: WeatherSourceOptions["credentials"]
|
||||
client?: WeatherSourceOptions["client"]
|
||||
}
|
||||
|
||||
constructor(options: WeatherSourceOptions) {
|
||||
this.options = options
|
||||
export const weatherConfig = type({
|
||||
"+": "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 {
|
||||
return new WeatherSource(this.options)
|
||||
async feedSourceForUser(_userId: string, config: unknown): Promise<WeatherSource> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
230
docs/db-persistence-layer-spec.md
Normal file
230
docs/db-persistence-layer-spec.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# DB Persistence Layer Spec
|
||||
|
||||
## Problem Statement
|
||||
|
||||
AELIS currently hardcodes the same set of feed sources for every user. Source configuration (TFL lines, weather units, calendar IDs, etc.) and credentials (OAuth tokens) are not persisted. Users cannot customize which sources appear in their feed or configure source-specific settings.
|
||||
|
||||
The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persistence layer that stores per-user source configuration and credentials, using Drizzle ORM with Bun.sql as the Postgres driver.
|
||||
|
||||
## Requirements
|
||||
|
||||
### 1. Replace `pg` with `Bun.sql`
|
||||
|
||||
- Remove `pg` and `@types/pg` dependencies
|
||||
- Replace `db.ts` with a Drizzle instance backed by `Bun.sql` (`drizzle-orm/bun-sql`)
|
||||
- All DB access goes through Drizzle — no raw Pool usage
|
||||
|
||||
### 2. Migrate Better Auth to Drizzle adapter
|
||||
|
||||
- Use `better-auth/adapters/drizzle` so auth tables are managed through the same Drizzle instance
|
||||
- Define Better Auth tables (user, session, account, verification) in the Drizzle schema
|
||||
- Better Auth's `database` option switches from `Pool` to the Drizzle adapter
|
||||
|
||||
### 3. User source configuration table
|
||||
|
||||
A `user_sources` table stores per-user source state:
|
||||
|
||||
| Column | Type | Description |
|
||||
| ------------ | ------------------------ | ------------------------------------------------------------ |
|
||||
| `id` | `uuid` PK | Row ID |
|
||||
| `user_id` | `text` FK → `user.id` | Owner |
|
||||
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
|
||||
| `enabled` | `boolean` | Whether this source is active in the user's feed |
|
||||
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
|
||||
| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
|
||||
| `created_at` | `timestamp with tz` | Row creation time |
|
||||
| `updated_at` | `timestamp with tz` | Last modification time |
|
||||
|
||||
- Unique constraint on `(user_id, source_id)` — one config row per source per user.
|
||||
- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time.
|
||||
- `credentials` is stored as encrypted bytes. Only OAuth tokens and secrets go here — non-sensitive config stays in `config`.
|
||||
|
||||
### 4. Credential encryption
|
||||
|
||||
- AES-256-GCM encryption for the `credentials` column
|
||||
- Encryption key sourced from an environment variable (`CREDENTIALS_ENCRYPTION_KEY`)
|
||||
- A `crypto` utility module in the backend provides `encrypt(plaintext)` → `Buffer` and `decrypt(ciphertext)` → `string`
|
||||
- IV is generated per-encryption and stored as a prefix to the ciphertext
|
||||
|
||||
### 5. Default sources on signup
|
||||
|
||||
When a new user is created, seed `user_sources` rows for default sources:
|
||||
|
||||
| Source | Default config |
|
||||
| ------------------ | --------------------------------------------------------------- |
|
||||
| `aelis.location` | `{}` |
|
||||
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
|
||||
| `aelis.tfl` | `{ "lines": <all default lines> }` |
|
||||
|
||||
- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup.
|
||||
- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first.
|
||||
|
||||
### 6. Source providers query DB
|
||||
|
||||
`FeedSourceProvider.feedSourceForUser` is already async (returns `Promise<FeedSource>`). `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled`-based graceful degradation — if a provider throws, the source is skipped and the error is logged.
|
||||
|
||||
Each provider receives the Drizzle DB instance and queries `user_sources` internally. If the source is disabled or the row is missing, the provider throws a `SourceDisabledError`. If config validation fails, it throws with a descriptive message. Both cases are handled by `createSession`'s `Promise.allSettled` — the source is excluded from the session and the error is logged.
|
||||
|
||||
```typescript
|
||||
class TflSourceProvider implements FeedSourceProvider {
|
||||
constructor(private db: DrizzleDb, private apiKey: string) {}
|
||||
|
||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
||||
const row = await this.db.select()
|
||||
.from(userSources)
|
||||
.where(and(
|
||||
eq(userSources.userId, userId),
|
||||
eq(userSources.sourceId, "aelis.tfl"),
|
||||
eq(userSources.enabled, true),
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!row[0]) {
|
||||
throw new SourceDisabledError("aelis.tfl", userId)
|
||||
}
|
||||
|
||||
const config = tflSourceConfig(row[0].config ?? {})
|
||||
if (config instanceof type.errors) {
|
||||
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
|
||||
}
|
||||
|
||||
return new TflSource({ ...config, apiKey: this.apiKey })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No interface changes are needed — the existing async `FeedSourceProvider` and `UserSessionManager` signatures are sufficient.
|
||||
|
||||
### 7. Drizzle Kit migrations
|
||||
|
||||
- Use `drizzle-kit` for schema migrations
|
||||
- `drizzle.config.ts` at `apps/aelis-backend/drizzle.config.ts`
|
||||
- Migration files stored in `apps/aelis-backend/drizzle/`
|
||||
- Scripts in `package.json`: `db:generate`, `db:migrate`, `db:studio`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Bun.sql driver**
|
||||
- [ ] `pg` and `@types/pg` are removed from `package.json`
|
||||
- [ ] `db.ts` exports a Drizzle instance using `Bun.sql`
|
||||
- [ ] All existing DB usage (Better Auth) works with the new driver
|
||||
|
||||
2. **Better Auth on Drizzle**
|
||||
- [ ] Better Auth uses `drizzle-adapter` with the shared Drizzle instance
|
||||
- [ ] Auth tables (user, session, account, verification) are defined in the Drizzle schema
|
||||
- [ ] Signup, signin, and session validation work as before
|
||||
|
||||
3. **User sources table**
|
||||
- [ ] `user_sources` table exists with the schema described above
|
||||
- [ ] Unique constraint on `(user_id, source_id)` is enforced
|
||||
- [ ] `config` column accepts arbitrary JSON
|
||||
- [ ] `credentials` column stores encrypted bytes
|
||||
|
||||
4. **Credential encryption**
|
||||
- [ ] Encrypt/decrypt utility works with AES-256-GCM
|
||||
- [ ] IV is unique per encryption
|
||||
- [ ] Missing `CREDENTIALS_ENCRYPTION_KEY` fails fast at startup
|
||||
- [ ] Unit tests cover round-trip encrypt → decrypt
|
||||
|
||||
5. **Default source seeding**
|
||||
- [ ] New user signup creates `user_sources` rows for location, weather, and TFL
|
||||
- [ ] Default config values match the table above
|
||||
- [ ] Sources requiring credentials are not auto-enabled
|
||||
|
||||
6. **Provider DB integration**
|
||||
- [ ] Each provider queries `user_sources` for the user's config and credentials
|
||||
- [ ] Disabled sources (enabled=false or missing row) throw `SourceDisabledError`, excluded via `Promise.allSettled`
|
||||
- [ ] Invalid config logs an error and skips the source (graceful degradation)
|
||||
- [ ] `SourceDisabledError` class is created in `src/session/`
|
||||
|
||||
_Note: `FeedSourceProvider` is already async, `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled` graceful degradation. No interface changes needed._
|
||||
|
||||
7. **Migrations**
|
||||
- [ ] `drizzle.config.ts` is configured
|
||||
- [ ] Initial migration creates all tables (auth + user_sources)
|
||||
- [ ] `bun run db:generate` and `bun run db:migrate` work
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Drizzle + Bun.sql setup
|
||||
|
||||
1. Install `drizzle-orm` and `drizzle-kit`; remove `pg` and `@types/pg`
|
||||
2. Create `src/db/index.ts` — Drizzle instance with `Bun.sql`
|
||||
3. Create `src/db/schema.ts` — Better Auth tables + `user_sources` table
|
||||
4. Create `drizzle.config.ts`
|
||||
5. Add `db:generate`, `db:migrate`, `db:studio` scripts to `package.json`
|
||||
|
||||
### Phase 2: Better Auth migration
|
||||
|
||||
6. Update `src/auth/index.ts` to use `drizzle-adapter` with the Drizzle instance
|
||||
7. Verify signup/signin/session validation still work
|
||||
8. Remove old `src/db.ts` (raw Pool)
|
||||
|
||||
### Phase 3: Credential encryption
|
||||
|
||||
9. Create `src/lib/crypto.ts` with `encrypt` and `decrypt` functions (AES-256-GCM)
|
||||
10. Add `CREDENTIALS_ENCRYPTION_KEY` to `.env.example`
|
||||
11. Write unit tests for encrypt/decrypt round-trip
|
||||
|
||||
### Phase 4: User source config
|
||||
|
||||
12. Create `src/db/user-sources.ts` — query helpers (get sources for user, upsert config, etc.)
|
||||
13. Create `src/session/source-disabled-error.ts` — `SourceDisabledError` class
|
||||
14. Implement default source seeding on user creation
|
||||
15. Update each provider (Weather, TFL, Location) to accept Drizzle DB instance and query `user_sources` for config/credentials
|
||||
|
||||
_`FeedSourceProvider` is already async and `UserSessionManager.getOrCreate` already handles provider failures via `Promise.allSettled`. No interface or caller changes needed._
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
16. Generate and run initial migration
|
||||
17. Run existing tests, fix any breakage
|
||||
18. Manual test: signup → default sources created → feed returns data
|
||||
|
||||
## File Structure (new/modified)
|
||||
|
||||
```
|
||||
apps/aelis-backend/
|
||||
├── drizzle.config.ts # NEW
|
||||
├── drizzle/ # NEW — migration files
|
||||
├── src/
|
||||
│ ├── db.ts # REPLACE — Drizzle + Bun.sql
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts # NEW — all table definitions
|
||||
│ │ └── user-sources.ts # NEW — query helpers
|
||||
│ ├── auth/
|
||||
│ │ └── index.ts # MODIFY — drizzle adapter
|
||||
│ ├── lib/
|
||||
│ │ ├── crypto.ts # NEW — encrypt/decrypt
|
||||
│ │ └── crypto.test.ts # NEW
|
||||
│ ├── session/
|
||||
│ │ └── source-disabled-error.ts # NEW — SourceDisabledError
|
||||
│ ├── weather/
|
||||
│ │ └── provider.ts # MODIFY — query DB
|
||||
│ └── tfl/
|
||||
│ └── provider.ts # MODIFY — query DB
|
||||
```
|
||||
|
||||
_`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `location/http.ts` are already async-ready on master and do not need changes._
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Add:**
|
||||
- `drizzle-orm`
|
||||
- `drizzle-kit` (dev)
|
||||
|
||||
**Remove:**
|
||||
- `pg`
|
||||
- `@types/pg` (dev)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**Add to `.env.example`:**
|
||||
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
|
||||
|
||||
## Open Questions (Deferred)
|
||||
|
||||
- HTTP endpoints for CRUD on user source config (settings UI)
|
||||
- OAuth flow for connecting Google Calendar / CalDAV accounts
|
||||
- Source config validation schemas exported from each source package (currently only TFL has one)
|
||||
- Whether to cache DB-loaded config in the UserSession to avoid repeated queries on reconnect
|
||||
@@ -180,6 +180,31 @@ describe("FeedEngine", () => {
|
||||
|
||||
expect(engine.refresh()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("register invalidates feed cache", async () => {
|
||||
const location = createLocationSource()
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
await engine.refresh()
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.register(createWeatherSource())
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("unregister invalidates feed cache", async () => {
|
||||
const location = createLocationSource()
|
||||
const weather = createWeatherSource()
|
||||
const engine = new FeedEngine().register(location).register(weather)
|
||||
|
||||
await engine.refresh()
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.unregister("weather")
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("graph validation", () => {
|
||||
@@ -934,4 +959,54 @@ describe("FeedEngine", () => {
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalidateCache", () => {
|
||||
test("clears cached result", async () => {
|
||||
const location = createLocationSource()
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
await engine.refresh()
|
||||
expect(engine.lastFeed()).not.toBeNull()
|
||||
|
||||
engine.invalidateCache()
|
||||
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
|
||||
test("is safe to call when no cache exists", () => {
|
||||
const engine = new FeedEngine()
|
||||
|
||||
expect(() => engine.invalidateCache()).not.toThrow()
|
||||
expect(engine.lastFeed()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isStarted", () => {
|
||||
test("returns false before start", () => {
|
||||
const engine = new FeedEngine()
|
||||
|
||||
expect(engine.isStarted()).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true after start", () => {
|
||||
const location = createLocationSource()
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
engine.start()
|
||||
|
||||
expect(engine.isStarted()).toBe(true)
|
||||
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("returns false after stop", () => {
|
||||
const location = createLocationSource()
|
||||
const engine = new FeedEngine().register(location)
|
||||
|
||||
engine.start()
|
||||
engine.stop()
|
||||
|
||||
expect(engine.isStarted()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,23 +97,33 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a FeedSource. Invalidates the cached graph.
|
||||
* Registers a FeedSource. Invalidates the cached graph and feed cache.
|
||||
*/
|
||||
register<TItem extends FeedItem>(source: FeedSource<TItem>): FeedEngine<TItems | TItem> {
|
||||
this.sources.set(source.id, source)
|
||||
this.graph = null
|
||||
this.invalidateCache()
|
||||
return this as FeedEngine<TItems | TItem>
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a FeedSource by ID. Invalidates the cached graph.
|
||||
* Unregisters a FeedSource by ID. Invalidates the cached graph and feed cache.
|
||||
*/
|
||||
unregister(sourceId: string): this {
|
||||
this.sources.delete(sourceId)
|
||||
this.graph = null
|
||||
this.invalidateCache()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached feed result so the next access triggers a fresh refresh.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cachedResult = null
|
||||
this.cachedAt = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a post-processor. Processors run in registration order
|
||||
* after items are collected, on every update path.
|
||||
@@ -249,6 +259,13 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
|
||||
this.cleanups = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the engine is currently running reactive subscriptions.
|
||||
*/
|
||||
isStarted(): boolean {
|
||||
return this.started
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current accumulated context.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user