mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-23 02:21:17 +00:00
Compare commits
1 Commits
feat/admin
...
feat/calda
| Author | SHA1 | Date | |
|---|---|---|---|
| 1129d52906 |
42
.github/workflows/build-waitlist-website.yml
vendored
42
.github/workflows/build-waitlist-website.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Build waitlist website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- apps/waitlist-website/**
|
||||
- .github/workflows/build-waitlist-website.yml
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: cr.nym.sh
|
||||
IMAGE_NAME: aelis-waitlist-website
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: apps/waitlist-website
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -21,4 +21,4 @@ jobs:
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
run: bun test
|
||||
|
||||
@@ -6,14 +6,3 @@ 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
24
apps/admin-dashboard/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "src/index.css",
|
||||
"tailwindFunctions": ["cn", "cva"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"$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": {}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"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 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,25 +0,0 @@
|
||||
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 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/* 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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,76 +0,0 @@
|
||||
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 }
|
||||
@@ -1,49 +0,0 @@
|
||||
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 }
|
||||
@@ -1,65 +0,0 @@
|
||||
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 }
|
||||
@@ -1,100 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,19 +0,0 @@
|
||||
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 }
|
||||
@@ -1,22 +0,0 @@
|
||||
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 }
|
||||
@@ -1,193 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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 }
|
||||
@@ -1,142 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
"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,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 }
|
||||
@@ -1,49 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,33 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,57 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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(/\/+$/, ""))
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
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,
|
||||
]),
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
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"] })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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} />
|
||||
},
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,9 +4,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
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 +0,0 @@
|
||||
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
|
||||
@@ -1,457 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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,13 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/server.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"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"
|
||||
"test": "bun test src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aelis/core": "workspace:*",
|
||||
@@ -24,12 +18,10 @@
|
||||
"@openrouter/sdk": "^0.9.11",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4",
|
||||
"lodash.merge": "^4.6.2"
|
||||
"pg": "^8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"drizzle-kit": "^0.31.9"
|
||||
"@types/pg": "^8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { Auth } from "./index.ts"
|
||||
import type { AuthSessionEnv } from "./session-middleware.ts"
|
||||
|
||||
export type AdminMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||
|
||||
/**
|
||||
* Creates a middleware that requires a valid session with admin role.
|
||||
* Returns 401 if not authenticated, 403 if not admin.
|
||||
*/
|
||||
export function createRequireAdmin(auth: Auth): AdminMiddleware {
|
||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
if (session.user.role !== "admin") {
|
||||
return c.json({ error: "Forbidden" }, 403)
|
||||
}
|
||||
|
||||
c.set("user", session.user)
|
||||
c.set("session", session.session)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Hono } from "hono"
|
||||
|
||||
import type { Auth } from "./index.ts"
|
||||
import { auth } from "./index.ts"
|
||||
|
||||
export function registerAuthHandlers(app: Hono, auth: Auth): void {
|
||||
export function registerAuthHandlers(app: Hono): void {
|
||||
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
||||
}
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { admin } from "better-auth/plugins"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
import { pool } from "../db.ts"
|
||||
|
||||
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>
|
||||
export const auth = betterAuth({
|
||||
database: pool,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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
|
||||
@@ -17,50 +18,46 @@ declare module "hono" {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a middleware that attaches session and user to the context.
|
||||
* Does not reject unauthenticated requests - use createRequireSession for that.
|
||||
* Middleware that attaches session and user to the context.
|
||||
* Does not reject unauthenticated requests - use requireSession for that.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
export async function sessionMiddleware(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)
|
||||
await next()
|
||||
} else {
|
||||
c.set("user", null)
|
||||
c.set("session", null)
|
||||
}
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
|
||||
* Middleware that requires a valid session. Returns 401 if not authenticated.
|
||||
*/
|
||||
export function createGetSessionFromHeaders(auth: Auth) {
|
||||
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,10 +81,6 @@ 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 = Auth["$Infer"]["Session"]["user"]
|
||||
export type AuthSession = Auth["$Infer"]["Session"]["session"]
|
||||
export type AuthUser = typeof auth.$Infer.Session.user
|
||||
export type AuthSession = typeof auth.$Infer.Session.session
|
||||
|
||||
5
apps/aelis-backend/src/db.ts
Normal file
5
apps/aelis-backend/src/db.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Pool } from "pg"
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
})
|
||||
@@ -1,96 +0,0 @@
|
||||
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],
|
||||
}),
|
||||
}))
|
||||
@@ -1,23 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,11 +1,9 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
|
||||
import { contextKey } from "@aelis/core"
|
||||
import { describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { describe, expect, 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"
|
||||
@@ -52,45 +50,9 @@ 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 () => {
|
||||
mockEnabledSourceIds = []
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||
const manager = new UserSessionManager({ providers: [] })
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
@@ -102,29 +64,19 @@ describe("GET /api/feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
priority: 0.8,
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
mockEnabledSourceIds = ["test"]
|
||||
const manager = new UserSessionManager({
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "test",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("test", items)
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [() => createStubSource("test", items)],
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
@@ -144,24 +96,14 @@ describe("GET /api/feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "fresh-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
mockEnabledSourceIds = ["test"]
|
||||
const manager = new UserSessionManager({
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "test",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("test", items)
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [() => createStubSource("test", items)],
|
||||
})
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
@@ -192,18 +134,7 @@ describe("GET /api/feed", () => {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
mockEnabledSourceIds = ["failing"]
|
||||
const manager = new UserSessionManager({
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "failing",
|
||||
async feedSourceForUser() {
|
||||
return failingSource
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
@@ -215,32 +146,6 @@ 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", () => {
|
||||
@@ -251,27 +156,17 @@ describe("GET /api/context", () => {
|
||||
// The mock auth middleware always injects this hardcoded user ID
|
||||
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
||||
|
||||
async function buildContextApp(userId?: string) {
|
||||
mockEnabledSourceIds = ["weather"]
|
||||
function buildContextApp(userId?: string) {
|
||||
const manager = new UserSessionManager({
|
||||
db: fakeDb,
|
||||
providers: [
|
||||
{
|
||||
sourceId: "weather",
|
||||
async feedSourceForUser() {
|
||||
return createStubSource("weather", [], contextEntries)
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [() => createStubSource("weather", [], contextEntries)],
|
||||
})
|
||||
const app = buildTestApp(manager, userId)
|
||||
const session = await manager.getOrCreate(mockUserId)
|
||||
const session = manager.getOrCreate(mockUserId)
|
||||
return { app, session }
|
||||
}
|
||||
|
||||
test("returns 401 without auth", async () => {
|
||||
mockEnabledSourceIds = []
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
||||
const manager = new UserSessionManager({ providers: [] })
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||
@@ -280,7 +175,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key param is missing", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context")
|
||||
|
||||
@@ -290,7 +185,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is invalid JSON", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=notjson")
|
||||
|
||||
@@ -300,7 +195,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is not an array", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request('/api/context?key="string"')
|
||||
|
||||
@@ -310,7 +205,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key contains invalid element types", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
||||
|
||||
@@ -320,7 +215,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when key is an empty array", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request("/api/context?key=[]")
|
||||
|
||||
@@ -330,7 +225,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 400 when match param is invalid", async () => {
|
||||
const { app } = await buildContextApp("user-1")
|
||||
const { app } = buildContextApp("user-1")
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
||||
|
||||
@@ -340,7 +235,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns exact match with match=exact", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
||||
@@ -352,7 +247,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 404 with match=exact when only prefix would match", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
||||
@@ -361,7 +256,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns prefix match with match=prefix", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
||||
@@ -378,7 +273,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("default mode returns exact match when available", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
||||
@@ -390,7 +285,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("default mode falls back to prefix when no exact match", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["aelis.weather"]')
|
||||
@@ -406,7 +301,7 @@ describe("GET /api/context", () => {
|
||||
})
|
||||
|
||||
test("returns 404 when neither exact nor prefix matches", async () => {
|
||||
const { app, session } = await buildContextApp("user-1")
|
||||
const { app, session } = buildContextApp("user-1")
|
||||
await session.engine.refresh()
|
||||
|
||||
const res = await app.request('/api/context?key=["nonexistent"]')
|
||||
|
||||
@@ -33,14 +33,7 @@ export function registerFeedHttpHandlers(
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
|
||||
let session
|
||||
try {
|
||||
session = await sessionManager.getOrCreate(user.id)
|
||||
} catch (err) {
|
||||
console.error("[handleGetFeed] Failed to create session:", err)
|
||||
return c.json({ error: "Service unavailable" }, 503)
|
||||
}
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
const feed = await session.feed()
|
||||
|
||||
@@ -53,7 +46,7 @@ async function handleGetFeed(c: Context<Env>) {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleGetContext(c: Context<Env>) {
|
||||
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)
|
||||
@@ -77,15 +70,7 @@ async function handleGetContext(c: Context<Env>) {
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
|
||||
let session
|
||||
try {
|
||||
session = await sessionManager.getOrCreate(user.id)
|
||||
} catch (err) {
|
||||
console.error("[handleGetContext] Failed to create session:", err)
|
||||
return c.json({ error: "Service unavailable" }, 503)
|
||||
}
|
||||
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
const context = session.engine.currentContext()
|
||||
const key = contextKey(...parsed)
|
||||
|
||||
|
||||
@@ -50,13 +50,11 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
|
||||
schema: enhancementResultJsonSchema,
|
||||
},
|
||||
},
|
||||
reasoning: { effort: "none" },
|
||||
stream: false,
|
||||
},
|
||||
})
|
||||
|
||||
const message = response.choices?.[0]?.message
|
||||
const content = message?.content ?? message?.reasoning
|
||||
const content = response.choices?.[0]?.message?.content
|
||||
if (typeof content !== "string") {
|
||||
console.warn("[enhancement] LLM returned no content in response")
|
||||
return null
|
||||
|
||||
@@ -9,7 +9,6 @@ import { mergeEnhancement } from "./merge.ts"
|
||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||
return {
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
data: { value: 42 },
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { FeedItem } from "@aelis/core"
|
||||
|
||||
import type { EnhancementResult } from "./schema.ts"
|
||||
|
||||
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
||||
|
||||
/**
|
||||
* Merges an EnhancementResult into feed items.
|
||||
*
|
||||
@@ -12,11 +10,7 @@ const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
||||
* - Returns a new array (no mutation)
|
||||
* - Ignores fills for items/slots that don't exist
|
||||
*/
|
||||
export function mergeEnhancement(
|
||||
items: FeedItem[],
|
||||
result: EnhancementResult,
|
||||
currentTime: Date,
|
||||
): FeedItem[] {
|
||||
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
|
||||
const merged = items.map((item) => {
|
||||
const fills = result.slotFills[item.id]
|
||||
if (!fills || !item.slots) return item
|
||||
@@ -37,7 +31,6 @@ export function mergeEnhancement(
|
||||
for (const synthetic of result.syntheticItems) {
|
||||
merged.push({
|
||||
id: synthetic.id,
|
||||
sourceId: ENHANCEMENT_SOURCE_ID,
|
||||
type: synthetic.type,
|
||||
timestamp: currentTime,
|
||||
data: { text: synthetic.text },
|
||||
|
||||
@@ -7,7 +7,6 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||
return {
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||
data: { value: 42 },
|
||||
@@ -61,9 +60,7 @@ describe("buildPrompt", () => {
|
||||
|
||||
expect(parsed.items).toHaveLength(1)
|
||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
|
||||
insight: "Weather insight",
|
||||
})
|
||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
|
||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
||||
expect(parsed.context).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
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,9 +3,10 @@ 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({
|
||||
@@ -15,21 +16,16 @@ const locationInput = type({
|
||||
timestamp: "string.date.iso",
|
||||
})
|
||||
|
||||
interface LocationHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerLocationHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation)
|
||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(c: Context<Env>) {
|
||||
@@ -48,15 +44,7 @@ async function handleUpdateLocation(c: Context<Env>) {
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
|
||||
let session
|
||||
try {
|
||||
session = await sessionManager.getOrCreate(user.id)
|
||||
} catch (err) {
|
||||
console.error("[handleUpdateLocation] Failed to create session:", err)
|
||||
return c.json({ error: "Service unavailable" }, 503)
|
||||
}
|
||||
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: result.lat,
|
||||
lng: result.lng,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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,24 +1,16 @@
|
||||
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 { 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 { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.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({
|
||||
@@ -33,9 +25,8 @@ function main() {
|
||||
}
|
||||
|
||||
const sessionManager = new UserSessionManager({
|
||||
db,
|
||||
providers: [
|
||||
new LocationSourceProvider(),
|
||||
() => new LocationSource(),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
@@ -52,23 +43,18 @@ function main() {
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
const authSessionMiddleware = createRequireSession(auth)
|
||||
const adminMiddleware = createRequireAdmin(auth)
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
|
||||
|
||||
registerAuthHandlers(app, auth)
|
||||
if (!isDev) {
|
||||
registerAuthHandlers(app)
|
||||
}
|
||||
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware,
|
||||
})
|
||||
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
||||
registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
||||
registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db })
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await closeDb()
|
||||
process.exit(0)
|
||||
})
|
||||
registerLocationHttpHandlers(app, { sessionManager })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { FeedSource } from "@aelis/core"
|
||||
import type { type } from "arktype"
|
||||
|
||||
export type ConfigSchema = ReturnType<typeof type>
|
||||
|
||||
export interface FeedSourceProvider {
|
||||
/** 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>
|
||||
feedSourceForUser(userId: string): FeedSource
|
||||
}
|
||||
|
||||
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||
|
||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||
export type {
|
||||
FeedSourceProvider,
|
||||
FeedSourceProviderFn,
|
||||
FeedSourceProviderInput,
|
||||
} from "./feed-source-provider.ts"
|
||||
export { UserSession } from "./user-session.ts"
|
||||
export { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
@@ -1,160 +1,48 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
|
||||
|
||||
import { LocationSource } from "@aelis/source-location"
|
||||
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 { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||
import { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
/**
|
||||
* 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)
|
||||
const mockWeatherClient: WeatherKitClient = {
|
||||
fetch: async () => ({}) as WeatherKitResponse,
|
||||
}
|
||||
|
||||
/** Set which sourceIds are enabled for a specific user. */
|
||||
function setEnabledSourcesForUser(userId: string, sourceIds: string[]) {
|
||||
enabledByUser.set(userId, sourceIds)
|
||||
}
|
||||
|
||||
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] })
|
||||
test("getOrCreate creates session on first call", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.engine).toBeDefined()
|
||||
})
|
||||
|
||||
test("getOrCreate returns same session for same user", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
test("getOrCreate returns same session for same user", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
})
|
||||
|
||||
test("getOrCreate returns different sessions for different users", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
test("getOrCreate returns different sessions for different users", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("each user gets independent source instances", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
test("each user gets independent source instances", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session2 = await manager.getOrCreate("user-2")
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||
@@ -162,42 +50,58 @@ describe("UserSessionManager", () => {
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("remove destroys session and allows re-creation", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
test("remove destroys session and allows re-creation", () => {
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session1 = await manager.getOrCreate("user-1")
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
manager.remove("user-1")
|
||||
const session2 = await manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("remove is no-op for unknown user", () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
expect(() => manager.remove("unknown")).not.toThrow()
|
||||
})
|
||||
|
||||
test("registers multiple providers", async () => {
|
||||
setEnabledSources(["aelis.location", "aelis.weather"])
|
||||
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 })
|
||||
const manager = new UserSessionManager({
|
||||
db: fakeDb,
|
||||
providers: [locationProvider, weatherProvider],
|
||||
providers: [() => new LocationSource(), provider],
|
||||
})
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
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")
|
||||
|
||||
expect(session.getSource("aelis.location")).toBeDefined()
|
||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("refresh returns feed result through session", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result).toHaveProperty("context")
|
||||
@@ -207,10 +111,9 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
test("location update via executeAction works", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
@@ -223,11 +126,10 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
test("subscribe receives updates after location push", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
const callback = mock()
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
@@ -244,17 +146,16 @@ describe("UserSessionManager", () => {
|
||||
})
|
||||
|
||||
test("remove stops reactive updates", async () => {
|
||||
setEnabledSources(["aelis.location"])
|
||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
||||
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||
const callback = mock()
|
||||
|
||||
const session = await manager.getOrCreate("user-1")
|
||||
const session = 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 = await manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
@@ -266,418 +167,4 @@ 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,86 +1,33 @@
|
||||
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 type { FeedSourceProviderInput } 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 {
|
||||
db: Database
|
||||
providers: FeedSourceProvider[]
|
||||
providers: FeedSourceProviderInput[]
|
||||
feedEnhancer?: FeedEnhancer | null
|
||||
}
|
||||
|
||||
export class UserSessionManager {
|
||||
private sessions = new Map<string, UserSession>()
|
||||
private pending = new Map<string, Promise<UserSession>>()
|
||||
private readonly db: Database
|
||||
private readonly providers = new Map<string, FeedSourceProvider>()
|
||||
private readonly providers: FeedSourceProviderInput[]
|
||||
private readonly feedEnhancer: FeedEnhancer | null
|
||||
private readonly db: Database
|
||||
|
||||
constructor(config: UserSessionManagerConfig) {
|
||||
this.db = config.db
|
||||
for (const provider of config.providers) {
|
||||
this.providers.set(provider.sourceId, provider)
|
||||
}
|
||||
this.providers = config.providers
|
||||
this.feedEnhancer = config.feedEnhancer ?? null
|
||||
this.db = config.db
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
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)
|
||||
return session
|
||||
} finally {
|
||||
this.pending.delete(userId)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
remove(userId: string): void {
|
||||
@@ -89,217 +36,5 @@ 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, spyOn, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
@@ -25,10 +25,7 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
|
||||
describe("UserSession", () => {
|
||||
test("registers sources and starts engine", async () => {
|
||||
const session = new UserSession("test-user", [
|
||||
createStubSource("test-a"),
|
||||
createStubSource("test-b"),
|
||||
])
|
||||
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
|
||||
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
@@ -37,7 +34,7 @@ describe("UserSession", () => {
|
||||
|
||||
test("getSource returns registered source", () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession("test-user", [location])
|
||||
const session = new UserSession([location])
|
||||
|
||||
const result = session.getSource<LocationSource>("aelis.location")
|
||||
|
||||
@@ -45,13 +42,13 @@ describe("UserSession", () => {
|
||||
})
|
||||
|
||||
test("getSource returns undefined for unknown source", () => {
|
||||
const session = new UserSession("test-user", [createStubSource("test")])
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
expect(session.getSource("unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("destroy stops engine and clears sources", () => {
|
||||
const session = new UserSession("test-user", [createStubSource("test")])
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
session.destroy()
|
||||
|
||||
@@ -60,7 +57,7 @@ describe("UserSession", () => {
|
||||
|
||||
test("engine.executeAction routes to correct source", async () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession("test-user", [location])
|
||||
const session = new UserSession([location])
|
||||
|
||||
await session.engine.executeAction("aelis.location", "update-location", {
|
||||
lat: 51.5,
|
||||
@@ -79,13 +76,12 @@ describe("UserSession.feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const session = new UserSession("test-user", [createStubSource("test", items)])
|
||||
const session = new UserSession([createStubSource("test", items)])
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
@@ -97,7 +93,6 @@ describe("UserSession.feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
@@ -106,7 +101,7 @@ describe("UserSession.feed", () => {
|
||||
const enhancer = async (feedItems: FeedItem[]) =>
|
||||
feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||
|
||||
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
@@ -118,7 +113,6 @@ describe("UserSession.feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
@@ -130,7 +124,7 @@ describe("UserSession.feed", () => {
|
||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
||||
}
|
||||
|
||||
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result1 = await session.feed()
|
||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
||||
@@ -145,7 +139,6 @@ describe("UserSession.feed", () => {
|
||||
let currentItems: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { version: 1 },
|
||||
@@ -165,7 +158,7 @@ describe("UserSession.feed", () => {
|
||||
}))
|
||||
}
|
||||
|
||||
const session = new UserSession("test-user", [source], enhancer)
|
||||
const session = new UserSession([source], enhancer)
|
||||
|
||||
// First feed triggers refresh + enhancement
|
||||
const result1 = await session.feed()
|
||||
@@ -176,7 +169,6 @@ describe("UserSession.feed", () => {
|
||||
currentItems = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
||||
data: { version: 2 },
|
||||
@@ -198,7 +190,6 @@ describe("UserSession.feed", () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
sourceId: "test",
|
||||
type: "test",
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
@@ -208,7 +199,7 @@ describe("UserSession.feed", () => {
|
||||
throw new Error("enhancement exploded")
|
||||
}
|
||||
|
||||
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
||||
const session = new UserSession([createStubSource("test", items)], enhancer)
|
||||
|
||||
const result = await session.feed()
|
||||
|
||||
@@ -217,178 +208,3 @@ 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,7 +3,6 @@ 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
|
||||
@@ -13,8 +12,7 @@ export class UserSession {
|
||||
private enhancingPromise: Promise<void> | null = null
|
||||
private unsubscribe: (() => void) | null = null
|
||||
|
||||
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
||||
this.userId = userId
|
||||
constructor(sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
||||
this.engine = new FeedEngine()
|
||||
this.enhancer = enhancer ?? null
|
||||
for (const source of sources) {
|
||||
@@ -69,89 +67,6 @@ 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
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -1,710 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
|
||||
import { type } from "arktype"
|
||||
import { TflSource, type ITflApi } from "@aelis/source-tfl"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
@@ -7,32 +6,14 @@ export type TflSourceProviderOptions =
|
||||
| { apiKey: string; client?: never }
|
||||
| { apiKey?: never; client: ITflApi }
|
||||
|
||||
export const tflConfig = type({
|
||||
"+": "reject",
|
||||
"lines?": "string[]",
|
||||
})
|
||||
|
||||
export class TflSourceProvider implements FeedSourceProvider {
|
||||
readonly sourceId = "aelis.tfl"
|
||||
readonly configSchema = tflConfig
|
||||
private readonly apiKey: string | undefined
|
||||
private readonly client: ITflApi | undefined
|
||||
private readonly options: TflSourceProviderOptions
|
||||
|
||||
constructor(options: TflSourceProviderOptions) {
|
||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
||||
this.client = "client" in options ? options.client : undefined
|
||||
this.options = 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,
|
||||
})
|
||||
feedSourceForUser(_userId: string): TflSource {
|
||||
return new TflSource(this.options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,15 @@
|
||||
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export interface WeatherSourceProviderOptions {
|
||||
credentials: WeatherSourceOptions["credentials"]
|
||||
client?: WeatherSourceOptions["client"]
|
||||
}
|
||||
|
||||
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"]
|
||||
private readonly options: WeatherSourceOptions
|
||||
|
||||
constructor(options: WeatherSourceProviderOptions) {
|
||||
this.credentials = options.credentials
|
||||
this.client = options.client
|
||||
constructor(options: WeatherSourceOptions) {
|
||||
this.options = 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,
|
||||
})
|
||||
feedSourceForUser(_userId: string): WeatherSource {
|
||||
return new WeatherSource(this.options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ApiRequestMiddleware } from "./client"
|
||||
|
||||
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
|
||||
// TODO: placeholder auth middleware
|
||||
return init
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user