mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-23 02:21:17 +00:00
Compare commits
2 Commits
feat/admin
...
feat/calda
| Author | SHA1 | Date | |
|---|---|---|---|
|
2717ec1b30
|
|||
|
a4baef521f
|
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
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun run test
|
run: bun test
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
expo:
|
expo:
|
||||||
name: Expo Dev Server
|
name: Expo Dev Server
|
||||||
description: Expo development server for aelis-client
|
description: Expo development server for aris-client
|
||||||
triggeredBy:
|
triggeredBy:
|
||||||
- postDevcontainerStart
|
- postDevcontainerStart
|
||||||
commands:
|
commands:
|
||||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
start: cd apps/aris-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
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# aelis
|
# aris
|
||||||
|
|
||||||
To install dependencies:
|
To install dependencies:
|
||||||
|
|
||||||
@@ -8,14 +8,14 @@ bun install
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### @aelis/source-tfl
|
### @aris/source-tfl
|
||||||
|
|
||||||
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/aelis-source-tfl
|
cd packages/aris-source-tfl
|
||||||
bun run test
|
bun run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aelis/backend",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/server.ts",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aelis/core": "workspace:*",
|
|
||||||
"@aelis/source-caldav": "workspace:*",
|
|
||||||
"@aelis/source-google-calendar": "workspace:*",
|
|
||||||
"@aelis/source-location": "workspace:*",
|
|
||||||
"@aelis/source-tfl": "workspace:*",
|
|
||||||
"@aelis/source-weatherkit": "workspace:*",
|
|
||||||
"@openrouter/sdk": "^0.9.11",
|
|
||||||
"arktype": "^2.1.29",
|
|
||||||
"better-auth": "^1",
|
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"hono": "^4",
|
|
||||||
"lodash.merge": "^4.6.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/lodash.merge": "^4.6.9",
|
|
||||||
"drizzle-kit": "^0.31.9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,26 +0,0 @@
|
|||||||
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 * as schema from "../db/schema.ts"
|
|
||||||
|
|
||||||
export function createAuth(db: Database) {
|
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
|
||||||
throw new Error("BETTER_AUTH_SECRET is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
return betterAuth({
|
|
||||||
database: drizzleAdapter(db, {
|
|
||||||
provider: "pg",
|
|
||||||
schema,
|
|
||||||
}),
|
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
plugins: [admin()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Auth = ReturnType<typeof createAuth>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
|
||||||
|
|
||||||
import type { Auth } from "./index.ts"
|
|
||||||
import type { AuthSession, AuthUser } from "./session.ts"
|
|
||||||
|
|
||||||
export interface SessionVariables {
|
|
||||||
user: AuthUser | null
|
|
||||||
session: AuthSession | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
|
||||||
|
|
||||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
|
||||||
|
|
||||||
declare module "hono" {
|
|
||||||
interface ContextVariableMap extends SessionVariables {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a middleware that attaches session and user to the context.
|
|
||||||
* Does not reject unauthenticated requests - use createRequireSession 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("user", session.user)
|
|
||||||
c.set("session", session.session)
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
|
|
||||||
*/
|
|
||||||
export function createGetSessionFromHeaders(auth: Auth) {
|
|
||||||
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
|
||||||
const session = await auth.api.getSession({ headers })
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dev/test middleware that injects a fake user and session.
|
|
||||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
|
||||||
*/
|
|
||||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
|
||||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
||||||
if (!userId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
const user: AuthUser = {
|
|
||||||
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
|
||||||
name: "Dev User",
|
|
||||||
email: "dev@aelis.local",
|
|
||||||
emailVerified: true,
|
|
||||||
image: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
role: "admin",
|
|
||||||
banned: false,
|
|
||||||
banReason: null,
|
|
||||||
banExpires: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: AuthSession = {
|
|
||||||
id: "Wt3FvBpXaQrMhD8sKjE6LcYn0gUz5iRo",
|
|
||||||
userId: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
|
||||||
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
|
|
||||||
expiresAt,
|
|
||||||
ipAddress: "127.0.0.1",
|
|
||||||
userAgent: "aelis-dev",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("user", user)
|
|
||||||
c.set("session", session)
|
|
||||||
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import type { Auth } from "./index.ts"
|
|
||||||
|
|
||||||
export type AuthUser = Auth["$Infer"]["Session"]["user"]
|
|
||||||
export type AuthSession = Auth["$Infer"]["Session"]["session"]
|
|
||||||
@@ -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,418 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { contextKey } from "@aelis/core"
|
|
||||||
import { describe, expect, mock, spyOn, test } from "bun:test"
|
|
||||||
import { Hono } from "hono"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
|
|
||||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import { UserSessionManager } from "../session/index.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./http.ts"
|
|
||||||
|
|
||||||
interface FeedResponse {
|
|
||||||
items: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
priority: number
|
|
||||||
timestamp: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
errors: Array<{ sourceId: string; error: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStubSource(
|
|
||||||
id: string,
|
|
||||||
items: FeedItem[] = [],
|
|
||||||
contextEntries: readonly ContextEntry[] | null = null,
|
|
||||||
): FeedSource {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction(): Promise<unknown> {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
|
||||||
return contextEntries
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
|
||||||
})
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
let mockEnabledSourceIds: string[] = []
|
|
||||||
|
|
||||||
mock.module("../sources/user-sources.ts", () => ({
|
|
||||||
sources: (_db: Database, _userId: string) => ({
|
|
||||||
async enabled() {
|
|
||||||
const now = new Date()
|
|
||||||
return mockEnabledSourceIds.map((sourceId) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId: _userId,
|
|
||||||
sourceId,
|
|
||||||
enabled: true,
|
|
||||||
config: {},
|
|
||||||
credentials: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
async find(sourceId: string) {
|
|
||||||
const now = new Date()
|
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId: _userId,
|
|
||||||
sourceId,
|
|
||||||
enabled: true,
|
|
||||||
config: {},
|
|
||||||
credentials: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const fakeDb = {} as Database
|
|
||||||
|
|
||||||
describe("GET /api/feed", () => {
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
mockEnabledSourceIds = []
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns cached feed when available", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.8,
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
mockEnabledSourceIds = ["test"]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
sourceId: "test",
|
|
||||||
async feedSourceForUser() {
|
|
||||||
return createStubSource("test", items)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// Prime the cache
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
expect(session.engine.lastFeed()).not.toBeNull()
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("item-1")
|
|
||||||
expect(body.items[0]!.type).toBe("test")
|
|
||||||
expect(body.items[0]!.priority).toBe(0.8)
|
|
||||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forces refresh when no cached feed", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "fresh-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
|
||||||
data: { fresh: true },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
mockEnabledSourceIds = ["test"]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
sourceId: "test",
|
|
||||||
async feedSourceForUser() {
|
|
||||||
return createStubSource("test", items)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("fresh-1")
|
|
||||||
expect(body.items[0]!.data.fresh).toBe(true)
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes source errors as message strings", async () => {
|
|
||||||
const failingSource: FeedSource = {
|
|
||||||
id: "failing",
|
|
||||||
async listActions() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction() {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
throw new Error("connection timeout")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
mockEnabledSourceIds = ["failing"]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
sourceId: "failing",
|
|
||||||
async feedSourceForUser() {
|
|
||||||
return failingSource
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(0)
|
|
||||||
expect(body.errors).toHaveLength(1)
|
|
||||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
|
||||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 503 when all providers fail", async () => {
|
|
||||||
mockEnabledSourceIds = ["test"]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
sourceId: "test",
|
|
||||||
async feedSourceForUser() {
|
|
||||||
throw new Error("provider down")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const spy = spyOn(console, "error").mockImplementation(() => {})
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(503)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toBe("Service unavailable")
|
|
||||||
|
|
||||||
spy.mockRestore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("GET /api/context", () => {
|
|
||||||
const weatherKey = contextKey("aelis.weather", "weather")
|
|
||||||
const weatherData = { temperature: 20, condition: "Clear" }
|
|
||||||
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
|
|
||||||
|
|
||||||
// The mock auth middleware always injects this hardcoded user ID
|
|
||||||
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
|
||||||
|
|
||||||
async function buildContextApp(userId?: string) {
|
|
||||||
mockEnabledSourceIds = ["weather"]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
sourceId: "weather",
|
|
||||||
async feedSourceForUser() {
|
|
||||||
return createStubSource("weather", [], contextEntries)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, userId)
|
|
||||||
const session = await manager.getOrCreate(mockUserId)
|
|
||||||
return { app, session }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
mockEnabledSourceIds = []
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key param is missing", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is invalid JSON", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=notjson")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is not an array", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key="string"')
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key contains invalid element types", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is an empty array", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=[]")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when match param is invalid", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("match")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns exact match with match=exact", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { match: string; value: unknown }
|
|
||||||
expect(body.match).toBe("exact")
|
|
||||||
expect(body.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 404 with match=exact when only prefix would match", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns prefix match with match=prefix", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
match: string
|
|
||||||
entries: Array<{ key: unknown[]; value: unknown }>
|
|
||||||
}
|
|
||||||
expect(body.match).toBe("prefix")
|
|
||||||
expect(body.entries).toHaveLength(1)
|
|
||||||
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
|
|
||||||
expect(body.entries[0]!.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("default mode returns exact match when available", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { match: string; value: unknown }
|
|
||||||
expect(body.match).toBe("exact")
|
|
||||||
expect(body.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("default mode falls back to prefix when no exact match", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
match: string
|
|
||||||
entries: Array<{ key: unknown[]; value: unknown }>
|
|
||||||
}
|
|
||||||
expect(body.match).toBe("prefix")
|
|
||||||
expect(body.entries).toHaveLength(1)
|
|
||||||
expect(body.entries[0]!.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 404 when neither exact nor prefix matches", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["nonexistent"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toBe("Context key not found")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import type { Context, Hono } from "hono"
|
|
||||||
|
|
||||||
import { contextKey } from "@aelis/core"
|
|
||||||
import { createMiddleware } from "hono/factory"
|
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
|
||||||
|
|
||||||
type Env = {
|
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerFeedHttpHandlers(
|
|
||||||
app: Hono,
|
|
||||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
|
||||||
) {
|
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
|
||||||
c.set("sessionManager", sessionManager)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
|
||||||
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetFeed(c: Context<Env>) {
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await sessionManager.getOrCreate(user.id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[handleGetFeed] Failed to create session:", err)
|
|
||||||
return c.json({ error: "Service unavailable" }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
const feed = await session.feed()
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
items: feed.items,
|
|
||||||
errors: feed.errors.map((e) => ({
|
|
||||||
sourceId: e.sourceId,
|
|
||||||
error: e.error.message,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetContext(c: Context<Env>) {
|
|
||||||
const keyParam = c.req.query("key")
|
|
||||||
if (!keyParam) {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(keyParam)
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchParam = c.req.query("match")
|
|
||||||
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
|
|
||||||
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await sessionManager.getOrCreate(user.id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[handleGetContext] Failed to create session:", err)
|
|
||||||
return c.json({ error: "Service unavailable" }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = session.engine.currentContext()
|
|
||||||
const key = contextKey(...parsed)
|
|
||||||
|
|
||||||
if (matchParam === "exact") {
|
|
||||||
const value = context.get(key)
|
|
||||||
if (value === undefined) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
return c.json({ match: "exact", value })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchParam === "prefix") {
|
|
||||||
const entries = context.find(key)
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
return c.json({ match: "prefix", entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: single find() covers both exact and prefix matches
|
|
||||||
const entries = context.find(key)
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If exactly one result with the same key length, treat as exact match
|
|
||||||
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
|
|
||||||
return c.json({ match: "exact", value: entries[0]!.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ match: "prefix", entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
|
|
||||||
function isContextKeyPart(value: unknown): boolean {
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
||||||
return Object.values(value).every(
|
|
||||||
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { LlmClient } from "./llm-client.ts"
|
|
||||||
|
|
||||||
import { mergeEnhancement } from "./merge.ts"
|
|
||||||
import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|
||||||
|
|
||||||
/** Takes feed items, returns enhanced feed items. */
|
|
||||||
export type FeedEnhancer = (items: FeedItem[]) => Promise<FeedItem[]>
|
|
||||||
|
|
||||||
export interface FeedEnhancerConfig {
|
|
||||||
client: LlmClient
|
|
||||||
/** Defaults to Date.now — override for testing */
|
|
||||||
clock?: () => Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a FeedEnhancer that uses the provided LlmClient.
|
|
||||||
*
|
|
||||||
* Skips the LLM call when no items have unfilled slots.
|
|
||||||
* Returns items unchanged on LLM failure.
|
|
||||||
*/
|
|
||||||
export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
|
|
||||||
const { client } = config
|
|
||||||
const clock = config.clock ?? (() => new Date())
|
|
||||||
|
|
||||||
return async function enhanceFeed(items) {
|
|
||||||
if (!hasUnfilledSlots(items)) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = clock()
|
|
||||||
const { systemPrompt, userMessage } = buildPrompt(items, currentTime)
|
|
||||||
|
|
||||||
let result
|
|
||||||
try {
|
|
||||||
result = await client.enhance({ systemPrompt, userMessage })
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] LLM call failed:", err)
|
|
||||||
result = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeEnhancement(items, result, currentTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { OpenRouter } from "@openrouter/sdk"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
import { enhancementResultJsonSchema, parseEnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "openai/gpt-4.1-mini"
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
export interface LlmClientConfig {
|
|
||||||
apiKey: string
|
|
||||||
model?: string
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClientRequest {
|
|
||||||
systemPrompt: string
|
|
||||||
userMessage: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LlmClient {
|
|
||||||
enhance(request: LlmClientRequest): Promise<EnhancementResult | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reusable LLM client backed by OpenRouter.
|
|
||||||
* The OpenRouter SDK instance is created once and reused across calls.
|
|
||||||
*/
|
|
||||||
export function createLlmClient(config: LlmClientConfig): LlmClient {
|
|
||||||
const client = new OpenRouter({
|
|
||||||
apiKey: config.apiKey,
|
|
||||||
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
const model = config.model ?? DEFAULT_MODEL
|
|
||||||
|
|
||||||
return {
|
|
||||||
async enhance(request) {
|
|
||||||
const response = await client.chat.send({
|
|
||||||
chatGenerationParams: {
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
{ role: "system" as const, content: request.systemPrompt },
|
|
||||||
{ role: "user" as const, content: request.userMessage },
|
|
||||||
],
|
|
||||||
responseFormat: {
|
|
||||||
type: "json_schema" as const,
|
|
||||||
jsonSchema: {
|
|
||||||
name: "enhancement_result",
|
|
||||||
strict: true,
|
|
||||||
schema: enhancementResultJsonSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reasoning: { effort: "none" },
|
|
||||||
stream: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const message = response.choices?.[0]?.message
|
|
||||||
const content = message?.content ?? message?.reasoning
|
|
||||||
if (typeof content !== "string") {
|
|
||||||
console.warn("[enhancement] LLM returned no content in response")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(content)
|
|
||||||
if (!result) {
|
|
||||||
console.warn("[enhancement] Failed to parse LLM response:", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
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 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date("2025-06-01T12:00:00Z")
|
|
||||||
|
|
||||||
describe("mergeEnhancement", () => {
|
|
||||||
test("fills matching slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {
|
|
||||||
"item-1": { insight: "Rain after 3pm" },
|
|
||||||
},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBe("Rain after 3pm")
|
|
||||||
// Description preserved
|
|
||||||
expect(merged[0]!.slots!.insight!.description).toBe("Weather insight")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not mutate original items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(item.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent items", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "non-existent": { insight: "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores fills for non-existent slots", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { "non-existent-slot": "text" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("skips null fills", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: { "item-1": { insight: null } },
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]!.slots!.insight!.content).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes through items without slots unchanged", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("appends synthetic items with backfilled fields", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(2)
|
|
||||||
expect(merged[1]!.id).toBe("briefing-morning")
|
|
||||||
expect(merged[1]!.type).toBe("briefing")
|
|
||||||
expect(merged[1]!.timestamp).toEqual(now)
|
|
||||||
expect(merged[1]!.data).toEqual({ text: "Light afternoon ahead." })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles empty enhancement result", () => {
|
|
||||||
const item = makeItem()
|
|
||||||
const result: EnhancementResult = {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = mergeEnhancement([item], result, now)
|
|
||||||
|
|
||||||
expect(merged).toHaveLength(1)
|
|
||||||
expect(merged[0]).toBe(item)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
|
||||||
|
|
||||||
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges an EnhancementResult into feed items.
|
|
||||||
*
|
|
||||||
* - Writes slot content from slotFills into matching items
|
|
||||||
* - Appends synthetic items to the list
|
|
||||||
* - 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[] {
|
|
||||||
const merged = items.map((item) => {
|
|
||||||
const fills = result.slotFills[item.id]
|
|
||||||
if (!fills || !item.slots) return item
|
|
||||||
|
|
||||||
const mergedSlots = { ...item.slots }
|
|
||||||
let changed = false
|
|
||||||
|
|
||||||
for (const [slotName, content] of Object.entries(fills)) {
|
|
||||||
if (slotName in mergedSlots && content !== null) {
|
|
||||||
mergedSlots[slotName] = { ...mergedSlots[slotName]!, content }
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? { ...item, slots: mergedSlots } : item
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const synthetic of result.syntheticItems) {
|
|
||||||
merged.push({
|
|
||||||
id: synthetic.id,
|
|
||||||
sourceId: ENHANCEMENT_SOURCE_ID,
|
|
||||||
type: synthetic.type,
|
|
||||||
timestamp: currentTime,
|
|
||||||
data: { text: synthetic.text },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
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 },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUserMessage(userMessage: string): Record<string, unknown> {
|
|
||||||
return JSON.parse(userMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("hasUnfilledSlots", () => {
|
|
||||||
test("returns false for items without slots", () => {
|
|
||||||
expect(hasUnfilledSlots([makeItem()])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for items with all slots filled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: "filled" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns true when at least one slot is unfilled", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(hasUnfilledSlots([item])).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false for empty array", () => {
|
|
||||||
expect(hasUnfilledSlots([])).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("buildPrompt", () => {
|
|
||||||
test("puts items with unfilled slots in items", () => {
|
|
||||||
const item = makeItem({
|
|
||||||
slots: {
|
|
||||||
insight: { description: "Weather insight", content: null },
|
|
||||||
filled: { description: "Already done", content: "done" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([item], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
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]!.type).toBeUndefined()
|
|
||||||
expect(parsed.context).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("puts slotless items in context", () => {
|
|
||||||
const withSlots = makeItem({
|
|
||||||
id: "with-slots",
|
|
||||||
slots: { insight: { description: "test", content: null } },
|
|
||||||
})
|
|
||||||
const withoutSlots = makeItem({ id: "no-slots" })
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([withSlots, withoutSlots], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("with-slots")
|
|
||||||
expect(parsed.context).toHaveLength(1)
|
|
||||||
expect((parsed.context as Array<Record<string, unknown>>)[0]!.id).toBe("no-slots")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes time in ISO format", () => {
|
|
||||||
const { userMessage } = buildPrompt([], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
const parsed = parseUserMessage(userMessage)
|
|
||||||
|
|
||||||
expect(parsed.time).toBe("2025-06-01T12:00:00.000Z")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("system prompt is non-empty", () => {
|
|
||||||
const { systemPrompt } = buildPrompt([], new Date())
|
|
||||||
expect(systemPrompt.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes schedule in system prompt", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Team standup",
|
|
||||||
startDate: "2025-06-01T10:00:00Z",
|
|
||||||
endDate: "2025-06-01T10:30:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: null,
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
insight: { description: "test", content: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Schedule:\n")
|
|
||||||
expect(systemPrompt).toContain("Team standup")
|
|
||||||
expect(systemPrompt).toContain("10:00")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes location in schedule", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Therapy",
|
|
||||||
startDate: "2025-06-02T18:00:00Z",
|
|
||||||
endDate: "2025-06-02T19:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "92 Tooley Street, London",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Therapy @ 92 Tooley Street, London")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes week calendar but omits schedule when no calendar items", () => {
|
|
||||||
const weatherItem = makeItem({
|
|
||||||
type: "weather-current",
|
|
||||||
data: { temperature: 14 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const { systemPrompt } = buildPrompt([weatherItem], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(systemPrompt).toContain("Week:")
|
|
||||||
expect(systemPrompt).not.toContain("Schedule:")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("user message is pure JSON", () => {
|
|
||||||
const calEvent = makeItem({
|
|
||||||
id: "cal-1",
|
|
||||||
type: "caldav-event",
|
|
||||||
data: {
|
|
||||||
title: "Budget Review",
|
|
||||||
startTime: "2025-06-01T14:00:00Z",
|
|
||||||
endTime: "2025-06-01T15:00:00Z",
|
|
||||||
isAllDay: false,
|
|
||||||
location: "https://meet.google.com/abc",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { userMessage } = buildPrompt([calEvent], new Date("2025-06-01T12:00:00Z"))
|
|
||||||
|
|
||||||
expect(userMessage.startsWith("{")).toBe(true)
|
|
||||||
expect(() => JSON.parse(userMessage)).not.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
|
||||||
|
|
||||||
import { CalDavFeedItemType } from "@aelis/source-caldav"
|
|
||||||
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
|
|
||||||
|
|
||||||
import systemPromptBase from "./prompts/system.txt"
|
|
||||||
|
|
||||||
const CALENDAR_ITEM_TYPES = new Set<string>([
|
|
||||||
CalDavFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.Event,
|
|
||||||
CalendarFeedItemType.AllDay,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the system prompt and user message for the enhancement harness.
|
|
||||||
*
|
|
||||||
* Includes a pre-computed mini calendar so the LLM doesn't have to
|
|
||||||
* parse timestamps to understand the user's schedule.
|
|
||||||
*/
|
|
||||||
export function buildPrompt(
|
|
||||||
items: FeedItem[],
|
|
||||||
currentTime: Date,
|
|
||||||
): { systemPrompt: string; userMessage: string } {
|
|
||||||
const schedule = buildSchedule(items, currentTime)
|
|
||||||
|
|
||||||
const enhanceItems: Array<{
|
|
||||||
id: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
slots: Record<string, string>
|
|
||||||
}> = []
|
|
||||||
const contextItems: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const hasUnfilledSlots =
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null)
|
|
||||||
|
|
||||||
if (hasUnfilledSlots) {
|
|
||||||
enhanceItems.push({
|
|
||||||
id: item.id,
|
|
||||||
data: item.data,
|
|
||||||
slots: Object.fromEntries(
|
|
||||||
Object.entries(item.slots!)
|
|
||||||
.filter(([, slot]) => slot.content === null)
|
|
||||||
.map(([name, slot]) => [name, slot.description]),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
contextItems.push({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
data: item.data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage = JSON.stringify({
|
|
||||||
time: currentTime.toISOString(),
|
|
||||||
items: enhanceItems,
|
|
||||||
context: contextItems,
|
|
||||||
})
|
|
||||||
|
|
||||||
const weekCalendar = buildWeekCalendar(currentTime)
|
|
||||||
let systemPrompt = systemPromptBase
|
|
||||||
systemPrompt += `\n\nWeek:\n${weekCalendar}`
|
|
||||||
if (schedule) {
|
|
||||||
systemPrompt += `\n\nSchedule:\n${schedule}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return { systemPrompt, userMessage }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if any item has at least one unfilled slot.
|
|
||||||
*/
|
|
||||||
export function hasUnfilledSlots(items: FeedItem[]): boolean {
|
|
||||||
return items.some(
|
|
||||||
(item) =>
|
|
||||||
item.slots &&
|
|
||||||
Object.values(item.slots).some((slot) => slot.content === null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Helpers --
|
|
||||||
|
|
||||||
interface CalendarEntry {
|
|
||||||
date: Date
|
|
||||||
title: string
|
|
||||||
location: string | null
|
|
||||||
isAllDay: boolean
|
|
||||||
startTime: Date
|
|
||||||
endTime: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
function toValidDate(value: unknown): Date | null {
|
|
||||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
const date = new Date(value)
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
|
|
||||||
if (!CALENDAR_ITEM_TYPES.has(item.type)) return null
|
|
||||||
|
|
||||||
const d = item.data
|
|
||||||
const title = d.title
|
|
||||||
if (typeof title !== "string" || !title) return null
|
|
||||||
|
|
||||||
// CalDAV uses startDate/endDate, Google Calendar uses startTime/endTime
|
|
||||||
const startTime = toValidDate(d.startDate ?? d.startTime)
|
|
||||||
if (!startTime) return null
|
|
||||||
|
|
||||||
const endTime = toValidDate(d.endDate ?? d.endTime) ?? startTime
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: startTime,
|
|
||||||
title,
|
|
||||||
location: typeof d.location === "string" ? d.location : null,
|
|
||||||
isAllDay: typeof d.isAllDay === "boolean" ? d.isAllDay : false,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
|
||||||
return n.toString().padStart(2, "0")
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayShort(date: Date): string {
|
|
||||||
return `${DAYS[date.getUTCDay()]}, ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayLabel(date: Date, currentTime: Date): string {
|
|
||||||
const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
|
|
||||||
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
|
||||||
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
const dayName = formatDayShort(date)
|
|
||||||
|
|
||||||
if (diffDays === 0) return `Today: ${dayName}`
|
|
||||||
if (diffDays === 1) return `Tomorrow: ${dayName}`
|
|
||||||
return dayName
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a week overview mapping day names to dates,
|
|
||||||
* so the LLM can easily match ISO timestamps to days.
|
|
||||||
*/
|
|
||||||
function buildWeekCalendar(currentTime: Date): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(currentTime)
|
|
||||||
date.setUTCDate(date.getUTCDate() + i)
|
|
||||||
const label = i === 0 ? "Today" : i === 1 ? "Tomorrow" : ""
|
|
||||||
const dayStr = formatDayShort(date)
|
|
||||||
const iso = date.toISOString().slice(0, 10)
|
|
||||||
const prefix = label ? `${label}: ` : ""
|
|
||||||
lines.push(`${prefix}${dayStr} = ${iso}`)
|
|
||||||
}
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a compact text calendar from all calendar-type items.
|
|
||||||
* Groups events by day relative to currentTime.
|
|
||||||
*/
|
|
||||||
function buildSchedule(items: FeedItem[], currentTime: Date): string {
|
|
||||||
const entries: CalendarEntry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const entry = extractCalendarEntry(item)
|
|
||||||
if (entry) entries.push(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.length === 0) return ""
|
|
||||||
|
|
||||||
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
|
||||||
|
|
||||||
const byDay = new Map<string, CalendarEntry[]>()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = entry.date.toISOString().slice(0, 10)
|
|
||||||
const group = byDay.get(key)
|
|
||||||
if (group) {
|
|
||||||
group.push(entry)
|
|
||||||
} else {
|
|
||||||
byDay.set(key, [entry])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = []
|
|
||||||
for (const [, dayEntries] of byDay) {
|
|
||||||
lines.push(formatDayLabel(dayEntries[0]!.startTime, currentTime))
|
|
||||||
for (const entry of dayEntries) {
|
|
||||||
if (entry.isAllDay) {
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` all day ${entry.title}${loc}`)
|
|
||||||
} else {
|
|
||||||
const timeRange = `${formatTime(entry.startTime)}–${formatTime(entry.endTime)}`
|
|
||||||
const loc = entry.location ? ` @ ${entry.location}` : ""
|
|
||||||
lines.push(` ${timeRange} ${entry.title}${loc}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
|
|
||||||
|
|
||||||
The user message is a JSON object with:
|
|
||||||
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
|
|
||||||
- "context": other feed items (no slots) for cross-source reasoning.
|
|
||||||
- "time": current ISO timestamp.
|
|
||||||
|
|
||||||
Your output has two fields:
|
|
||||||
- "slotFills": map of item ID → slot name → short text (or null if you can't fill it or cannot provide answer). Each item ID appears ONCE with ALL its slots in a single object.
|
|
||||||
- "syntheticItems": array of { id, type, text } for new items (briefings, nudges, insights). Only when genuinely useful and when not redundant.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- DO NOT USE EMDASH OR DASH OR ATTEMPT TO USE SYMBOLS TO CIRCUMVENT THIS RULE.
|
|
||||||
- One sentence per slot. Two max if absolutely necessary. Be direct.
|
|
||||||
- Say "I" not "we."
|
|
||||||
- Hedge when inferring. Don't state guesses as facts.
|
|
||||||
- Use the week and schedule below to understand when events happen. Match weather data to the correct date.
|
|
||||||
- Look for connections across items.
|
|
||||||
- Don't pad — return null for slots you can't meaningfully fill, and skip synthetic items if there's nothing useful to add.
|
|
||||||
- Never fabricate information not present in the feed. If you don't have data to support a fill, return null.
|
|
||||||
- Read each slot's description carefully — it defines when to return null.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import {
|
|
||||||
emptyEnhancementResult,
|
|
||||||
enhancementResultJsonSchema,
|
|
||||||
parseEnhancementResult,
|
|
||||||
} from "./schema.ts"
|
|
||||||
|
|
||||||
describe("parseEnhancementResult", () => {
|
|
||||||
test("parses valid result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": {
|
|
||||||
insight: "Rain after 3pm",
|
|
||||||
"cross-source": null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{
|
|
||||||
id: "briefing-morning",
|
|
||||||
type: "briefing",
|
|
||||||
text: "Light afternoon ahead.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(result!.slotFills["weather-1"]!.insight).toBe("Rain after 3pm")
|
|
||||||
expect(result!.slotFills["weather-1"]!["cross-source"]).toBeNull()
|
|
||||||
expect(result!.syntheticItems).toHaveLength(1)
|
|
||||||
expect(result!.syntheticItems[0]!.id).toBe("briefing-morning")
|
|
||||||
expect(result!.syntheticItems[0]!.text).toBe("Light afternoon ahead.")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses empty result", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = parseEnhancementResult(input)
|
|
||||||
|
|
||||||
expect(result).not.toBeNull()
|
|
||||||
expect(Object.keys(result!.slotFills)).toHaveLength(0)
|
|
||||||
expect(result!.syntheticItems).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for invalid JSON", () => {
|
|
||||||
expect(parseEnhancementResult("not json")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null for non-object", () => {
|
|
||||||
expect(parseEnhancementResult('"hello"')).toBeNull()
|
|
||||||
expect(parseEnhancementResult("42")).toBeNull()
|
|
||||||
expect(parseEnhancementResult("null")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills is missing", () => {
|
|
||||||
const input = JSON.stringify({ syntheticItems: [] })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItems is missing", () => {
|
|
||||||
const input = JSON.stringify({ slotFills: {} })
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when slotFills has non-string values", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: { "item-1": { slot: 42 } },
|
|
||||||
syntheticItems: [],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns null when syntheticItem is missing required fields", () => {
|
|
||||||
const input = JSON.stringify({
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "x" }],
|
|
||||||
})
|
|
||||||
expect(parseEnhancementResult(input)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("emptyEnhancementResult", () => {
|
|
||||||
test("returns empty slotFills and syntheticItems", () => {
|
|
||||||
const result = emptyEnhancementResult()
|
|
||||||
expect(result.slotFills).toEqual({})
|
|
||||||
expect(result.syntheticItems).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("schema sync", () => {
|
|
||||||
const referencePayloads = [
|
|
||||||
{
|
|
||||||
name: "full payload with null slot fill",
|
|
||||||
payload: {
|
|
||||||
slotFills: {
|
|
||||||
"weather-1": { insight: "Rain after 3pm", crossSource: null },
|
|
||||||
"cal-2": { summary: "Busy morning" },
|
|
||||||
},
|
|
||||||
syntheticItems: [
|
|
||||||
{ id: "briefing-morning", type: "briefing", text: "Light day ahead." },
|
|
||||||
{ id: "nudge-umbrella", type: "nudge", text: "Bring an umbrella." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty collections",
|
|
||||||
payload: { slotFills: {}, syntheticItems: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "slot fills only",
|
|
||||||
payload: {
|
|
||||||
slotFills: { "item-1": { slot: "filled" } },
|
|
||||||
syntheticItems: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "synthetic items only",
|
|
||||||
payload: {
|
|
||||||
slotFills: {},
|
|
||||||
syntheticItems: [{ id: "insight-1", type: "insight", text: "Something." }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const { name, payload } of referencePayloads) {
|
|
||||||
test(`arktype and JSON Schema agree on: ${name}`, () => {
|
|
||||||
// arktype accepts it
|
|
||||||
const parsed = parseEnhancementResult(JSON.stringify(payload))
|
|
||||||
expect(parsed).not.toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema structure matches
|
|
||||||
const jsonSchema = enhancementResultJsonSchema
|
|
||||||
expect(Object.keys(jsonSchema.properties).sort()).toEqual(
|
|
||||||
Object.keys(payload).sort(),
|
|
||||||
)
|
|
||||||
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
|
|
||||||
|
|
||||||
// syntheticItems item schema has the right required fields
|
|
||||||
const itemSchema = jsonSchema.properties.syntheticItems.items
|
|
||||||
expect([...itemSchema.required].sort()).toEqual(["id", "text", "type"])
|
|
||||||
|
|
||||||
// Verify each synthetic item has exactly the fields the JSON Schema expects
|
|
||||||
for (const item of payload.syntheticItems) {
|
|
||||||
expect(Object.keys(item).sort()).toEqual([...itemSchema.required].sort())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: missing required field", () => {
|
|
||||||
// Missing syntheticItems
|
|
||||||
expect(parseEnhancementResult(JSON.stringify({ slotFills: {} }))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema also requires it
|
|
||||||
expect(enhancementResultJsonSchema.required).toContain("syntheticItems")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("JSON Schema rejects what arktype rejects: wrong slot fill value type", () => {
|
|
||||||
const bad = { slotFills: { "item-1": { slot: 42 } }, syntheticItems: [] }
|
|
||||||
|
|
||||||
// arktype rejects it
|
|
||||||
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
|
|
||||||
|
|
||||||
// JSON Schema only allows string or null for slot values
|
|
||||||
const slotValueTypes =
|
|
||||||
enhancementResultJsonSchema.properties.slotFills.additionalProperties
|
|
||||||
.additionalProperties.type
|
|
||||||
expect(slotValueTypes).toContain("string")
|
|
||||||
expect(slotValueTypes).toContain("null")
|
|
||||||
expect(slotValueTypes).not.toContain("number")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
const SyntheticItem = type({
|
|
||||||
id: "string",
|
|
||||||
type: "string",
|
|
||||||
text: "string",
|
|
||||||
})
|
|
||||||
|
|
||||||
const EnhancementResult = type({
|
|
||||||
slotFills: "Record<string, Record<string, string | null>>",
|
|
||||||
syntheticItems: SyntheticItem.array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SyntheticItem = typeof SyntheticItem.infer
|
|
||||||
export type EnhancementResult = typeof EnhancementResult.infer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON Schema passed to OpenRouter's structured output.
|
|
||||||
* OpenRouter doesn't support arktype, so this is maintained separately.
|
|
||||||
*
|
|
||||||
* ⚠️ Must stay in sync with EnhancementResult above.
|
|
||||||
* If you add/remove fields, update both schemas.
|
|
||||||
*/
|
|
||||||
export const enhancementResultJsonSchema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
slotFills: {
|
|
||||||
type: "object",
|
|
||||||
description:
|
|
||||||
"Map of feed item ID to an object of slot name to filled text content. Use null for slots that cannot be meaningfully filled.",
|
|
||||||
additionalProperties: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: {
|
|
||||||
type: ["string", "null"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
syntheticItems: {
|
|
||||||
type: "array",
|
|
||||||
description:
|
|
||||||
"New feed items to inject (briefings, nudges, cross-source insights). Keep these short and actionable.",
|
|
||||||
items: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
id: {
|
|
||||||
type: "string",
|
|
||||||
description: "Unique ID, e.g. 'briefing-morning'",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: "string",
|
|
||||||
description: "One of: 'briefing', 'nudge', 'insight'",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: "string",
|
|
||||||
description: "Display text, 1-3 sentences",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["id", "type", "text"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["slotFills", "syntheticItems"],
|
|
||||||
additionalProperties: false,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a JSON string into an EnhancementResult.
|
|
||||||
* Returns null if the input is malformed.
|
|
||||||
*/
|
|
||||||
export function parseEnhancementResult(json: string): EnhancementResult | null {
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(json)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = EnhancementResult(parsed)
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyEnhancementResult(): EnhancementResult {
|
|
||||||
return { slotFills: {}, syntheticItems: [] }
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,81 +0,0 @@
|
|||||||
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 { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
|
||||||
import { createLlmClient } from "./enhancement/llm-client.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({
|
|
||||||
client: createLlmClient({
|
|
||||||
apiKey: openrouterApiKey,
|
|
||||||
model: process.env.OPENROUTER_MODEL || undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
if (!feedEnhancer) {
|
|
||||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
|
||||||
db,
|
|
||||||
providers: [
|
|
||||||
new LocationSourceProvider(),
|
|
||||||
new WeatherSourceProvider({
|
|
||||||
credentials: {
|
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
|
||||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
|
||||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
|
||||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
feedEnhancer,
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = new Hono()
|
|
||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
|
||||||
|
|
||||||
const authSessionMiddleware = createRequireSession(auth)
|
|
||||||
const adminMiddleware = createRequireAdmin(auth)
|
|
||||||
|
|
||||||
registerAuthHandlers(app, auth)
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = main()
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port: 3000,
|
|
||||||
fetch: app.fetch,
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export type { FeedSourceProvider } from "./feed-source-provider.ts"
|
|
||||||
export { UserSession } from "./user-session.ts"
|
|
||||||
export { UserSessionManager } from "./user-session-manager.ts"
|
|
||||||
@@ -1,683 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
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 { 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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] })
|
|
||||||
|
|
||||||
const session = await 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] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session1).toBe(session2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("each user gets independent source instances", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
|
||||||
|
|
||||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
|
||||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
|
||||||
|
|
||||||
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] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
manager.remove("user-1")
|
|
||||||
const session2 = await 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] })
|
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("registers multiple providers", async () => {
|
|
||||||
setEnabledSources(["aelis.location", "aelis.weather"])
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
db: fakeDb,
|
|
||||||
providers: [locationProvider, weatherProvider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session.getSource("aelis.location")).toBeDefined()
|
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
const result = await session.engine.refresh()
|
|
||||||
|
|
||||||
expect(result).toHaveProperty("context")
|
|
||||||
expect(result).toHaveProperty("items")
|
|
||||||
expect(result).toHaveProperty("errors")
|
|
||||||
expect(result.context.time).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const source = session.getSource<LocationSource>("aelis.location")
|
|
||||||
expect(source?.lastLocation?.lat).toBe(51.5074)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
const callback = mock()
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
session.engine.subscribe(callback)
|
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for async update propagation
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
|
||||||
setEnabledSources(["aelis.location"])
|
|
||||||
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })
|
|
||||||
const callback = mock()
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
session.engine.subscribe(callback)
|
|
||||||
|
|
||||||
manager.remove("user-1")
|
|
||||||
|
|
||||||
// Create new session and push location — old callback should not fire
|
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
|
||||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
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,305 +0,0 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { type } from "arktype"
|
|
||||||
import merge from "lodash.merge"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
import type { FeedSourceProvider } from "./feed-source-provider.ts"
|
|
||||||
|
|
||||||
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
|
||||||
import { UserSession } from "./user-session.ts"
|
|
||||||
|
|
||||||
export interface UserSessionManagerConfig {
|
|
||||||
db: Database
|
|
||||||
providers: FeedSourceProvider[]
|
|
||||||
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 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.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`)
|
|
||||||
}
|
|
||||||
this.sessions.set(userId, session)
|
|
||||||
return session
|
|
||||||
} finally {
|
|
||||||
this.pending.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(userId: string): void {
|
|
||||||
const session = this.sessions.get(userId)
|
|
||||||
if (session) {
|
|
||||||
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,394 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { LocationSource } from "@aelis/source-location"
|
|
||||||
import { describe, expect, spyOn, test } from "bun:test"
|
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("UserSession", () => {
|
|
||||||
test("registers sources and starts engine", async () => {
|
|
||||||
const session = new UserSession("test-user", [
|
|
||||||
createStubSource("test-a"),
|
|
||||||
createStubSource("test-b"),
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await session.engine.refresh()
|
|
||||||
|
|
||||||
expect(result.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getSource returns registered source", () => {
|
|
||||||
const location = new LocationSource()
|
|
||||||
const session = new UserSession("test-user", [location])
|
|
||||||
|
|
||||||
const result = session.getSource<LocationSource>("aelis.location")
|
|
||||||
|
|
||||||
expect(result).toBe(location)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getSource returns undefined for unknown source", () => {
|
|
||||||
const session = new UserSession("test-user", [createStubSource("test")])
|
|
||||||
|
|
||||||
expect(session.getSource("unknown")).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("destroy stops engine and clears sources", () => {
|
|
||||||
const session = new UserSession("test-user", [createStubSource("test")])
|
|
||||||
|
|
||||||
session.destroy()
|
|
||||||
|
|
||||||
expect(session.getSource("test")).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("engine.executeAction routes to correct source", async () => {
|
|
||||||
const location = new LocationSource()
|
|
||||||
const session = new UserSession("test-user", [location])
|
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5,
|
|
||||||
lng: -0.1,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(location.lastLocation).toBeDefined()
|
|
||||||
expect(location.lastLocation!.lat).toBe(51.5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("UserSession.feed", () => {
|
|
||||||
test("returns feed items without enhancer", async () => {
|
|
||||||
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 result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns enhanced items when enhancer is provided", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
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 result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.data.enhanced).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caches enhanced items on subsequent calls", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
let enhancerCallCount = 0
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
enhancerCallCount++
|
|
||||||
return feedItems.map((item) => ({ ...item, data: { ...item.data, enhanced: true } }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancerCallCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("re-enhances after engine refresh with new data", async () => {
|
|
||||||
let currentItems: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { version: 1 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const source = createStubSource("test", currentItems)
|
|
||||||
// Make fetchItems dynamic so refresh returns new data
|
|
||||||
source.fetchItems = async () => currentItems
|
|
||||||
|
|
||||||
const enhancedVersions: number[] = []
|
|
||||||
const enhancer = async (feedItems: FeedItem[]) => {
|
|
||||||
const version = feedItems[0]!.data.version as number
|
|
||||||
enhancedVersions.push(version)
|
|
||||||
return feedItems.map((item) => ({
|
|
||||||
...item,
|
|
||||||
data: { ...item.data, enhanced: true },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession("test-user", [source], enhancer)
|
|
||||||
|
|
||||||
// First feed triggers refresh + enhancement
|
|
||||||
const result1 = await session.feed()
|
|
||||||
expect(result1.items[0]!.data.version).toBe(1)
|
|
||||||
expect(result1.items[0]!.data.enhanced).toBe(true)
|
|
||||||
|
|
||||||
// Update source data and trigger engine refresh
|
|
||||||
currentItems = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
|
||||||
data: { version: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
// Wait for subscriber-triggered background enhancement
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
// feed() should now serve re-enhanced items with version 2
|
|
||||||
const result2 = await session.feed()
|
|
||||||
expect(result2.items[0]!.data.version).toBe(2)
|
|
||||||
expect(result2.items[0]!.data.enhanced).toBe(true)
|
|
||||||
expect(enhancedVersions).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to unenhanced items when enhancer throws", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const enhancer = async () => {
|
|
||||||
throw new Error("enhancement exploded")
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = new UserSession("test-user", [createStubSource("test", items)], enhancer)
|
|
||||||
|
|
||||||
const result = await session.feed()
|
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1)
|
|
||||||
expect(result.items[0]!.id).toBe("item-1")
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
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
|
|
||||||
private enhancedItems: FeedItem[] | null = null
|
|
||||||
/** The FeedResult that enhancedItems was derived from. */
|
|
||||||
private enhancedSource: FeedResult | null = null
|
|
||||||
private enhancingPromise: Promise<void> | null = null
|
|
||||||
private unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
constructor(userId: string, sources: FeedSource[], enhancer?: FeedEnhancer | null) {
|
|
||||||
this.userId = userId
|
|
||||||
this.engine = new FeedEngine()
|
|
||||||
this.enhancer = enhancer ?? null
|
|
||||||
for (const source of sources) {
|
|
||||||
this.sources.set(source.id, source)
|
|
||||||
this.engine.register(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.enhancer) {
|
|
||||||
this.unsubscribe = this.engine.subscribe((result) => {
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.runEnhancement(result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current feed, refreshing if the engine cache expired.
|
|
||||||
* Enhancement runs eagerly on engine updates; this method awaits
|
|
||||||
* any in-flight enhancement or triggers one if needed.
|
|
||||||
*/
|
|
||||||
async feed(): Promise<FeedResult> {
|
|
||||||
const cached = this.engine.lastFeed()
|
|
||||||
const result = cached ?? (await this.engine.refresh())
|
|
||||||
|
|
||||||
if (!this.enhancer) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for any in-flight background enhancement to finish
|
|
||||||
if (this.enhancingPromise) {
|
|
||||||
await this.enhancingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve cached enhancement only if it matches the current engine result
|
|
||||||
if (this.enhancedItems && this.enhancedSource === result) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale or missing — re-enhance
|
|
||||||
await this.runEnhancement(result)
|
|
||||||
|
|
||||||
if (this.enhancedItems) {
|
|
||||||
return { ...result, items: this.enhancedItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
|
||||||
return this.sources.get(sourceId) as T | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSource(sourceId: string): boolean {
|
|
||||||
return this.sources.has(sourceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a new source in the engine and invalidates all caches.
|
|
||||||
* Stops and restarts the engine to establish reactive subscriptions.
|
|
||||||
*/
|
|
||||||
addSource(source: FeedSource): void {
|
|
||||||
if (this.sources.has(source.id)) {
|
|
||||||
throw new Error(`Cannot add source "${source.id}": already registered`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasStarted = this.engine.isStarted()
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.register(source)
|
|
||||||
this.sources.set(source.id, source)
|
|
||||||
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces a source in the engine and invalidates all caches.
|
|
||||||
* Stops and restarts the engine to re-establish reactive subscriptions.
|
|
||||||
*/
|
|
||||||
replaceSource(oldSourceId: string, newSource: FeedSource): void {
|
|
||||||
if (!this.sources.has(oldSourceId)) {
|
|
||||||
throw new Error(`Cannot replace source "${oldSourceId}": not registered`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasStarted = this.engine.isStarted()
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.unregister(oldSourceId)
|
|
||||||
this.sources.delete(oldSourceId)
|
|
||||||
|
|
||||||
this.engine.register(newSource)
|
|
||||||
this.sources.set(newSource.id, newSource)
|
|
||||||
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a source from the engine and invalidates all caches.
|
|
||||||
* Stops and restarts the engine to clean up reactive subscriptions.
|
|
||||||
*/
|
|
||||||
removeSource(sourceId: string): void {
|
|
||||||
if (!this.sources.has(sourceId)) return
|
|
||||||
|
|
||||||
const wasStarted = this.engine.isStarted()
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.engine.unregister(sourceId)
|
|
||||||
this.sources.delete(sourceId)
|
|
||||||
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
|
|
||||||
if (wasStarted) {
|
|
||||||
this.engine.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(): void {
|
|
||||||
this.unsubscribe?.()
|
|
||||||
this.unsubscribe = null
|
|
||||||
this.engine.stop()
|
|
||||||
this.sources.clear()
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private invalidateEnhancement(): void {
|
|
||||||
this.enhancedItems = null
|
|
||||||
this.enhancedSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private runEnhancement(result: FeedResult): Promise<void> {
|
|
||||||
const promise = this.enhance(result)
|
|
||||||
this.enhancingPromise = promise
|
|
||||||
promise.finally(() => {
|
|
||||||
if (this.enhancingPromise === promise) {
|
|
||||||
this.enhancingPromise = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
private async enhance(result: FeedResult): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.enhancedItems = await this.enhancer!(result.items)
|
|
||||||
this.enhancedSource = result
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[enhancement] Unexpected error:", err)
|
|
||||||
this.invalidateEnhancement()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,38 +0,0 @@
|
|||||||
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
|
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
constructor(options: TflSourceProviderOptions) {
|
|
||||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
|
||||||
this.client = "client" in options ? options.client : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user