Add components: service/persona-api, worker/media-worker, app-react/creator-ui
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
cb3d4d5786
commit
be5dcf1f28
229
.woodpecker.yml
229
.woodpecker.yml
@ -57,12 +57,239 @@ steps:
|
||||
event: push
|
||||
|
||||
# COMPONENT_STEPS_BELOW
|
||||
|
||||
# Woodpecker CI step for creator-ui React app
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-creator-ui:
|
||||
depends_on: [preflight]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: persona-community-2/creator-ui
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: apps/creator-ui/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
verify-creator-ui:
|
||||
depends_on: [build-creator-ui]
|
||||
image: alpine/curl
|
||||
failure: ignore
|
||||
commands:
|
||||
- |
|
||||
TAG="${CI_COMMIT_SHA:0:8}"
|
||||
REPO="persona-community-2/creator-ui"
|
||||
REGISTRY="registry.threesix.ai"
|
||||
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--insecure \
|
||||
--connect-timeout 10 \
|
||||
--max-time 15 \
|
||||
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "404" ]; then
|
||||
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
||||
echo " Build may have failed. Deploy will be skipped."
|
||||
exit 1
|
||||
else
|
||||
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
|
||||
exit 0
|
||||
fi
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-creator-ui:
|
||||
depends_on: [verify-creator-ui]
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- echo "==> Deploying creator-ui with image tag ${CI_COMMIT_SHA:0:8}"
|
||||
- kubectl set image deployment/persona-community-2-creator-ui persona-community-2-creator-ui=registry.threesix.ai/persona-community-2/creator-ui:${CI_COMMIT_SHA:0:8} -n projects
|
||||
- kubectl patch deployment/persona-community-2-creator-ui -n projects -p '{"spec":{"replicas":1}}'
|
||||
- |
|
||||
echo "==> Verifying deployment persona-community-2-creator-ui"
|
||||
ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-creator-ui -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||
EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/creator-ui:${CI_COMMIT_SHA:0:8}"
|
||||
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||
echo "FATAL: Image mismatch after deploy"
|
||||
echo " expected: $EXPECTED_IMAGE"
|
||||
echo " actual: $ACTUAL_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||
echo "==> Waiting for rollout (timeout 120s)..."
|
||||
kubectl rollout status deployment/persona-community-2-creator-ui -n projects --timeout=120s
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
# Woodpecker CI step for media-worker worker
|
||||
# Add this step to your .woodpecker.yml
|
||||
|
||||
build-media-worker:
|
||||
depends_on: [preflight]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: persona-community-2/media-worker
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: workers/media-worker/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
verify-media-worker:
|
||||
depends_on: [build-media-worker]
|
||||
image: alpine/curl
|
||||
failure: ignore
|
||||
commands:
|
||||
- |
|
||||
TAG="${CI_COMMIT_SHA:0:8}"
|
||||
REPO="persona-community-2/media-worker"
|
||||
REGISTRY="registry.threesix.ai"
|
||||
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--insecure \
|
||||
--connect-timeout 10 \
|
||||
--max-time 15 \
|
||||
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "404" ]; then
|
||||
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
||||
echo " Build may have failed. Deploy will be skipped."
|
||||
exit 1
|
||||
else
|
||||
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
|
||||
exit 0
|
||||
fi
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-media-worker:
|
||||
depends_on: [verify-media-worker]
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- echo "==> Deploying media-worker with image tag ${CI_COMMIT_SHA:0:8}"
|
||||
- kubectl set image deployment/persona-community-2-media-worker persona-community-2-media-worker=registry.threesix.ai/persona-community-2/media-worker:${CI_COMMIT_SHA:0:8} -n projects
|
||||
- kubectl patch deployment/persona-community-2-media-worker -n projects -p '{"spec":{"replicas":1}}'
|
||||
- |
|
||||
echo "==> Verifying deployment persona-community-2-media-worker"
|
||||
ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-media-worker -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||
EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/media-worker:${CI_COMMIT_SHA:0:8}"
|
||||
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||
echo "FATAL: Image mismatch after deploy"
|
||||
echo " expected: $EXPECTED_IMAGE"
|
||||
echo " actual: $ACTUAL_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||
echo "==> Waiting for rollout (timeout 120s)..."
|
||||
kubectl rollout status deployment/persona-community-2-media-worker -n projects --timeout=120s
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
# Woodpecker CI step for persona-api service
|
||||
# Add this step to your .woodpecker.yml
|
||||
# NOTE: verify step is replicated in all component templates (service, app-react,
|
||||
# app-astro, app-nextjs, worker). Update all 5 if changing the verify logic.
|
||||
|
||||
build-persona-api:
|
||||
depends_on: [preflight]
|
||||
image: woodpeckerci/plugin-kaniko
|
||||
settings:
|
||||
registry: registry.threesix.ai
|
||||
repo: persona-community-2/persona-api
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
context: .
|
||||
dockerfile: services/persona-api/Dockerfile
|
||||
cache: true
|
||||
skip-tls-verify: true
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
verify-persona-api:
|
||||
depends_on: [build-persona-api]
|
||||
image: alpine/curl
|
||||
failure: ignore
|
||||
commands:
|
||||
- |
|
||||
TAG="${CI_COMMIT_SHA:0:8}"
|
||||
REPO="persona-community-2/persona-api"
|
||||
REGISTRY="registry.threesix.ai"
|
||||
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--insecure \
|
||||
--connect-timeout 10 \
|
||||
--max-time 15 \
|
||||
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "404" ]; then
|
||||
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
|
||||
echo " Build may have failed. Deploy will be skipped."
|
||||
exit 1
|
||||
else
|
||||
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
|
||||
exit 0
|
||||
fi
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
deploy-persona-api:
|
||||
depends_on: [verify-persona-api]
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
- echo "==> Deploying persona-api with image tag ${CI_COMMIT_SHA:0:8}"
|
||||
- kubectl set image deployment/persona-community-2-persona-api persona-community-2-persona-api=registry.threesix.ai/persona-community-2/persona-api:${CI_COMMIT_SHA:0:8} -n projects
|
||||
- kubectl patch deployment/persona-community-2-persona-api -n projects -p '{"spec":{"replicas":1}}'
|
||||
- |
|
||||
echo "==> Verifying deployment persona-community-2-persona-api"
|
||||
ACTUAL_IMAGE=$(kubectl get deployment/persona-community-2-persona-api -n projects -o jsonpath='{.spec.template.spec.containers[0].image}')
|
||||
EXPECTED_IMAGE="registry.threesix.ai/persona-community-2/persona-api:${CI_COMMIT_SHA:0:8}"
|
||||
if [ "$ACTUAL_IMAGE" != "$EXPECTED_IMAGE" ]; then
|
||||
echo "FATAL: Image mismatch after deploy"
|
||||
echo " expected: $EXPECTED_IMAGE"
|
||||
echo " actual: $ACTUAL_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Image confirmed: $ACTUAL_IMAGE"
|
||||
echo "==> Waiting for rollout (timeout 120s)..."
|
||||
kubectl rollout status deployment/persona-community-2-persona-api -n projects --timeout=120s
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
# Do not remove the marker above - component steps are inserted here
|
||||
|
||||
# Sync point after all component builds/deploys complete
|
||||
# depends_on is updated dynamically when components are added
|
||||
build-complete:
|
||||
depends_on: [preflight] # BUILD_COMPLETE_DEPS
|
||||
depends_on: [preflight, deploy-persona-api, deploy-media-worker, deploy-creator-ui] # BUILD_COMPLETE_DEPS
|
||||
image: alpine:3.19
|
||||
commands:
|
||||
- echo "All component builds complete"
|
||||
|
||||
@ -94,4 +94,9 @@ persona-community-2/
|
||||
|
||||
## Components
|
||||
|
||||
<!-- Components will be listed here as they're added -->
|
||||
| Component | Type | Path |
|
||||
|-----------|------|------|
|
||||
| **persona-api** | API service | `services/persona-api/` |
|
||||
| **media-worker** | Background worker | `workers/media-worker/` |
|
||||
| **creator-ui** | React app | `apps/creator-ui/` |
|
||||
|
||||
|
||||
3
Procfile
3
Procfile
@ -1,2 +1,5 @@
|
||||
# Local development processes
|
||||
# Components will be added below as they're created
|
||||
persona-api: cd services/persona-api && make run
|
||||
media-worker: cd workers/media-worker && make run
|
||||
creator-ui: cd apps/creator-ui && npm run dev
|
||||
|
||||
18
apps/creator-ui/.eslintrc.cjs
Normal file
18
apps/creator-ui/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
34
apps/creator-ui/Dockerfile
Normal file
34
apps/creator-ui/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
||||
# Build stage - using pnpm for workspace dependency resolution
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy workspace configuration files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Copy shared packages (required for workspace:* dependencies)
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Copy the app component
|
||||
COPY apps/creator-ui/ ./apps/creator-ui/
|
||||
|
||||
# Install dependencies using pnpm (resolves workspace:* correctly)
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
# Build the app
|
||||
WORKDIR /workspace/apps/creator-ui
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=build /workspace/apps/creator-ui/dist /usr/share/nginx/html
|
||||
COPY apps/creator-ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
6
apps/creator-ui/component.yaml
Normal file
6
apps/creator-ui/component.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
name: creator-ui
|
||||
type: app
|
||||
port: 3001
|
||||
path: apps/creator-ui
|
||||
stack: react
|
||||
dependencies: []
|
||||
13
apps/creator-ui/index.html
Normal file
13
apps/creator-ui/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>creator-ui | persona-community-2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
apps/creator-ui/nginx.conf
Normal file
26
apps/creator-ui/nginx.conf
Normal file
@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 3001;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
41
apps/creator-ui/package.json
Normal file
41
apps/creator-ui/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "creator-ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview --port 3001",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@persona-community-2/ai-client": "workspace:*",
|
||||
"@persona-community-2/api-client": "workspace:*",
|
||||
"@persona-community-2/auth": "workspace:*",
|
||||
"@persona-community-2/layout": "workspace:*",
|
||||
"@persona-community-2/logger": "workspace:*",
|
||||
"@persona-community-2/realtime": "workspace:*",
|
||||
"@persona-community-2/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.3.2",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
apps/creator-ui/postcss.config.cjs
Normal file
6
apps/creator-ui/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
apps/creator-ui/public/vite.svg
Normal file
1
apps/creator-ui/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
449
apps/creator-ui/src/App.tsx
Normal file
449
apps/creator-ui/src/App.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useLocation, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from '@persona-community-2/auth';
|
||||
import { DashboardShell, Sidebar, Header, type NavItem } from '@persona-community-2/layout';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Badge,
|
||||
Home,
|
||||
ImageIcon,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from '@persona-community-2/ui';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { ForgotPasswordPage } from './pages/ForgotPasswordPage';
|
||||
import { ResetPasswordPage } from './pages/ResetPasswordPage';
|
||||
import { VerifyEmailPage } from './pages/VerifyEmailPage';
|
||||
import { SessionsPage } from './pages/SessionsPage';
|
||||
import { ChatPage } from './pages/ChatPage';
|
||||
import { GeneratePage } from './pages/GeneratePage';
|
||||
import { MediaPage } from './pages/MediaPage';
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/', icon: Home },
|
||||
{ label: 'Chat', href: '/chat', icon: MessageSquare },
|
||||
{ label: 'Generate', href: '/generate', icon: Sparkles },
|
||||
{ label: 'Media', href: '/media', icon: ImageIcon },
|
||||
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
|
||||
{ label: 'Users', href: '/users', icon: Users, badge: '12' },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Dashboard',
|
||||
'/chat': 'Chat',
|
||||
'/generate': 'Generate',
|
||||
'/media': 'Media',
|
||||
'/analytics': 'Analytics',
|
||||
'/users': 'Users',
|
||||
'/settings': 'Settings',
|
||||
'/settings/sessions': 'Sessions',
|
||||
'/settings/verify-email': 'Verify Email',
|
||||
};
|
||||
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to creator-ui</CardTitle>
|
||||
<CardDescription>
|
||||
This is part of the{' '}
|
||||
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded text-sm">
|
||||
persona-community-2
|
||||
</code>{' '}
|
||||
monorepo, using the shared UI library and layout components.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-3">
|
||||
<Button>Get Started</Button>
|
||||
<Button variant="outline">Documentation</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Total Users</CardDescription>
|
||||
<CardTitle className="text-3xl">1,234</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="success">+12% from last month</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Active Sessions</CardDescription>
|
||||
<CardTitle className="text-3xl">567</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="info">Live</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>API Requests</CardDescription>
|
||||
<CardTitle className="text-3xl">89.2k</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="warning">High traffic</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Edit this file at{' '}
|
||||
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded">
|
||||
apps/creator-ui/src/App.tsx
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">All Users</h2>
|
||||
<p className="text-sm text-[var(--text-muted)]">Manage your team members and their roles.</p>
|
||||
</div>
|
||||
<Button>Add User</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ name: 'Alice Chen', role: 'Admin', status: 'Active' },
|
||||
{ name: 'Bob Martinez', role: 'Editor', status: 'Active' },
|
||||
{ name: 'Carol Singh', role: 'Viewer', status: 'Invited' },
|
||||
].map((user) => (
|
||||
<Card key={user.name}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{user.name}</CardTitle>
|
||||
<CardDescription>{user.role}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant={user.status === 'Active' ? 'success' : 'info'}>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Page Views</CardDescription>
|
||||
<CardTitle className="text-3xl">24.5k</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="success">+8% this week</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Bounce Rate</CardDescription>
|
||||
<CardTitle className="text-3xl">32%</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="success">-3% improvement</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Avg. Session</CardDescription>
|
||||
<CardTitle className="text-3xl">4m 12s</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="info">Stable</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Traffic Sources</CardTitle>
|
||||
<CardDescription>Where your visitors are coming from this month.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ source: 'Direct', visits: '8,421', pct: 34 },
|
||||
{ source: 'Search', visits: '6,312', pct: 26 },
|
||||
{ source: 'Social', visits: '5,105', pct: 21 },
|
||||
{ source: 'Referral', visits: '4,662', pct: 19 },
|
||||
].map((row) => (
|
||||
<div key={row.source} className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{row.source}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 rounded-full bg-[var(--surface-200)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)]"
|
||||
style={{ width: `${row.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-muted)] w-16 text-right">{row.visits}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General</CardTitle>
|
||||
<CardDescription>Manage your application settings and preferences.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'Application Name', value: 'persona-community-2' },
|
||||
{ label: 'Environment', value: 'Production' },
|
||||
{ label: 'Region', value: 'US West' },
|
||||
].map((setting) => (
|
||||
<div key={setting.label} className="flex items-center justify-between py-2 border-b border-[var(--border-muted)] last:border-0">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{setting.label}</span>
|
||||
<Badge variant="outline">{setting.value}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Manage your account settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Sign Out</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">Sign out of your account on this device.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>Sign Out</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danger Zone</CardTitle>
|
||||
<CardDescription>Irreversible actions for your application.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Delete Application</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">Permanently remove this application and all its data.</p>
|
||||
</div>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
|
||||
<p className="text-sm text-[var(--text-muted)]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MagicLinkCallbackPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { loginWithMagicLink } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [verifying, setVerifying] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
const email = searchParams.get('email');
|
||||
|
||||
if (!token || !email) {
|
||||
setError('Invalid magic link. Missing token or email.');
|
||||
setVerifying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loginWithMagicLink({ email, token })
|
||||
.then(() => {
|
||||
navigate('/', { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Magic link verification failed.');
|
||||
setVerifying(false);
|
||||
});
|
||||
}, [searchParams, loginWithMagicLink, navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>{verifying ? 'Verifying Magic Link' : 'Verification Failed'}</CardTitle>
|
||||
<CardDescription>
|
||||
{verifying
|
||||
? 'Please wait while we verify your magic link...'
|
||||
: 'We could not verify your magic link.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{verifying ? (
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[var(--text-error, #ef4444)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
<Link to="/login">
|
||||
<Button variant="outline">Back to Login</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const itemsWithActive = navItems.map((item) => ({
|
||||
...item,
|
||||
active: location.pathname === item.href,
|
||||
}));
|
||||
|
||||
const pageTitle = pageTitles[location.pathname] || 'Dashboard';
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
logo={
|
||||
<span className="font-semibold text-lg">persona-community-2</span>
|
||||
}
|
||||
items={itemsWithActive}
|
||||
onNavigate={(href) => navigate(href)}
|
||||
footer={
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
{user?.email || 'v0.0.1'}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
header={
|
||||
<Header
|
||||
title={pageTitle}
|
||||
showSearch
|
||||
searchPlaceholder="Search..."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/generate" element={<GeneratePage />} />
|
||||
<Route path="/media" element={<MediaPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/sessions" element={<SessionsPage />} />
|
||||
<Route path="/settings/verify-email" element={<VerifyEmailPage />} />
|
||||
</Routes>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/auth/magic-link/callback" element={<MagicLinkCallbackPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute
|
||||
redirectTo="/login"
|
||||
onRedirect={(path) => {
|
||||
// Navigate to login, storing current location for redirect after login
|
||||
navigate(path, { state: { from: location.pathname }, replace: true });
|
||||
}}
|
||||
fallback={<LoadingScreen />}
|
||||
>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Determine API base URL from environment or current origin
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
return (
|
||||
<AuthProvider authBaseUrl={`${apiBaseUrl}/api/{{SERVICE_NAME}}`}>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
6
apps/creator-ui/src/index.css
Normal file
6
apps/creator-ui/src/index.css
Normal file
@ -0,0 +1,6 @@
|
||||
/* Import design system tokens */
|
||||
@import '@persona-community-2/ui/styles';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
11
apps/creator-ui/src/lib/logger.ts
Normal file
11
apps/creator-ui/src/lib/logger.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createLogger, installGlobalHandlers } from '@persona-community-2/logger';
|
||||
|
||||
export const logger = createLogger({
|
||||
level: import.meta.env.DEV ? 'debug' : 'info',
|
||||
service: 'creator-ui',
|
||||
// Set endpoint to send logs to your backend:
|
||||
// endpoint: '/api/logs',
|
||||
});
|
||||
|
||||
// Install global error handlers
|
||||
installGlobalHandlers(logger);
|
||||
19
apps/creator-ui/src/main.tsx
Normal file
19
apps/creator-ui/src/main.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import './lib/logger';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
190
apps/creator-ui/src/pages/ChatPage.tsx
Normal file
190
apps/creator-ui/src/pages/ChatPage.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import { useChat } from '@persona-community-2/realtime';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
ChatBubble,
|
||||
ChatInput,
|
||||
Badge,
|
||||
ProviderBadge,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
interface TimelineMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
timestamp: Date;
|
||||
provider?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const { user, getToken } = useAuth();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// API base URL from environment
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const authHeaders = useMemo(() => {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
}, [getToken]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
aiMessages,
|
||||
streamingMessages,
|
||||
sendMessage,
|
||||
connectionState,
|
||||
onlineUsers,
|
||||
} = useChat({
|
||||
endpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/chat/messages`,
|
||||
sseEndpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/events`,
|
||||
channel: 'channel:general',
|
||||
userId: user?.id || 'anonymous',
|
||||
userName: user?.name || user?.email || 'Anonymous',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
// Track send errors for user feedback
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
|
||||
// Merge user messages + AI messages into a single sorted timeline
|
||||
const timeline = useMemo<TimelineMessage[]>(() => {
|
||||
const combined: TimelineMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
combined.push({
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
role: msg.userId === user?.id ? 'user' : 'assistant',
|
||||
timestamp: new Date(msg.timestamp),
|
||||
});
|
||||
}
|
||||
|
||||
for (const msg of aiMessages) {
|
||||
combined.push({
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
role: 'assistant',
|
||||
timestamp: new Date(msg.timestamp),
|
||||
provider: msg.provider,
|
||||
});
|
||||
}
|
||||
|
||||
// Add in-progress streaming messages
|
||||
for (const [, stream] of streamingMessages) {
|
||||
combined.push({
|
||||
id: stream.streamId,
|
||||
content: stream.content,
|
||||
role: 'assistant',
|
||||
timestamp: new Date(stream.timestamp),
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
return combined;
|
||||
}, [messages, aiMessages, streamingMessages, user?.id]);
|
||||
|
||||
// Handle sending a message (wraps async sendMessage for ChatInput)
|
||||
const handleSendMessage = useCallback((content: string) => {
|
||||
sendMessage(content).catch(() => {
|
||||
setSendError('Failed to send message. Please try again.');
|
||||
setTimeout(() => setSendError(null), 3000);
|
||||
});
|
||||
}, [sendMessage]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [timeline]);
|
||||
|
||||
const connectionBadge = () => {
|
||||
switch (connectionState) {
|
||||
case 'connected':
|
||||
return <Badge variant="success">Connected</Badge>;
|
||||
case 'connecting':
|
||||
return <Badge variant="warning">Connecting...</Badge>;
|
||||
case 'disconnected':
|
||||
return <Badge variant="error">Disconnected</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="error">Error</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-8rem)] flex flex-col">
|
||||
<Card className="flex-1 flex flex-col">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>AI Chat</CardTitle>
|
||||
<CardDescription>
|
||||
Chat with AI in real-time
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-[var(--text-muted)]">
|
||||
{onlineUsers.length} online
|
||||
</span>
|
||||
{connectionBadge()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col min-h-0">
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-2 mb-4">
|
||||
{timeline.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-[var(--text-muted)] text-sm">
|
||||
No messages yet. Start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
<ChatBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
/>
|
||||
{msg.provider && (
|
||||
<div className={msg.role === 'user' ? 'text-right' : 'text-left'}>
|
||||
<ProviderBadge provider={msg.provider} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex-shrink-0 space-y-2">
|
||||
{sendError && (
|
||||
<div className="px-3 py-2 text-sm text-[var(--error)] bg-[var(--error)]/10 rounded-lg">
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
<ChatInput
|
||||
onSubmit={handleSendMessage}
|
||||
disabled={connectionState !== 'connected'}
|
||||
placeholder={
|
||||
connectionState === 'connected'
|
||||
? 'Type a message... (Cmd+Enter to send)'
|
||||
: 'Connecting...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
apps/creator-ui/src/pages/ForgotPasswordPage.tsx
Normal file
110
apps/creator-ui/src/pages/ForgotPasswordPage.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormField,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error?.message || body.message || 'Request failed');
|
||||
}
|
||||
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Reset your password</CardTitle>
|
||||
<CardDescription>
|
||||
{sent
|
||||
? 'Check your email for a reset link'
|
||||
: "Enter your email and we'll send you a reset link"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!sent ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Send reset link
|
||||
</Button>
|
||||
<Link to="/login" className="text-sm text-center text-[var(--accent)] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
) : (
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
If an account exists with that email, you will receive a password reset link.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
In dev mode, check the server console for the reset token.
|
||||
</p>
|
||||
<Link to="/login" className="text-sm text-[var(--accent)] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
apps/creator-ui/src/pages/GeneratePage.tsx
Normal file
248
apps/creator-ui/src/pages/GeneratePage.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import { useMediaGeneration } from '@persona-community-2/realtime';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
FormField,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
ImageGrid,
|
||||
VideoGrid,
|
||||
GenerationProgress,
|
||||
ProviderBadge,
|
||||
Loader2,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
type GenerateMode = 'image' | 'video';
|
||||
|
||||
interface ImageResult {
|
||||
images: Array<{ data: string; isUrl: boolean; seed?: number }>;
|
||||
provider: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
interface VideoResult {
|
||||
videos: Array<{ data: string; isUrl: boolean; mimeType: string }>;
|
||||
provider: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export function GeneratePage() {
|
||||
const { user, getToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<GenerateMode>('image');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [aspectRatio, setAspectRatio] = useState('1:1');
|
||||
const [count, setCount] = useState(1);
|
||||
const [duration, setDuration] = useState('5s');
|
||||
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const authHeaders = useMemo(() => {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
}, [getToken]);
|
||||
|
||||
const imageGen = useMediaGeneration<ImageResult>({
|
||||
endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`,
|
||||
sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
|
||||
userId: user?.id || 'anonymous',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
const videoGen = useMediaGeneration<VideoResult>({
|
||||
endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/video`,
|
||||
sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
|
||||
userId: user?.id || 'anonymous',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
const gen = mode === 'image' ? imageGen : videoGen;
|
||||
const isGenerating = gen.status === 'pending' || gen.status === 'generating';
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
gen.reset();
|
||||
const request = mode === 'image'
|
||||
? { prompt, count, aspectRatio }
|
||||
: { prompt, aspectRatio, duration };
|
||||
await gen.generate(request);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Generation</CardTitle>
|
||||
<CardDescription>
|
||||
Generate images and videos using AI (Gemini / LaoZhang)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={mode === 'image' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('image')}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Images
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'video' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setMode('video');
|
||||
if (aspectRatio === '1:1') setAspectRatio('16:9');
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Video
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Prompt"
|
||||
name="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={
|
||||
mode === 'image'
|
||||
? 'A serene mountain landscape at sunset...'
|
||||
: 'A cat playing piano in a jazz club...'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Aspect Ratio</label>
|
||||
<Select value={aspectRatio} onValueChange={(v) => setAspectRatio(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mode === 'image' && <SelectItem value="1:1">Square (1:1)</SelectItem>}
|
||||
<SelectItem value="16:9">Landscape (16:9)</SelectItem>
|
||||
<SelectItem value="9:16">Portrait (9:16)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === 'image' ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Count</label>
|
||||
<Select value={String(count)} onValueChange={(v) => setCount(Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 image</SelectItem>
|
||||
<SelectItem value="2">2 images</SelectItem>
|
||||
<SelectItem value="4">4 images</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Duration</label>
|
||||
<Select value={duration} onValueChange={(v) => setDuration(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5s">5 seconds</SelectItem>
|
||||
<SelectItem value="10s">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleGenerate} disabled={isGenerating || !prompt.trim()}>
|
||||
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isGenerating ? 'Generating...' : `Generate ${mode === 'image' ? 'Images' : 'Video'}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isGenerating && (
|
||||
<Card>
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--text-muted)]">{gen.message || 'Starting...'}</span>
|
||||
<span className="text-[var(--text-muted)]">{gen.progress}%</span>
|
||||
</div>
|
||||
<GenerationProgress percent={gen.progress} />
|
||||
{gen.sseState !== 'connected' && (
|
||||
<p className="text-xs text-[var(--warning)]">
|
||||
SSE {gen.sseState} — events may be delayed
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{gen.status === 'failed' && gen.error && (
|
||||
<Card className="border-[var(--error)]">
|
||||
<CardContent className="py-4 text-[var(--error)]">
|
||||
{gen.error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{gen.status === 'complete' && imageGen.result && mode === 'image' && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Results</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{imageGen.result.provider && <ProviderBadge provider={imageGen.result.provider} />}
|
||||
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
|
||||
View in Library
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ImageGrid
|
||||
images={imageGen.result.images.map((img) => ({
|
||||
src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`,
|
||||
alt: prompt,
|
||||
}))}
|
||||
columns={imageGen.result.images.length > 1 ? 2 : 1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{gen.status === 'complete' && videoGen.result && mode === 'video' && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Results</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{videoGen.result.provider && <ProviderBadge provider={videoGen.result.provider} />}
|
||||
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
|
||||
View in Library
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VideoGrid
|
||||
videos={videoGen.result.videos.map((vid) => ({
|
||||
src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`,
|
||||
mimeType: vid.mimeType,
|
||||
alt: prompt,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
apps/creator-ui/src/pages/LoginPage.tsx
Normal file
270
apps/creator-ui/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormField,
|
||||
useFormErrors,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
} from '@persona-community-2/ui';
|
||||
import { isApiClientError } from '@persona-community-2/api-client';
|
||||
|
||||
type LoginTab = 'password' | 'otp' | 'magic-link';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, sendOTP, loginWithOTP, sendMagicLink, isLoading } = useAuth();
|
||||
const { setErrors, clearErrors, getError } = useFormErrors();
|
||||
const [generalError, setGeneralError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<LoginTab>('password');
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [otpEmail, setOtpEmail] = useState('');
|
||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||
|
||||
const from = (location.state as { from?: string })?.from || '/';
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
await login({ email, password });
|
||||
navigate(from, { replace: true });
|
||||
} catch (error) {
|
||||
if (isApiClientError(error)) {
|
||||
if (error.isValidationError()) {
|
||||
setErrors(error.getFieldErrors());
|
||||
} else {
|
||||
setGeneralError(error.message);
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
setGeneralError(error.message);
|
||||
} else {
|
||||
setGeneralError('An unexpected error occurred.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
try {
|
||||
await sendOTP(email);
|
||||
setOtpEmail(email);
|
||||
setOtpSent(true);
|
||||
} catch (error) {
|
||||
setGeneralError(error instanceof Error ? error.message : 'Failed to send code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const code = formData.get('code') as string;
|
||||
|
||||
try {
|
||||
await loginWithOTP({ email: otpEmail, code });
|
||||
navigate(from, { replace: true });
|
||||
} catch (error) {
|
||||
setGeneralError(error instanceof Error ? error.message : 'Invalid code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMagicLink = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
setMagicLinkSent(true);
|
||||
} catch (error) {
|
||||
setGeneralError(error instanceof Error ? error.message : 'Failed to send link');
|
||||
}
|
||||
};
|
||||
|
||||
const tabClass = (tab: LoginTab) =>
|
||||
`flex-1 py-2 text-sm font-medium text-center rounded-md transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-[var(--surface-100)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your persona-community-2 account</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-[var(--surface-200)]">
|
||||
<button type="button" className={tabClass('password')} onClick={() => { setActiveTab('password'); clearErrors(); setGeneralError(null); }}>
|
||||
Password
|
||||
</button>
|
||||
<button type="button" className={tabClass('otp')} onClick={() => { setActiveTab('otp'); clearErrors(); setGeneralError(null); setOtpSent(false); }}>
|
||||
OTP
|
||||
</button>
|
||||
<button type="button" className={tabClass('magic-link')} onClick={() => { setActiveTab('magic-link'); clearErrors(); setGeneralError(null); setMagicLinkSent(false); }}>
|
||||
Magic Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generalError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{generalError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Password tab */}
|
||||
{activeTab === 'password' && (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
error={getError('email')}
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormField
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
error={getError('password')}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="text-right">
|
||||
<Link to="/forgot-password" className="text-sm text-[var(--accent)] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP tab */}
|
||||
{activeTab === 'otp' && !otpSent && (
|
||||
<form onSubmit={handleSendOTP} className="space-y-4">
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Send code
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'otp' && otpSent && (
|
||||
<form onSubmit={handleVerifyOTP} className="space-y-4">
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
A 6-digit code was sent to <strong>{otpEmail}</strong>
|
||||
</p>
|
||||
<FormField
|
||||
label="Code"
|
||||
name="code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Verify
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
onClick={() => setOtpSent(false)}
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Magic Link tab */}
|
||||
{activeTab === 'magic-link' && !magicLinkSent && (
|
||||
<form onSubmit={handleSendMagicLink} className="space-y-4">
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Send magic link
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'magic-link' && magicLinkSent && (
|
||||
<div className="text-center space-y-3 py-4">
|
||||
<p className="text-sm text-[var(--text-primary)]">Check your email</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
We sent a sign-in link to your email. Click it to continue.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
In dev mode, check the server console for the link token.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<p className="text-sm text-center text-[var(--text-muted)]">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-[var(--accent)] hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/creator-ui/src/pages/MediaPage.tsx
Normal file
129
apps/creator-ui/src/pages/MediaPage.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import { useMediaUpload } from '@persona-community-2/realtime';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
MediaUploader,
|
||||
MediaLibrary,
|
||||
type MediaItem,
|
||||
Badge,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function MediaPage() {
|
||||
const { getToken } = useAuth();
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const authHeaders = useMemo(() => {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
}, [getToken]);
|
||||
|
||||
const mediaUpload = useMediaUpload({
|
||||
apiPrefix,
|
||||
serviceName: '{{SERVICE_NAME}}',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
setFetchError(null);
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media`, {
|
||||
headers: { ...authHeaders },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setFetchError(`Failed to load media (${res.status})`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setItems(data.items || []);
|
||||
} catch (err) {
|
||||
setFetchError(err instanceof Error ? err.message : 'Failed to load media');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [apiPrefix, authHeaders]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMedia();
|
||||
}, [fetchMedia]);
|
||||
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
fetchMedia();
|
||||
}, [fetchMedia]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...authHeaders },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
}, [apiPrefix, authHeaders]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Media</CardTitle>
|
||||
<CardDescription>
|
||||
Upload images and videos to your media library
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MediaUploader
|
||||
upload={mediaUpload.upload}
|
||||
isUploading={mediaUpload.isUploading}
|
||||
progress={mediaUpload.progress}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onError={(err) => console.error('Upload error:', err)}
|
||||
/>
|
||||
{mediaUpload.error && (
|
||||
<p className="mt-2 text-sm text-[var(--error)]">{mediaUpload.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Media Library</CardTitle>
|
||||
<CardDescription>
|
||||
Your uploaded and generated media files
|
||||
</CardDescription>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<Badge variant="outline">{items.length} files</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(deleteError || fetchError) && (
|
||||
<p className="mb-4 text-sm text-[var(--error)]">{deleteError || fetchError}</p>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-[var(--text-muted)]">Loading...</div>
|
||||
) : (
|
||||
<MediaLibrary
|
||||
items={items}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
apps/creator-ui/src/pages/RegisterPage.tsx
Normal file
128
apps/creator-ui/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormField,
|
||||
useFormErrors,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading } = useAuth();
|
||||
const { setErrors, clearErrors, getError } = useFormErrors();
|
||||
const [generalError, setGeneralError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register({ email, password, name });
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setGeneralError(error.message);
|
||||
} else {
|
||||
setGeneralError('An unexpected error occurred.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>Get started with persona-community-2</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{generalError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{generalError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
error={getError('name')}
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
error={getError('email')}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
error={getError('password')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
description="Must contain uppercase, lowercase, and a number"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
error={getError('confirmPassword')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create account
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-center text-[var(--text-muted)]">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-[var(--accent)] hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
apps/creator-ui/src/pages/ResetPasswordPage.tsx
Normal file
135
apps/creator-ui/src/pages/ResetPasswordPage.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
FormField,
|
||||
useFormErrors,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setErrors, clearErrors, getError } = useFormErrors();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [generalError, setGeneralError] = useState<string | null>(null);
|
||||
|
||||
const token = searchParams.get('token') || '';
|
||||
const email = searchParams.get('email') || '';
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
setGeneralError(null);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, token, newPassword }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error?.message || body.message || 'Reset failed');
|
||||
}
|
||||
|
||||
navigate('/login', { state: { message: 'Password reset successfully. Please sign in.' } });
|
||||
} catch (err) {
|
||||
setGeneralError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token || !email) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Invalid reset link</CardTitle>
|
||||
<CardDescription>This password reset link is missing required parameters.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<Link to="/forgot-password" className="text-sm text-[var(--accent)] hover:underline">
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Set new password</CardTitle>
|
||||
<CardDescription>Enter your new password below</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{generalError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{generalError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="New Password"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
error={getError('newPassword')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
description="Must contain uppercase, lowercase, and a number"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Repeat your new password"
|
||||
error={getError('confirmPassword')}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Reset password
|
||||
</Button>
|
||||
<Link to="/login" className="text-sm text-center text-[var(--accent)] hover:underline">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
apps/creator-ui/src/pages/SessionsPage.tsx
Normal file
178
apps/creator-ui/src/pages/SessionsPage.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth, type Session } from '@persona-community-2/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Badge,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function SessionsPage() {
|
||||
const { getToken } = useAuth();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const authHeaders = useCallback(() => {
|
||||
const token = getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
};
|
||||
}, [getToken]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load sessions');
|
||||
const data = await res.json();
|
||||
setSessions(data.data || data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [apiPrefix, authHeaders]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
const revokeSession = async (sessionId: string) => {
|
||||
setRevokingId(sessionId);
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to revoke session');
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeAll = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to revoke sessions');
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMs / 3600000);
|
||||
const diffDay = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMin < 1) return 'Just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--text-muted)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Active Sessions</h2>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Manage your active login sessions across devices.
|
||||
</p>
|
||||
</div>
|
||||
{sessions.length > 1 && (
|
||||
<Button variant="outline" onClick={revokeAll}>
|
||||
Revoke all other sessions
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{sessions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-[var(--text-muted)]">
|
||||
No active sessions found.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<Card key={session.id}>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-[var(--text-primary)]">
|
||||
{session.deviceLabel || 'Unknown device'}
|
||||
</span>
|
||||
{session.isCurrent && (
|
||||
<Badge variant="success">Current</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{session.ipAddress || 'Unknown IP'}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
Last active: {formatDate(session.lastActiveAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!session.isCurrent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => revokeSession(session.id)}
|
||||
disabled={revokingId === session.id}
|
||||
>
|
||||
{revokingId === session.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
apps/creator-ui/src/pages/VerifyEmailPage.tsx
Normal file
161
apps/creator-ui/src/pages/VerifyEmailPage.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@persona-community-2/auth';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
FormField,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Loader2,
|
||||
Check,
|
||||
} from '@persona-community-2/ui';
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const { user, getToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const authHeaders = () => {
|
||||
const token = getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email/send`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error?.message || body.message || 'Failed to send code');
|
||||
}
|
||||
|
||||
setCodeSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const code = formData.get('code') as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error?.message || body.message || 'Verification failed');
|
||||
}
|
||||
|
||||
setVerified(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (verified) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<Check className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<CardTitle>Email verified</CardTitle>
|
||||
<CardDescription>Your email address has been verified successfully.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Verify your email</CardTitle>
|
||||
<CardDescription>
|
||||
{codeSent
|
||||
? `Enter the 6-digit code sent to ${user?.email}`
|
||||
: 'Verify your email address to access all features'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!codeSent ? (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[var(--text-muted)] mb-4">
|
||||
We'll send a verification code to <strong>{user?.email}</strong>
|
||||
</p>
|
||||
<Button onClick={handleSendCode} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Send verification code
|
||||
</Button>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-2">
|
||||
In dev mode, check the server console for the code.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<FormField
|
||||
label="Verification Code"
|
||||
name="code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Verify email
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
apps/creator-ui/src/vite-env.d.ts
vendored
Normal file
1
apps/creator-ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
apps/creator-ui/tailwind.config.ts
Normal file
17
apps/creator-ui/tailwind.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
// Include shared packages for Tailwind classes
|
||||
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||
'../../packages/layout/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
25
apps/creator-ui/tsconfig.json
Normal file
25
apps/creator-ui/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
apps/creator-ui/tsconfig.node.json
Normal file
11
apps/creator-ui/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
34
apps/creator-ui/vite.config.ts
Normal file
34
apps/creator-ui/vite.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
// SSE events endpoint — must disable buffering for streaming
|
||||
'/api/{{SERVICE_NAME}}/events': {
|
||||
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||
changeOrigin: true,
|
||||
// Disable response buffering so SSE events stream immediately
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
// Prevent Vite from buffering SSE responses
|
||||
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
||||
proxyRes.headers['cache-control'] = 'no-cache';
|
||||
proxyRes.headers['x-accel-buffering'] = 'no';
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 3001,
|
||||
},
|
||||
});
|
||||
2
go.work
2
go.work
@ -1,4 +1,6 @@
|
||||
go 1.25
|
||||
|
||||
use ./pkg
|
||||
use ./services/persona-api
|
||||
use ./workers/media-worker
|
||||
// Component modules will be added below
|
||||
|
||||
31
services/persona-api/.env.example
Normal file
31
services/persona-api/.env.example
Normal file
@ -0,0 +1,31 @@
|
||||
# persona-api Service Configuration
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8001
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# App
|
||||
APP_NAME=persona-api
|
||||
APP_ENVIRONMENT=development
|
||||
APP_DEBUG=true
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=text
|
||||
|
||||
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
|
||||
AUTH_ENABLED=false
|
||||
JWT_SECRET=dev-secret-change-in-production # Required — server refuses to start with empty secret
|
||||
REGISTRATION_ENABLED=true
|
||||
|
||||
# Email delivery (notify service)
|
||||
# When NOTIFY_URL is empty, auth codes are logged to stdout (dev mode).
|
||||
# NOTIFY_URL=https://notify.threesix.ai
|
||||
# NOTIFY_API_KEY=notify_send_xxx
|
||||
# NOTIFY_HOST=myapp.threesix.ai
|
||||
# NOTIFY_FROM=noreply@myapp.threesix.ai
|
||||
|
||||
# Database (if needed)
|
||||
# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
|
||||
# The postgres:// scheme works for both — CockroachDB is wire-compatible.
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/persona-community-2?sslmode=disable
|
||||
35
services/persona-api/Dockerfile
Normal file
35
services/persona-api/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Configure Go private modules
|
||||
# Disable workspace mode - each component builds independently with replace directives
|
||||
ENV GOPRIVATE=git.threesix.ai/*
|
||||
ENV GOWORK=off
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy shared pkg and this service only
|
||||
COPY pkg/ ./pkg/
|
||||
COPY services/persona-api/ ./services/persona-api/
|
||||
|
||||
# Download dependencies (populates go.sum if empty)
|
||||
RUN cd pkg && go mod download
|
||||
RUN cd services/persona-api && go mod download
|
||||
|
||||
# Build from the service directory (uses replace directive for ../pkg)
|
||||
RUN cd services/persona-api && CGO_ENABLED=0 go build -o /persona-api ./cmd/server
|
||||
|
||||
# Production stage
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=builder /persona-api /persona-api
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
ENTRYPOINT ["/persona-api"]
|
||||
38
services/persona-api/Makefile
Normal file
38
services/persona-api/Makefile
Normal file
@ -0,0 +1,38 @@
|
||||
.PHONY: build run dev test lint fmt docker-build clean
|
||||
|
||||
SERVICE := persona-api
|
||||
BINARY := bin/$(SERVICE)
|
||||
GO_MODULE := git.threesix.ai/jordan/persona-community-2
|
||||
|
||||
# Build the service binary
|
||||
build:
|
||||
go build -o $(BINARY) ./cmd/server
|
||||
|
||||
# Run the service locally
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run the service in development mode (alias for run)
|
||||
dev:
|
||||
go run ./cmd/server
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
gofmt -w .
|
||||
goimports -w -local $(GO_MODULE) .
|
||||
|
||||
# Build Docker image (run from monorepo root)
|
||||
docker-build:
|
||||
docker build -t $(SERVICE):latest -f Dockerfile ../..
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
420
services/persona-api/cmd/server/main.go
Normal file
420
services/persona-api/cmd/server/main.go
Normal file
@ -0,0 +1,420 @@
|
||||
// Package main is the entry point for the persona-api service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/album"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/personagen"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/database"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/gemini"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/laozhang"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/mediagen"
|
||||
mediagenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/mediagen/adapters"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/generation"
|
||||
emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/notify"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/realtime"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/storage"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/textgen"
|
||||
textgenAdapters "git.threesix.ai/jordan/persona-community-2/pkg/textgen/adapters"
|
||||
emailadapter "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/email"
|
||||
componentemail "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/email"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/adapter/postgres"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/api"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/config"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit")
|
||||
flag.Parse()
|
||||
|
||||
// If exporting OpenAPI, generate spec and exit (used by CI for docs generation)
|
||||
if *exportOpenAPI {
|
||||
spec := api.NewServiceSpec()
|
||||
jsonBytes, err := spec.JSON()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg := config.Load()
|
||||
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Create SSE hub for async event delivery (generation progress, chat, etc.)
|
||||
sseHub := realtime.NewSSEHub(logger.Logger)
|
||||
|
||||
// Initialize storage backend (before queue, since standalone queue handlers use it).
|
||||
// GCS_BUCKET set = production (GCS). Otherwise = dev (in-memory).
|
||||
listenPort := fmt.Sprintf("%d", 8001)
|
||||
var mediaStore storage.Store
|
||||
if bucket := os.Getenv("GCS_BUCKET"); bucket != "" {
|
||||
gcsStore, err := storage.NewGCSStore(ctx, bucket, os.Getenv("GCS_SERVICE_ACCOUNT_JSON"), logger.Logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create GCS store", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = gcsStore.Close() }()
|
||||
mediaStore = gcsStore
|
||||
logger.Info("storage initialized (GCS)", "bucket", bucket)
|
||||
} else {
|
||||
memStore := storage.NewMemoryStore("http://localhost:" + listenPort + "/storage")
|
||||
mediaStore = memStore
|
||||
logger.Info("storage initialized (in-memory dev mode)")
|
||||
}
|
||||
|
||||
// Select backend based on DATABASE_URL availability.
|
||||
// With DATABASE_URL: Postgres repos + DB queue (production)
|
||||
// Without DATABASE_URL: in-memory repos + in-process AI (development)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
albumRepo := memory.NewAlbumRepository()
|
||||
var userRepo port.UserRepository
|
||||
var sessionRepo port.SessionRepository
|
||||
var authCodeRepo port.AuthCodeRepository
|
||||
var mediaRepo port.MediaRepository
|
||||
var jobQueue queue.Producer
|
||||
var jobReader queue.JobReader
|
||||
|
||||
if cfg.Database.URL != "" {
|
||||
// Connect to database (shared pool for queue + auth repos).
|
||||
dbPool, err := database.Connect(ctx, cfg.Database.URL, database.Options{
|
||||
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("connected to database")
|
||||
|
||||
// Verify the database connection is actually alive before proceeding.
|
||||
if err := dbPool.DB.PingContext(ctx); err != nil {
|
||||
logger.Error("database health check failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("database health check passed")
|
||||
|
||||
// Run auth migrations.
|
||||
if err := database.RunMigrations(ctx, dbPool, migrationsFS, "migrations"); err != nil {
|
||||
logger.Error("failed to run auth migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("auth migrations complete")
|
||||
|
||||
// Postgres-backed repositories.
|
||||
userRepo = postgres.NewUserRepository(dbPool.DB)
|
||||
sessionRepo = postgres.NewSessionRepository(dbPool.DB)
|
||||
authCodeRepo = postgres.NewAuthCodeRepository(dbPool.DB)
|
||||
mediaRepo = postgres.NewMediaObjectRepository(dbPool.DB)
|
||||
|
||||
// DB-backed queue.
|
||||
jobQueue, jobReader = setupDBQueue(ctx, cfg, dbPool, sseHub, logger)
|
||||
} else {
|
||||
logger.Info("DATABASE_URL not set — running in standalone mode (in-memory queue + in-process AI)")
|
||||
userRepo = memory.NewUserRepository(cfg.DevUserEmail, cfg.DevUserPassword)
|
||||
sessionRepo = memory.NewSessionRepository()
|
||||
authCodeRepo = memory.NewAuthCodeRepository()
|
||||
mediaRepo = memory.NewMediaRepository()
|
||||
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
|
||||
}
|
||||
|
||||
// Validate required config.
|
||||
if cfg.JWTSecret == "" {
|
||||
logger.Error("JWT_SECRET must be set (even in development)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load email renderer (HTML templates embedded at build time).
|
||||
emailRenderer, err := emailpkg.NewRendererFromFS(componentemail.TemplateFS, "templates", emailpkg.BrandConfig{
|
||||
AppName: cfg.AppName,
|
||||
AppURL: cfg.AppURL,
|
||||
SupportEmail: cfg.SupportEmail,
|
||||
LogoURL: cfg.LogoURL,
|
||||
PrimaryColor: cfg.BrandColor,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to load email templates", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("email renderer loaded", "templates", len(emailRenderer.Purposes()))
|
||||
|
||||
// Create email sender — notify service in production (NOTIFY_URL set), log-only for dev.
|
||||
var emailSender port.EmailSender
|
||||
if cfg.NotifyURL != "" {
|
||||
notifyClient, err := notify.NewClient(notify.Config{
|
||||
URL: cfg.NotifyURL,
|
||||
APIKey: cfg.NotifyAPIKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to create notify client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
emailSender = emailadapter.NewNotifySender(notifyClient, emailRenderer, cfg.NotifyHost, cfg.NotifyFrom, logger)
|
||||
logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost)
|
||||
} else {
|
||||
emailSender = emailadapter.NewLogSender(logger)
|
||||
logger.Info("email sender initialized (log-only dev mode)")
|
||||
}
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
|
||||
authService := service.NewAuthService(
|
||||
userRepo, sessionRepo, authCodeRepo, emailSender,
|
||||
cfg.JWTSecret, cfg.RegistrationEnabled, logger,
|
||||
)
|
||||
|
||||
// Create application
|
||||
application := app.New("persona-api", app.WithDefaultPort(8001))
|
||||
|
||||
// Mount in-memory storage HTTP handler for dev mode
|
||||
if memStore, ok := mediaStore.(*storage.MemoryStore); ok {
|
||||
application.Router().Handle("/storage/*", memStore)
|
||||
}
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, &api.Dependencies{
|
||||
ExampleService: exampleService,
|
||||
AuthService: authService,
|
||||
AlbumService: albumService,
|
||||
Queue: jobQueue,
|
||||
JobReader: jobReader,
|
||||
SSEHub: sseHub,
|
||||
Store: mediaStore,
|
||||
MediaRepo: mediaRepo,
|
||||
EmailRenderer: emailRenderer,
|
||||
})
|
||||
|
||||
// Start background cleanup of expired sessions and auth codes.
|
||||
go runCleanup(ctx, sessionRepo, authCodeRepo, logger)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
}
|
||||
|
||||
// setupDBQueue initializes the production queue backend using the shared database pool + optional Redis.
|
||||
// Returns both Producer (for enqueue) and JobReader (for status polling).
|
||||
func setupDBQueue(ctx context.Context, cfg *config.Config, pool *database.Pool, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
|
||||
if err := queue.RunMigrations(ctx, pool); err != nil {
|
||||
logger.Error("failed to run queue migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("queue migrations complete")
|
||||
|
||||
jobQueue := queue.NewQueue(pool.DB, logger)
|
||||
|
||||
// Start Redis SSE subscriber if configured.
|
||||
if cfg.RedisURL != "" {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
logger.Error("failed to parse REDIS_URL", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
redisClient := redis.NewClient(opts)
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
logger.Error("failed to connect to Redis", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("connected to Redis")
|
||||
|
||||
go func() {
|
||||
if err := realtime.RunSSESubscriber(ctx, redisClient, sseHub, logger.Logger); err != nil {
|
||||
logger.Error("SSE Redis subscriber stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.Warn("REDIS_URL not set — SSE events from worker will not be delivered")
|
||||
}
|
||||
|
||||
return jobQueue, jobQueue
|
||||
}
|
||||
|
||||
// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
|
||||
// This mode requires no database or Redis — everything runs in a single process.
|
||||
// Returns both Producer (for enqueue) and JobReader (for status polling).
|
||||
func setupStandaloneQueue(ctx context.Context, store storage.Store, albumUpdater album.AlbumUpdater, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
|
||||
memQueue := queue.NewMemoryQueue(logger.Logger)
|
||||
|
||||
// LocalPublisher delivers events directly to the SSE hub (no Redis needed).
|
||||
pub := realtime.NewLocalPublisher(sseHub)
|
||||
|
||||
// Initialize AI providers
|
||||
mediagenManager := initMediagen(ctx, logger)
|
||||
textgenManager := initTextgen(ctx, logger)
|
||||
|
||||
// Register job handlers (same handlers the worker uses).
|
||||
if mediagenManager != nil {
|
||||
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
|
||||
memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
|
||||
memQueue.RegisterHandler("generate_anchor", album.AnchorHandler(mediagenManager, store, pub, albumUpdater, logger))
|
||||
memQueue.RegisterHandler("generate_shot", album.ShotHandler(mediagenManager, store, pub, albumUpdater, logger))
|
||||
}
|
||||
if textgenManager != nil {
|
||||
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
|
||||
memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
|
||||
}
|
||||
|
||||
// Persona generation requires both textgen (5-stage LLM pipeline) and mediagen (20 images + 4 videos).
|
||||
if textgenManager != nil && mediagenManager != nil {
|
||||
memQueue.RegisterHandler("persona_generate", personagen.QueueHandler(textgenManager, mediagenManager, store, pub, logger.Logger))
|
||||
}
|
||||
|
||||
return memQueue, memQueue
|
||||
}
|
||||
|
||||
// initMediagen creates a mediagen manager from available AI provider credentials.
|
||||
func initMediagen(ctx context.Context, logger *logging.Logger) *mediagen.Manager {
|
||||
var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider
|
||||
var geminiMediaProvider *mediagenAdapters.GeminiProvider
|
||||
|
||||
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
|
||||
client, err := laozhang.NewClient(laozhang.Config{
|
||||
APIKey: apiKey,
|
||||
VideoTimeout: 5 * time.Minute,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create LaoZhang client", "error", err)
|
||||
} else {
|
||||
laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(client)
|
||||
logger.Info("LaoZhang media provider initialized")
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
client, err := gemini.NewClient(ctx, gemini.Config{
|
||||
APIKey: apiKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create Gemini client", "error", err)
|
||||
} else {
|
||||
geminiMediaProvider = mediagenAdapters.NewGeminiProvider(client)
|
||||
logger.Info("Gemini media provider initialized")
|
||||
}
|
||||
}
|
||||
|
||||
if laozhangMediaProvider == nil && geminiMediaProvider == nil {
|
||||
logger.Warn("no media generation providers available (set LAOZHANG_API_KEY or GEMINI_API_KEY)")
|
||||
return nil
|
||||
}
|
||||
|
||||
mgCfg := mediagen.ProductionConfig(mediagen.ProviderSet{
|
||||
LaoZhang: laozhangMediaProvider,
|
||||
Gemini: geminiMediaProvider,
|
||||
}, mediagen.WithLogger(logger.Logger))
|
||||
if laozhangMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, laozhangMediaProvider)
|
||||
}
|
||||
if geminiMediaProvider != nil {
|
||||
mgCfg.VideoProviders = append(mgCfg.VideoProviders, geminiMediaProvider)
|
||||
}
|
||||
|
||||
mgr, err := mediagen.NewManager(mgCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create mediagen manager", "error", err)
|
||||
return nil
|
||||
}
|
||||
logger.Info("mediagen manager initialized (image + video)")
|
||||
return mgr
|
||||
}
|
||||
|
||||
// initTextgen creates a textgen manager from available AI provider credentials.
|
||||
func initTextgen(ctx context.Context, logger *logging.Logger) *textgen.Manager {
|
||||
var textProviders []textgen.TextGenerator
|
||||
|
||||
if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" {
|
||||
client, err := laozhang.NewClient(laozhang.Config{
|
||||
APIKey: apiKey,
|
||||
Logger: logger.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create LaoZhang text client", "error", err)
|
||||
} else {
|
||||
textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(client, ""))
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
provider, err := textgenAdapters.NewGeminiTextProvider(ctx, textgenAdapters.GeminiTextConfig{
|
||||
APIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to create Gemini text provider", "error", err)
|
||||
} else {
|
||||
textProviders = append(textProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textProviders) == 0 {
|
||||
logger.Warn("no text generation providers available")
|
||||
return nil
|
||||
}
|
||||
|
||||
tgCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger))
|
||||
tgCfg.Providers = textProviders
|
||||
|
||||
mgr, err := textgen.NewManager(tgCfg)
|
||||
if err != nil {
|
||||
logger.Warn("failed to create textgen manager", "error", err)
|
||||
return nil
|
||||
}
|
||||
logger.Info("textgen manager initialized")
|
||||
return mgr
|
||||
}
|
||||
|
||||
// runCleanup periodically removes expired sessions and auth codes.
|
||||
// Runs every hour. Stops when ctx is cancelled.
|
||||
func runCleanup(ctx context.Context, sessions port.SessionRepository, codes port.AuthCodeRepository, logger *logging.Logger) {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sessCount, err := sessions.DeleteExpired(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("failed to cleanup expired sessions", "error", err)
|
||||
} else if sessCount > 0 {
|
||||
logger.Info("cleaned up expired sessions", "count", sessCount)
|
||||
}
|
||||
|
||||
codeCount, err := codes.DeleteExpired(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("failed to cleanup expired auth codes", "error", err)
|
||||
} else if codeCount > 0 {
|
||||
logger.Info("cleaned up expired auth codes", "count", codeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
services/persona-api/cmd/server/migrations/.gitkeep
Normal file
0
services/persona-api/cmd/server/migrations/.gitkeep
Normal file
@ -0,0 +1,79 @@
|
||||
-- 001_create_users.sql
|
||||
-- Auth tables for user management, sessions, and authentication codes.
|
||||
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||
|
||||
-- Core user identity. Email is the primary identifier for humans.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verified BOOL NOT NULL DEFAULT FALSE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Password credentials. Separate table because OAuth-only users have no password.
|
||||
CREATE TABLE IF NOT EXISTS user_passwords (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
password_hash TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- OAuth provider connections (Google, GitHub, Apple, etc.).
|
||||
CREATE TABLE IF NOT EXISTS oauth_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
provider_email TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
token_expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (provider, provider_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_connections_user_id ON oauth_connections (user_id);
|
||||
|
||||
-- Verification codes for OTP login, magic links, password reset, and email verification.
|
||||
CREATE TABLE IF NOT EXISTS auth_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_email_purpose ON auth_codes (email, purpose, expires_at)
|
||||
WHERE used_at IS NULL;
|
||||
|
||||
-- Sessions track where and when users are logged in.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
device_label TEXT NOT NULL DEFAULT '',
|
||||
last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
-- User roles. Separate table so users can have multiple roles.
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, role)
|
||||
);
|
||||
@ -0,0 +1,9 @@
|
||||
-- 002_add_indexes.sql
|
||||
-- Additional indexes for query performance.
|
||||
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||
|
||||
-- Speed up OTP/magic-link/reset token lookup in FindValid queries.
|
||||
-- The existing partial index on (email, purpose, expires_at) doesn't cover the code column,
|
||||
-- so queries filtering on code must scan all matching rows.
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes (code)
|
||||
WHERE used_at IS NULL;
|
||||
@ -0,0 +1,22 @@
|
||||
-- 003_create_media_objects.sql
|
||||
-- Media metadata table for tracking uploads, generation provenance, and soft deletes.
|
||||
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
generation_job_id TEXT NOT NULL DEFAULT '',
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_objects_user_id ON media_objects (user_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_objects_generation_job ON media_objects (generation_job_id)
|
||||
WHERE generation_job_id != '';
|
||||
9
services/persona-api/component.yaml
Normal file
9
services/persona-api/component.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
name: persona-api
|
||||
type: service
|
||||
port: 8001
|
||||
path: services/persona-api
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - other-service
|
||||
8
services/persona-api/go.mod
Normal file
8
services/persona-api/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module git.threesix.ai/jordan/persona-community-2/services/persona-api
|
||||
|
||||
go 1.25
|
||||
|
||||
require git.threesix.ai/jordan/persona-community-2/pkg v0.0.0
|
||||
|
||||
// Use local workspace modules (for Docker builds without go.work)
|
||||
replace git.threesix.ai/jordan/persona-community-2/pkg => ../../pkg
|
||||
0
services/persona-api/go.sum
Normal file
0
services/persona-api/go.sum
Normal file
32
services/persona-api/internal/adapter/email/log.go
Normal file
32
services/persona-api/internal/adapter/email/log.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Package email provides email sending adapters for authentication flows.
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.EmailSender = (*LogSender)(nil)
|
||||
|
||||
// LogSender logs emails to the console instead of sending them.
|
||||
// Useful for development and testing when no notify service is configured.
|
||||
type LogSender struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewLogSender creates a new log-based email sender.
|
||||
func NewLogSender(logger *logging.Logger) *LogSender {
|
||||
return &LogSender{logger: logger.WithComponent("EmailSender")}
|
||||
}
|
||||
|
||||
func (s *LogSender) SendAuthCode(_ context.Context, email, code, purpose string) error {
|
||||
s.logger.Warn("DEV MODE — email not sent, code logged",
|
||||
"to", email,
|
||||
"purpose", purpose,
|
||||
"code", code,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
112
services/persona-api/internal/adapter/email/notify.go
Normal file
112
services/persona-api/internal/adapter/email/notify.go
Normal file
@ -0,0 +1,112 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/notify"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.EmailSender = (*NotifySender)(nil)
|
||||
|
||||
// NotifySender sends transactional emails via the orchard9 notify service.
|
||||
// It renders HTML using the email.Renderer before sending so every email
|
||||
// has a styled layout with inline CSS.
|
||||
type NotifySender struct {
|
||||
client *notify.Client
|
||||
renderer *emailpkg.Renderer
|
||||
host string
|
||||
from string
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewNotifySender creates a notify-backed email sender with HTML rendering.
|
||||
func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender {
|
||||
return &NotifySender{
|
||||
client: client,
|
||||
renderer: renderer,
|
||||
host: host,
|
||||
from: from,
|
||||
logger: logger.WithComponent("EmailSender"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error {
|
||||
// Map (purpose, code) to the correct template context.
|
||||
emailCtx := purposeToContext(purpose, code)
|
||||
|
||||
rendered, err := s.renderer.Render(purpose, emailCtx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to render email template", "purpose", purpose, "error", err)
|
||||
return fmt.Errorf("render email %s: %w", purpose, err)
|
||||
}
|
||||
if rendered.CSSInlineErr != nil {
|
||||
s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients",
|
||||
"purpose", purpose, "error", rendered.CSSInlineErr)
|
||||
}
|
||||
|
||||
resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{
|
||||
To: toEmail,
|
||||
From: s.from,
|
||||
Content: notify.Content{
|
||||
Subject: rendered.Subject,
|
||||
HTML: rendered.HTML,
|
||||
Text: rendered.PlainText,
|
||||
},
|
||||
Meta: notify.Meta{
|
||||
Host: s.host,
|
||||
Category: "critical",
|
||||
Tags: []string{"auth", purpose},
|
||||
},
|
||||
Options: notify.Options{
|
||||
// Stable idempotency key: same user + same code = same key, safe to retry.
|
||||
IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("failed to send email via notify", "to", toEmail, "purpose", purpose, "error", err)
|
||||
return fmt.Errorf("send email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// purposeToContext maps (purpose, code) to an EmailContext for template rendering.
|
||||
// code may be an OTP digit string or a URL depending on the purpose.
|
||||
func purposeToContext(purpose, code string) emailpkg.EmailContext {
|
||||
switch purpose {
|
||||
case "login_otp":
|
||||
return emailpkg.EmailContext{
|
||||
Code: code,
|
||||
ExpiresIn: 10,
|
||||
Purpose: "sign in",
|
||||
}
|
||||
case "magic_link":
|
||||
return emailpkg.EmailContext{
|
||||
ActionURL: emailpkg.SafeURL(code),
|
||||
ButtonText: "Sign In \u2192",
|
||||
ExpiresIn: 15,
|
||||
}
|
||||
case "password_reset":
|
||||
return emailpkg.EmailContext{
|
||||
ActionURL: emailpkg.SafeURL(code),
|
||||
ButtonText: "Reset Password \u2192",
|
||||
ExpiresIn: 60,
|
||||
}
|
||||
case "email_verify":
|
||||
return emailpkg.EmailContext{
|
||||
Code: code,
|
||||
ExpiresIn: 30,
|
||||
Purpose: "verify your email",
|
||||
}
|
||||
default:
|
||||
return emailpkg.EmailContext{
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
}
|
||||
177
services/persona-api/internal/adapter/memory/album.go
Normal file
177
services/persona-api/internal/adapter/memory/album.go
Normal file
@ -0,0 +1,177 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/album"
|
||||
)
|
||||
|
||||
// AlbumRepository is an in-memory implementation of port.AlbumRepository.
|
||||
// Used in standalone dev mode (no DATABASE_URL). Not safe for persistence across restarts.
|
||||
type AlbumRepository struct {
|
||||
mu sync.RWMutex
|
||||
albums map[album.AlbumID]*album.Album
|
||||
}
|
||||
|
||||
// NewAlbumRepository creates an in-memory album repository.
|
||||
func NewAlbumRepository() *AlbumRepository {
|
||||
return &AlbumRepository{
|
||||
albums: make(map[album.AlbumID]*album.Album),
|
||||
}
|
||||
}
|
||||
|
||||
// Create persists a new album. The caller must set ID, Name, SubjectDesc, Shots before calling.
|
||||
func (r *AlbumRepository) Create(ctx context.Context, a *album.Album) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
a.CreatedAt = now
|
||||
a.UpdatedAt = now
|
||||
copy := *a
|
||||
r.albums[a.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an album by ID and userID. Returns error if not found or wrong user.
|
||||
func (r *AlbumRepository) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return nil, fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
copy := *a
|
||||
shots := make([]album.Shot, len(a.Shots))
|
||||
copy.Shots = shots
|
||||
for i, s := range a.Shots {
|
||||
shots[i] = s
|
||||
}
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
// List returns all albums for a user, ordered by CreatedAt DESC.
|
||||
func (r *AlbumRepository) List(ctx context.Context, userID string) ([]album.Album, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []album.Album
|
||||
for _, a := range r.albums {
|
||||
if a.UserID != userID {
|
||||
continue
|
||||
}
|
||||
copy := *a
|
||||
shots := make([]album.Shot, len(a.Shots))
|
||||
for i, s := range a.Shots {
|
||||
shots[i] = s
|
||||
}
|
||||
copy.Shots = shots
|
||||
result = append(result, copy)
|
||||
}
|
||||
// Sort by CreatedAt DESC (simple insertion sort — in-memory is small).
|
||||
for i := 1; i < len(result); i++ {
|
||||
for j := i; j > 0 && result[j].CreatedAt.After(result[j-1].CreatedAt); j-- {
|
||||
result[j], result[j-1] = result[j-1], result[j]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Delete removes an album by ID and userID.
|
||||
func (r *AlbumRepository) Delete(ctx context.Context, id album.AlbumID, userID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
delete(r.albums, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAnchor stores the generated anchor URL.
|
||||
func (r *AlbumRepository) UpdateAnchor(ctx context.Context, id album.AlbumID, userID, anchorURL, anchorJobID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
a.AnchorURL = anchorURL
|
||||
a.AnchorJobID = anchorJobID
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateShot stores the generated image URL and status for a specific shot.
|
||||
func (r *AlbumRepository) UpdateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int, imageURL string, status album.ShotStatus, shotError string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
if shotIndex < 0 || shotIndex >= len(a.Shots) {
|
||||
return fmt.Errorf("shot index out of range: %d", shotIndex)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
a.Shots[shotIndex].ImageURL = imageURL
|
||||
a.Shots[shotIndex].Status = status
|
||||
a.Shots[shotIndex].Error = shotError
|
||||
if status == album.ShotComplete {
|
||||
a.Shots[shotIndex].GeneratedAt = &now
|
||||
}
|
||||
a.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetShot clears a shot back to pending.
|
||||
func (r *AlbumRepository) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
if shotIndex < 0 || shotIndex >= len(a.Shots) {
|
||||
return fmt.Errorf("shot index out of range: %d", shotIndex)
|
||||
}
|
||||
a.Shots[shotIndex].ImageURL = ""
|
||||
a.Shots[shotIndex].JobID = ""
|
||||
a.Shots[shotIndex].Status = album.ShotPending
|
||||
a.Shots[shotIndex].Error = ""
|
||||
a.Shots[shotIndex].GeneratedAt = nil
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAnchorJobID stores the anchor job ID when the anchor generation is enqueued.
|
||||
func (r *AlbumRepository) UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
a.AnchorJobID = jobID
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateShotJobID stores the job ID for a shot when its generation is enqueued.
|
||||
func (r *AlbumRepository) UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.albums[id]
|
||||
if !ok || a.UserID != userID {
|
||||
return fmt.Errorf("album not found: %s", id)
|
||||
}
|
||||
if shotIndex < 0 || shotIndex >= len(a.Shots) {
|
||||
return fmt.Errorf("shot index out of range: %d", shotIndex)
|
||||
}
|
||||
a.Shots[shotIndex].JobID = jobID
|
||||
a.Shots[shotIndex].Status = album.ShotGenerating
|
||||
a.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
87
services/persona-api/internal/adapter/memory/auth_code.go
Normal file
87
services/persona-api/internal/adapter/memory/auth_code.go
Normal file
@ -0,0 +1,87 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil)
|
||||
|
||||
// AuthCodeRepository is an in-memory auth code store for standalone development.
|
||||
type AuthCodeRepository struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*domain.AuthCode
|
||||
}
|
||||
|
||||
// NewAuthCodeRepository creates a new in-memory auth code repository.
|
||||
func NewAuthCodeRepository() *AuthCodeRepository {
|
||||
return &AuthCodeRepository{
|
||||
codes: make(map[string]*domain.AuthCode),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) Create(_ context.Context, code *domain.AuthCode) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cp := *code
|
||||
r.codes[code.ID] = &cp
|
||||
|
||||
// In standalone dev mode the code lives only in memory and is lost on restart.
|
||||
// Always log it so the developer can copy-paste the code from the terminal
|
||||
// even when NOTIFY_URL is set and an email is also being delivered.
|
||||
slog.Warn("[DEV] auth code created — use this code to log in",
|
||||
"email", code.Email,
|
||||
"purpose", code.Purpose,
|
||||
"code", code.Code,
|
||||
"expires_at", code.ExpiresAt.Format("15:04:05"),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) FindValid(_ context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, c := range r.codes {
|
||||
if c.Email == email && c.Code == code && c.Purpose == purpose && c.IsValid() {
|
||||
cp := *c
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrInvalidAuthCode
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) MarkUsed(_ context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
c, ok := r.codes[id]
|
||||
if !ok {
|
||||
return domain.ErrInvalidAuthCode
|
||||
}
|
||||
now := time.Now()
|
||||
c.UsedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) DeleteExpired(_ context.Context) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
deleted := 0
|
||||
for id, c := range r.codes {
|
||||
if now.After(c.ExpiresAt) {
|
||||
delete(r.codes, id)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
106
services/persona-api/internal/adapter/memory/example.go
Normal file
106
services/persona-api/internal/adapter/memory/example.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Package memory provides in-memory implementations of repository interfaces.
|
||||
// Useful for development, testing, and prototyping.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
|
||||
var _ port.ExampleRepository = (*ExampleRepository)(nil)
|
||||
|
||||
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
|
||||
type ExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
// NewExampleRepository creates a new in-memory example repository.
|
||||
func NewExampleRepository() *ExampleRepository {
|
||||
return &ExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(r.examples))
|
||||
for _, e := range r.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
e, ok := r.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to prevent external mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
// Create stores a new example.
|
||||
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy to prevent external mutation
|
||||
copy := *example
|
||||
r.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(r.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, e := range r.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
135
services/persona-api/internal/adapter/memory/media.go
Normal file
135
services/persona-api/internal/adapter/memory/media.go
Normal file
@ -0,0 +1,135 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.MediaRepository = (*MediaRepository)(nil)
|
||||
|
||||
// MediaRepository is an in-memory media metadata store for standalone development.
|
||||
type MediaRepository struct {
|
||||
mu sync.RWMutex
|
||||
objects map[domain.MediaObjectID]*domain.MediaObject
|
||||
byPath map[string]domain.MediaObjectID
|
||||
}
|
||||
|
||||
// NewMediaRepository creates a new in-memory media repository.
|
||||
func NewMediaRepository() *MediaRepository {
|
||||
return &MediaRepository{
|
||||
objects: make(map[domain.MediaObjectID]*domain.MediaObject),
|
||||
byPath: make(map[string]domain.MediaObjectID),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MediaRepository) copyObject(obj *domain.MediaObject) *domain.MediaObject {
|
||||
cp := *obj
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (r *MediaRepository) Create(_ context.Context, obj *domain.MediaObject) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.objects[obj.ID] = r.copyObject(obj)
|
||||
r.byPath[obj.Path] = obj.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) Get(_ context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
obj, ok := r.objects[id]
|
||||
if !ok || obj.DeletedAt != nil {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return r.copyObject(obj), nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) ListByUser(_ context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var all []domain.MediaObject
|
||||
for _, obj := range r.objects {
|
||||
if obj.UserID != userID || obj.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
if opts.ContentTypePrefix != "" && !strings.HasPrefix(obj.ContentType, opts.ContentTypePrefix) {
|
||||
continue
|
||||
}
|
||||
all = append(all, *r.copyObject(obj))
|
||||
}
|
||||
|
||||
// Sort by created_at DESC
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
})
|
||||
|
||||
total := len(all)
|
||||
|
||||
// Apply pagination
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset > len(all) {
|
||||
offset = len(all)
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
|
||||
return all[offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) SoftDelete(_ context.Context, id domain.MediaObjectID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
obj, ok := r.objects[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
obj.DeletedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) HardDelete(_ context.Context, id domain.MediaObjectID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
obj, ok := r.objects[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
delete(r.byPath, obj.Path)
|
||||
delete(r.objects, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaRepository) GetByPath(_ context.Context, path string) (*domain.MediaObject, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
id, ok := r.byPath[path]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
obj, ok := r.objects[id]
|
||||
if !ok || obj.DeletedAt != nil {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return r.copyObject(obj), nil
|
||||
}
|
||||
120
services/persona-api/internal/adapter/memory/session.go
Normal file
120
services/persona-api/internal/adapter/memory/session.go
Normal file
@ -0,0 +1,120 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.SessionRepository = (*SessionRepository)(nil)
|
||||
|
||||
// SessionRepository is an in-memory session store for standalone development.
|
||||
type SessionRepository struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[domain.SessionID]*domain.Session
|
||||
}
|
||||
|
||||
// NewSessionRepository creates a new in-memory session repository.
|
||||
func NewSessionRepository() *SessionRepository {
|
||||
return &SessionRepository{
|
||||
sessions: make(map[domain.SessionID]*domain.Session),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SessionRepository) copySession(s *domain.Session) *domain.Session {
|
||||
cp := *s
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(_ context.Context, session *domain.Session) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.sessions[session.ID] = r.copySession(session)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
s, ok := r.sessions[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
return r.copySession(s), nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) ListByUser(_ context.Context, userID domain.UserID) ([]domain.Session, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
var result []domain.Session
|
||||
for _, s := range r.sessions {
|
||||
if s.UserID == userID && s.RevokedAt == nil && s.ExpiresAt.After(now) {
|
||||
result = append(result, *r.copySession(s))
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) UpdateLastActive(_ context.Context, id domain.SessionID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
s, ok := r.sessions[id]
|
||||
if !ok {
|
||||
return domain.ErrSessionNotFound
|
||||
}
|
||||
s.LastActiveAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Revoke(_ context.Context, id domain.SessionID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
s, ok := r.sessions[id]
|
||||
if !ok {
|
||||
return domain.ErrSessionNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
s.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) RevokeAllForUser(_ context.Context, userID domain.UserID, exceptID *domain.SessionID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for _, s := range r.sessions {
|
||||
if s.UserID == userID && s.RevokedAt == nil {
|
||||
if exceptID != nil && s.ID == *exceptID {
|
||||
continue
|
||||
}
|
||||
s.RevokedAt = &now
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteExpired(_ context.Context) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
deleted := 0
|
||||
for id, s := range r.sessions {
|
||||
if now.After(s.ExpiresAt) {
|
||||
delete(r.sessions, id)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
243
services/persona-api/internal/adapter/memory/user.go
Normal file
243
services/persona-api/internal/adapter/memory/user.go
Normal file
@ -0,0 +1,243 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.UserRepository = (*UserRepository)(nil)
|
||||
|
||||
// UserRepository is an in-memory user store with bcrypt password hashing.
|
||||
// Pre-populated with demo users for standalone development.
|
||||
type UserRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[domain.UserID]*domain.User
|
||||
passwords map[domain.UserID]string // bcrypt hashes
|
||||
roles map[domain.UserID][]string // role lists
|
||||
byEmail map[string]domain.UserID // email → user ID index
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new in-memory user repository seeded with demo users.
|
||||
// If devEmail is non-empty, an additional user is seeded with that email and devPassword
|
||||
// so the developer's account survives server restarts without re-registering.
|
||||
func NewUserRepository(devEmail, devPassword string) *UserRepository {
|
||||
repo := &UserRepository{
|
||||
users: make(map[domain.UserID]*domain.User),
|
||||
passwords: make(map[domain.UserID]string),
|
||||
roles: make(map[domain.UserID][]string),
|
||||
byEmail: make(map[string]domain.UserID),
|
||||
}
|
||||
|
||||
// Seed demo users with bcrypt-hashed passwords.
|
||||
// Passwords meet complexity requirements (min 8 chars, uppercase, lowercase, digit).
|
||||
repo.seedUser("usr_test_001", "test@example.com", "Test User", "Password123", []string{"user"})
|
||||
repo.seedUser("usr_admin_001", "admin@example.com", "Admin User", "Admin1234", []string{"admin", "user"})
|
||||
|
||||
// Seed the developer's own account if DEV_USER_EMAIL is configured.
|
||||
// This ensures the email is always registered after restarts without manual re-registration.
|
||||
if devEmail != "" {
|
||||
repo.seedUser("usr_dev_001", devEmail, "Dev User", devPassword, []string{"admin", "user"})
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *UserRepository) seedUser(id, email, name, password string, userRoles []string) {
|
||||
uid := domain.UserID(id)
|
||||
now := time.Now()
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
panic("failed to hash seed password: " + err.Error())
|
||||
}
|
||||
|
||||
r.users[uid] = &domain.User{
|
||||
ID: uid,
|
||||
Email: email,
|
||||
EmailVerified: true,
|
||||
Name: name,
|
||||
Status: domain.UserStatusActive,
|
||||
Roles: userRoles,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.passwords[uid] = hash
|
||||
r.roles[uid] = userRoles
|
||||
r.byEmail[email] = uid
|
||||
}
|
||||
|
||||
func (r *UserRepository) copyUser(u *domain.User) *domain.User {
|
||||
cp := *u
|
||||
cp.Roles = make([]string, len(u.Roles))
|
||||
copy(cp.Roles, u.Roles)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(_ context.Context, user *domain.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.byEmail[user.Email]; exists {
|
||||
return domain.ErrDuplicateEmail
|
||||
}
|
||||
|
||||
r.users[user.ID] = r.copyUser(user)
|
||||
r.byEmail[user.Email] = user.ID
|
||||
r.roles[user.ID] = make([]string, len(user.Roles))
|
||||
copy(r.roles[user.ID], user.Roles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Get(_ context.Context, id domain.UserID) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
return r.copyUser(u), nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(_ context.Context, email string) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
uid, ok := r.byEmail[email]
|
||||
if !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
return r.copyUser(r.users[uid]), nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(_ context.Context, user *domain.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
existing, ok := r.users[user.ID]
|
||||
if !ok {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
// If email changed, update the index.
|
||||
if existing.Email != user.Email {
|
||||
if _, taken := r.byEmail[user.Email]; taken {
|
||||
return domain.ErrDuplicateEmail
|
||||
}
|
||||
delete(r.byEmail, existing.Email)
|
||||
r.byEmail[user.Email] = user.ID
|
||||
}
|
||||
|
||||
user.UpdatedAt = time.Now()
|
||||
r.users[user.ID] = r.copyUser(user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLogin(_ context.Context, id domain.UserID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
u.LastLoginAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) ExistsByEmail(_ context.Context, email string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
_, ok := r.byEmail[email]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) SetPassword(_ context.Context, userID domain.UserID, hash string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.users[userID]; !ok {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
r.passwords[userID] = hash
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetPasswordHash(_ context.Context, userID domain.UserID) (string, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
hash := r.passwords[userID]
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) HasPassword(_ context.Context, userID domain.UserID) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
_, ok := r.passwords[userID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) AddRole(_ context.Context, userID domain.UserID, role string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
u, ok := r.users[userID]
|
||||
if !ok {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
for _, existing := range r.roles[userID] {
|
||||
if existing == role {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r.roles[userID] = append(r.roles[userID], role)
|
||||
u.Roles = make([]string, len(r.roles[userID]))
|
||||
copy(u.Roles, r.roles[userID])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) RemoveRole(_ context.Context, userID domain.UserID, role string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
u, ok := r.users[userID]
|
||||
if !ok {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(r.roles[userID]))
|
||||
for _, existing := range r.roles[userID] {
|
||||
if existing != role {
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
}
|
||||
r.roles[userID] = filtered
|
||||
u.Roles = make([]string, len(filtered))
|
||||
copy(u.Roles, filtered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetRoles(_ context.Context, userID domain.UserID) ([]string, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if _, ok := r.users[userID]; !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
roles := r.roles[userID]
|
||||
result := make([]string, len(roles))
|
||||
copy(result, roles)
|
||||
return result, nil
|
||||
}
|
||||
120
services/persona-api/internal/adapter/postgres/auth_code.go
Normal file
120
services/persona-api/internal/adapter/postgres/auth_code.go
Normal file
@ -0,0 +1,120 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.AuthCodeRepository = (*AuthCodeRepository)(nil)
|
||||
|
||||
// authCodeRow maps to the auth_codes table.
|
||||
type authCodeRow struct {
|
||||
ID string `db:"id"`
|
||||
UserID *string `db:"user_id"`
|
||||
Email string `db:"email"`
|
||||
Code string `db:"code"`
|
||||
Purpose string `db:"purpose"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
UsedAt *time.Time `db:"used_at"`
|
||||
IPAddress string `db:"ip_address"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (r *authCodeRow) toDomain() *domain.AuthCode {
|
||||
ac := &domain.AuthCode{
|
||||
ID: r.ID,
|
||||
Email: r.Email,
|
||||
Code: r.Code,
|
||||
Purpose: domain.AuthCodePurpose(r.Purpose),
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
UsedAt: r.UsedAt,
|
||||
IPAddress: r.IPAddress,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
if r.UserID != nil {
|
||||
uid := domain.UserID(*r.UserID)
|
||||
ac.UserID = &uid
|
||||
}
|
||||
return ac
|
||||
}
|
||||
|
||||
// AuthCodeRepository implements port.AuthCodeRepository with PostgreSQL/CockroachDB.
|
||||
type AuthCodeRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewAuthCodeRepository creates a new Postgres-backed auth code repository.
|
||||
func NewAuthCodeRepository(db *sqlx.DB) *AuthCodeRepository {
|
||||
return &AuthCodeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) Create(ctx context.Context, code *domain.AuthCode) error {
|
||||
var userID *string
|
||||
if code.UserID != nil {
|
||||
s := string(*code.UserID)
|
||||
userID = &s
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO auth_codes (id, user_id, email, code, purpose, expires_at, ip_address, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, code.ID, userID, code.Email, code.Code, string(code.Purpose),
|
||||
code.ExpiresAt, code.IPAddress, code.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert auth code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error) {
|
||||
var row authCodeRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, user_id, email, code, purpose, expires_at, used_at, ip_address, created_at
|
||||
FROM auth_codes
|
||||
WHERE email = $1 AND code = $2 AND purpose = $3
|
||||
AND used_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, email, code, string(purpose))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrInvalidAuthCode
|
||||
}
|
||||
return nil, fmt.Errorf("find valid auth code: %w", err)
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) MarkUsed(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE auth_codes SET used_at = NOW() WHERE id = $1
|
||||
`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark auth code used: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuthCodeRepository) DeleteExpired(ctx context.Context) (int, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM auth_codes WHERE expires_at < NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired auth codes: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired rows affected: %w", err)
|
||||
}
|
||||
return int(rows), nil
|
||||
}
|
||||
184
services/persona-api/internal/adapter/postgres/media.go
Normal file
184
services/persona-api/internal/adapter/postgres/media.go
Normal file
@ -0,0 +1,184 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.MediaRepository = (*MediaObjectRepository)(nil)
|
||||
|
||||
// mediaObjectRow maps to the media_objects table.
|
||||
type mediaObjectRow struct {
|
||||
ID string `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
Path string `db:"path"`
|
||||
Filename string `db:"filename"`
|
||||
ContentType string `db:"content_type"`
|
||||
Size int64 `db:"size"`
|
||||
GenerationJobID string `db:"generation_job_id"`
|
||||
DeletedAt *time.Time `db:"deleted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (r *mediaObjectRow) toDomain() *domain.MediaObject {
|
||||
return &domain.MediaObject{
|
||||
ID: domain.MediaObjectID(r.ID),
|
||||
UserID: domain.UserID(r.UserID),
|
||||
Path: r.Path,
|
||||
Filename: r.Filename,
|
||||
ContentType: r.ContentType,
|
||||
Size: r.Size,
|
||||
GenerationJobID: r.GenerationJobID,
|
||||
DeletedAt: r.DeletedAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// MediaObjectRepository implements port.MediaRepository with PostgreSQL/CockroachDB.
|
||||
type MediaObjectRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewMediaObjectRepository creates a new Postgres-backed media repository.
|
||||
func NewMediaObjectRepository(db *sqlx.DB) *MediaObjectRepository {
|
||||
return &MediaObjectRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) Create(ctx context.Context, obj *domain.MediaObject) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO media_objects (id, user_id, path, filename, content_type, size, generation_job_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, string(obj.ID), string(obj.UserID), obj.Path, obj.Filename, obj.ContentType,
|
||||
obj.Size, obj.GenerationJobID, obj.CreatedAt, obj.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert media object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) {
|
||||
var row mediaObjectRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
|
||||
FROM media_objects WHERE id = $1 AND deleted_at IS NULL
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get media object: %w", err)
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) ListByUser(ctx context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
// Count total matching records
|
||||
countQuery := `SELECT COUNT(*) FROM media_objects WHERE user_id = $1 AND deleted_at IS NULL`
|
||||
args := []any{string(userID)}
|
||||
argIdx := 2
|
||||
|
||||
if opts.ContentTypePrefix != "" {
|
||||
countQuery += fmt.Sprintf(` AND content_type LIKE $%d`, argIdx)
|
||||
args = append(args, opts.ContentTypePrefix+"%")
|
||||
argIdx++
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
|
||||
return nil, 0, fmt.Errorf("count media objects: %w", err)
|
||||
}
|
||||
|
||||
// Fetch paginated results
|
||||
query := `
|
||||
SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
|
||||
FROM media_objects
|
||||
WHERE user_id = $1 AND deleted_at IS NULL`
|
||||
|
||||
fetchArgs := []any{string(userID)}
|
||||
fetchIdx := 2
|
||||
|
||||
if opts.ContentTypePrefix != "" {
|
||||
query += fmt.Sprintf(` AND content_type LIKE $%d`, fetchIdx)
|
||||
fetchArgs = append(fetchArgs, opts.ContentTypePrefix+"%")
|
||||
fetchIdx++
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`
|
||||
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, fetchIdx, fetchIdx+1)
|
||||
fetchArgs = append(fetchArgs, limit, opts.Offset)
|
||||
|
||||
var rows []mediaObjectRow
|
||||
if err := r.db.SelectContext(ctx, &rows, query, fetchArgs...); err != nil {
|
||||
return nil, 0, fmt.Errorf("list media objects: %w", err)
|
||||
}
|
||||
|
||||
objects := make([]domain.MediaObject, len(rows))
|
||||
for i := range rows {
|
||||
objects[i] = *rows[i].toDomain()
|
||||
}
|
||||
return objects, total, nil
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) SoftDelete(ctx context.Context, id domain.MediaObjectID) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE media_objects SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete media object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) HardDelete(ctx context.Context, id domain.MediaObjectID) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM media_objects WHERE id = $1`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("hard delete media object: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("hard delete rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MediaObjectRepository) GetByPath(ctx context.Context, path string) (*domain.MediaObject, error) {
|
||||
var row mediaObjectRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at
|
||||
FROM media_objects WHERE path = $1 AND deleted_at IS NULL
|
||||
`, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get media object by path: %w", err)
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
162
services/persona-api/internal/adapter/postgres/session.go
Normal file
162
services/persona-api/internal/adapter/postgres/session.go
Normal file
@ -0,0 +1,162 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.SessionRepository = (*SessionRepository)(nil)
|
||||
|
||||
// sessionRow maps to the sessions table.
|
||||
type sessionRow struct {
|
||||
ID string `db:"id"`
|
||||
UserID string `db:"user_id"`
|
||||
IPAddress string `db:"ip_address"`
|
||||
UserAgent string `db:"user_agent"`
|
||||
DeviceLabel string `db:"device_label"`
|
||||
LastActiveAt time.Time `db:"last_active_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
RevokedAt *time.Time `db:"revoked_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (r *sessionRow) toDomain() *domain.Session {
|
||||
return &domain.Session{
|
||||
ID: domain.SessionID(r.ID),
|
||||
UserID: domain.UserID(r.UserID),
|
||||
IPAddress: r.IPAddress,
|
||||
UserAgent: r.UserAgent,
|
||||
DeviceLabel: r.DeviceLabel,
|
||||
LastActiveAt: r.LastActiveAt,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
RevokedAt: r.RevokedAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// SessionRepository implements port.SessionRepository with PostgreSQL/CockroachDB.
|
||||
type SessionRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewSessionRepository creates a new Postgres-backed session repository.
|
||||
func NewSessionRepository(db *sqlx.DB) *SessionRepository {
|
||||
return &SessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO sessions (id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, string(session.ID), string(session.UserID), session.IPAddress, session.UserAgent,
|
||||
session.DeviceLabel, session.LastActiveAt, session.ExpiresAt, session.CreatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||
var row sessionRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at
|
||||
FROM sessions WHERE id = $1
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error) {
|
||||
var rows []sessionRow
|
||||
err := r.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, user_id, ip_address, user_agent, device_label, last_active_at, expires_at, revoked_at, created_at
|
||||
FROM sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY last_active_at DESC
|
||||
`, string(userID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
|
||||
sessions := make([]domain.Session, len(rows))
|
||||
for i := range rows {
|
||||
sessions[i] = *rows[i].toDomain()
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) UpdateLastActive(ctx context.Context, id domain.SessionID) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sessions SET last_active_at = NOW() WHERE id = $1
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last active: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) Revoke(ctx context.Context, id domain.SessionID) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke session: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke session rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrSessionNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error {
|
||||
if exceptID != nil {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sessions SET revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND id != $2
|
||||
`, string(userID), string(*exceptID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke all sessions except: %w", err)
|
||||
}
|
||||
} else {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sessions SET revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
`, string(userID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke all sessions: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepository) DeleteExpired(ctx context.Context) (int, error) {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < NOW()`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired sessions: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired sessions rows: %w", err)
|
||||
}
|
||||
return int(rows), nil
|
||||
}
|
||||
260
services/persona-api/internal/adapter/postgres/user.go
Normal file
260
services/persona-api/internal/adapter/postgres/user.go
Normal file
@ -0,0 +1,260 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ port.UserRepository = (*UserRepository)(nil)
|
||||
|
||||
// userRow maps to the users table.
|
||||
type userRow struct {
|
||||
ID string `db:"id"`
|
||||
Email string `db:"email"`
|
||||
EmailVerified bool `db:"email_verified"`
|
||||
Name string `db:"name"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
Status string `db:"status"`
|
||||
LastLoginAt *time.Time `db:"last_login_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (r *userRow) toDomain(roles []string) *domain.User {
|
||||
return &domain.User{
|
||||
ID: domain.UserID(r.ID),
|
||||
Email: r.Email,
|
||||
EmailVerified: r.EmailVerified,
|
||||
Name: r.Name,
|
||||
AvatarURL: r.AvatarURL,
|
||||
Status: domain.UserStatus(r.Status),
|
||||
Roles: roles,
|
||||
LastLoginAt: r.LastLoginAt,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// UserRepository implements port.UserRepository with PostgreSQL/CockroachDB.
|
||||
type UserRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new Postgres-backed user repository.
|
||||
func NewUserRepository(db *sqlx.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, email_verified, name, avatar_url, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, string(user.ID), user.Email, user.EmailVerified, user.Name, user.AvatarURL,
|
||||
string(user.Status), user.CreatedAt, user.UpdatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrDuplicateEmail
|
||||
}
|
||||
return fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
// Insert roles
|
||||
for _, role := range user.Roles {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO user_roles (user_id, role) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, role) DO NOTHING
|
||||
`, string(user.ID), role); err != nil {
|
||||
return fmt.Errorf("insert role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) {
|
||||
var row userRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
roles, err := r.GetRoles(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return row.toDomain(roles), nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
var row userRow
|
||||
err := r.db.GetContext(ctx, &row, `
|
||||
SELECT id, email, email_verified, name, avatar_url, status, last_login_at, created_at, updated_at
|
||||
FROM users WHERE email = $1
|
||||
`, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get user by email: %w", err)
|
||||
}
|
||||
|
||||
roles, err := r.GetRoles(ctx, domain.UserID(row.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return row.toDomain(roles), nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET email = $2, email_verified = $3, name = $4, avatar_url = $5,
|
||||
status = $6, updated_at = $7
|
||||
WHERE id = $1
|
||||
`, string(user.ID), user.Email, user.EmailVerified, user.Name,
|
||||
user.AvatarURL, string(user.Status), time.Now())
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrDuplicateEmail
|
||||
}
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id domain.UserID) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users SET last_login_at = NOW() WHERE id = $1
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last login: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last login rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.GetContext(ctx, &exists, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("exists by email: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) SetPassword(ctx context.Context, userID domain.UserID, hash string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO user_passwords (user_id, password_hash, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET password_hash = $2, updated_at = NOW()
|
||||
`, string(userID), hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error) {
|
||||
var hash string
|
||||
err := r.db.GetContext(ctx, &hash, `
|
||||
SELECT password_hash FROM user_passwords WHERE user_id = $1
|
||||
`, string(userID))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("get password hash: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) HasPassword(ctx context.Context, userID domain.UserID) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.GetContext(ctx, &exists, `
|
||||
SELECT EXISTS(SELECT 1 FROM user_passwords WHERE user_id = $1)
|
||||
`, string(userID))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("has password: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) AddRole(ctx context.Context, userID domain.UserID, role string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO user_roles (user_id, role) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, role) DO NOTHING
|
||||
`, string(userID), role)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add role: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) RemoveRole(ctx context.Context, userID domain.UserID, role string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM user_roles WHERE user_id = $1 AND role = $2
|
||||
`, string(userID), role)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove role: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetRoles(ctx context.Context, userID domain.UserID) ([]string, error) {
|
||||
var roles []string
|
||||
err := r.db.SelectContext(ctx, &roles, `
|
||||
SELECT role FROM user_roles WHERE user_id = $1 ORDER BY role
|
||||
`, string(userID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get roles: %w", err)
|
||||
}
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// isUniqueViolation checks if a database error is a unique constraint violation.
|
||||
// Works with both PostgreSQL (23505) and CockroachDB.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) {
|
||||
return pqErr.Code == "23505"
|
||||
}
|
||||
return false
|
||||
}
|
||||
291
services/persona-api/internal/api/handlers/album.go
Normal file
291
services/persona-api/internal/api/handlers/album.go
Normal file
@ -0,0 +1,291 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/album"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// Album handles HTTP requests for album CRUD and generation endpoints.
|
||||
// All generation endpoints are async: they enqueue a job and return 202.
|
||||
// Results arrive via SSE events on the user:<userId> channel.
|
||||
type Album struct {
|
||||
albums *service.AlbumService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewAlbum creates a new Album handler.
|
||||
func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album {
|
||||
return &Album{
|
||||
albums: albums,
|
||||
logger: logger.WithComponent("AlbumHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request/response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateAlbumRequest is the request body for POST /albums.
|
||||
type CreateAlbumRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"`
|
||||
Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"`
|
||||
TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character"
|
||||
}
|
||||
|
||||
// ShotTemplateBody is a single shot spec in the create request.
|
||||
type ShotTemplateBody struct {
|
||||
Label string `json:"label" validate:"required"`
|
||||
Direction string `json:"direction" validate:"required"`
|
||||
}
|
||||
|
||||
// AlbumJobResponse is the response for generation enqueue endpoints.
|
||||
type AlbumJobResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// AlbumJobsResponse is the response for bulk generation enqueue.
|
||||
type AlbumJobsResponse struct {
|
||||
JobIDs []string `json:"jobIds"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Create handles POST /albums — creates a new album with shot specs.
|
||||
func (h *Album) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
var req CreateAlbumRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If a template set was provided, use it (overrides explicit shots).
|
||||
var shots []album.ShotTemplate
|
||||
if req.TemplateSet != "" {
|
||||
set, ok := album.ShotTemplateSets[req.TemplateSet]
|
||||
if !ok {
|
||||
return httperror.BadRequest("unknown template set: " + req.TemplateSet)
|
||||
}
|
||||
shots = set
|
||||
} else {
|
||||
// Convert body shots to ShotTemplate.
|
||||
shots = make([]album.ShotTemplate, len(req.Shots))
|
||||
for i, s := range req.Shots {
|
||||
shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction}
|
||||
}
|
||||
}
|
||||
|
||||
a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create album", "error", err, "user_id", user.ID)
|
||||
return httperror.BadRequest(err.Error())
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List handles GET /albums — returns all albums for the authenticated user.
|
||||
func (h *Album) List(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
albums, err := h.albums.List(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list albums", "error", err, "user_id", user.ID)
|
||||
return httperror.Internal("failed to list albums")
|
||||
}
|
||||
|
||||
if albums == nil {
|
||||
albums = []album.Album{}
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, albums)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get handles GET /albums/{id} — returns a single album with all shot statuses.
|
||||
func (h *Album) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
a, err := h.albums.Get(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
return httperror.NotFound("album not found")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete handles DELETE /albums/{id} — deletes an album.
|
||||
func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
if err := h.albums.Delete(r.Context(), id, user.ID); err != nil {
|
||||
return httperror.NotFound("album not found")
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation (async — returns 202)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation.
|
||||
// Returns 202 with job ID. Result arrives via album_anchor_complete SSE event.
|
||||
func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id))
|
||||
return httperror.NotFound("album not found")
|
||||
}
|
||||
|
||||
h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots.
|
||||
// Returns 422 if the album has no anchor yet.
|
||||
// Returns 202 with job IDs for all enqueued shots.
|
||||
func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, album.ErrAnchorRequired) {
|
||||
return httperror.UnprocessableEntity("anchor must be generated before shots")
|
||||
}
|
||||
return httperror.NotFound("album not found")
|
||||
}
|
||||
|
||||
if jobIDs == nil {
|
||||
jobIDs = []string{}
|
||||
}
|
||||
|
||||
h.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
|
||||
httpresponse.Accepted(w, r, AlbumJobsResponse{JobIDs: jobIDs})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateShot handles POST /albums/{id}/shots/{index} — enqueues a single shot (for regeneration).
|
||||
func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
|
||||
if err != nil {
|
||||
return httperror.BadRequest("shot index must be a non-negative integer")
|
||||
}
|
||||
|
||||
jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex)
|
||||
if err != nil {
|
||||
if errors.Is(err, album.ErrAnchorRequired) {
|
||||
return httperror.UnprocessableEntity("anchor must be generated before shots")
|
||||
}
|
||||
return httperror.NotFound("album or shot not found")
|
||||
}
|
||||
|
||||
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetShot handles DELETE /albums/{id}/shots/{index} — resets a shot to pending.
|
||||
func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
return httperror.BadRequest("album ID is required")
|
||||
}
|
||||
|
||||
shotIndex, err := parseShotIndex(chi.URLParam(r, "index"))
|
||||
if err != nil {
|
||||
return httperror.BadRequest("shot index must be a non-negative integer")
|
||||
}
|
||||
|
||||
if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil {
|
||||
return httperror.NotFound("album or shot not found")
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseShotIndex parses and validates the shot index URL parameter.
|
||||
// Returns an error if the value is missing, non-numeric, or negative.
|
||||
func parseShotIndex(idx string) (int, error) {
|
||||
if idx == "" {
|
||||
return 0, errors.New("missing shot index")
|
||||
}
|
||||
n, err := strconv.Atoi(idx)
|
||||
if err != nil || n < 0 {
|
||||
return 0, errors.New("shot index must be a non-negative integer")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
331
services/persona-api/internal/api/handlers/auth.go
Normal file
331
services/persona-api/internal/api/handlers/auth.go
Normal file
@ -0,0 +1,331 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// Auth handles authentication HTTP requests.
|
||||
type Auth struct {
|
||||
svc *service.AuthService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewAuth creates a new Auth handler.
|
||||
func NewAuth(svc *service.AuthService, logger *logging.Logger) *Auth {
|
||||
return &Auth{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("AuthHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request / Response types ---
|
||||
|
||||
// LoginRequest is the request body for password login.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// RegisterRequest is the request body for registration.
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response for successful login or registration.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
// UserResponse is the user data returned in auth responses.
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfileRequest is the request body for updating the user profile.
|
||||
type UpdateProfileRequest struct {
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest is the request body for changing password.
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword" validate:"required"`
|
||||
NewPassword string `json:"newPassword" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// RefreshRequest is the request body for refreshing an access token.
|
||||
type RefreshRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
|
||||
// toUserResponse converts a domain.User to UserResponse.
|
||||
func toUserResponse(u *domain.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: string(u.ID),
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
AvatarURL: u.AvatarURL,
|
||||
EmailVerified: u.EmailVerified,
|
||||
Roles: u.Roles,
|
||||
}
|
||||
}
|
||||
|
||||
// toLoginResponse creates a LoginResponse from service output.
|
||||
func toLoginResponse(out *service.LoginOutput) LoginResponse {
|
||||
return LoginResponse{
|
||||
Token: out.Token,
|
||||
User: toUserResponse(out.User),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// Login authenticates a user with email and password.
|
||||
//
|
||||
// POST /api/{service}/auth/login
|
||||
func (h *Auth) Login(w http.ResponseWriter, r *http.Request) error {
|
||||
var req LoginRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.LoginWithPassword(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toLoginResponse(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register creates a new user account.
|
||||
//
|
||||
// POST /api/{service}/auth/register
|
||||
func (h *Auth) Register(w http.ResponseWriter, r *http.Request) error {
|
||||
var req RegisterRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.Register(r.Context(), req.Email, req.Password, req.Name, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, toLoginResponse(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Me returns the current authenticated user.
|
||||
//
|
||||
// GET /api/{service}/auth/me
|
||||
func (h *Auth) Me(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
freshUser, err := h.svc.GetCurrentUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toUserResponse(freshUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMe updates the current user's profile.
|
||||
//
|
||||
// PUT /api/{service}/auth/me
|
||||
func (h *Auth) UpdateMe(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpdateProfile(r.Context(), user.ID, req.Name, req.AvatarURL)
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toUserResponse(updated))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword changes the current user's password.
|
||||
//
|
||||
// POST /api/{service}/auth/change-password
|
||||
func (h *Auth) ChangePassword(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.ChangePassword(r.Context(), user.ID, req.CurrentPassword, req.NewPassword); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout revokes the current session.
|
||||
//
|
||||
// POST /api/{service}/auth/logout
|
||||
func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionID := ""
|
||||
if user.Metadata != nil {
|
||||
if sid, ok := user.Metadata["sid"].(string); ok {
|
||||
sessionID = sid
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.Logout(r.Context(), sessionID); err != nil {
|
||||
h.logger.Warn("logout session revoke failed", "error", err)
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshToken issues a new access token for an active session.
|
||||
//
|
||||
// POST /api/{service}/auth/refresh
|
||||
func (h *Auth) RefreshToken(w http.ResponseWriter, r *http.Request) error {
|
||||
// The caller sends their current (possibly near-expiry) token.
|
||||
// We parse it to get user ID and session ID, then issue a new one.
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
sessionID := ""
|
||||
if user.Metadata != nil {
|
||||
if sid, ok := user.Metadata["sid"].(string); ok {
|
||||
sessionID = sid
|
||||
}
|
||||
}
|
||||
if sessionID == "" {
|
||||
return httperror.Unauthorized("no session")
|
||||
}
|
||||
|
||||
output, err := h.svc.RefreshToken(r.Context(), sessionID, user.ID)
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toLoginResponse(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// mapAuthError translates domain errors to HTTP errors.
|
||||
func mapAuthError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidCredentials):
|
||||
return httperror.Unauthorized("invalid email or password")
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
return httperror.Unauthorized("invalid email or password")
|
||||
case errors.Is(err, domain.ErrUserSuspended):
|
||||
return httperror.Forbidden("account is suspended")
|
||||
case errors.Is(err, domain.ErrDuplicateEmail):
|
||||
return httperror.Conflict("email already registered")
|
||||
case errors.Is(err, domain.ErrWeakPassword):
|
||||
return httperror.BadRequest(err.Error())
|
||||
case errors.Is(err, domain.ErrRegistrationDisabled):
|
||||
return httperror.Forbidden("registration is currently disabled")
|
||||
case errors.Is(err, domain.ErrNameTooLong), errors.Is(err, domain.ErrEmailTooLong):
|
||||
return httperror.BadRequest(err.Error())
|
||||
case errors.Is(err, domain.ErrInvalidAvatarURL):
|
||||
return httperror.BadRequest("avatar URL must use http or https")
|
||||
case errors.Is(err, domain.ErrSessionNotFound):
|
||||
return httperror.NotFound("session not found")
|
||||
case errors.Is(err, domain.ErrSessionRevoked):
|
||||
return httperror.Unauthorized("session has been revoked")
|
||||
case errors.Is(err, domain.ErrInvalidAuthCode):
|
||||
return httperror.Unauthorized("invalid or expired code")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extracts the client IP from the request.
|
||||
// It prefers RemoteAddr (set by the Go HTTP server from the TCP connection) and
|
||||
// only uses X-Forwarded-For/X-Real-Ip when the direct connection is from a
|
||||
// private/loopback address, indicating a trusted reverse proxy.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
// Only trust proxy headers when the connection is from a private network.
|
||||
if isPrivateIP(host) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.SplitN(xff, ",", 2)
|
||||
ip := strings.TrimSpace(parts[0])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
if xri := r.Header.Get("X-Real-Ip"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// isPrivateIP returns true if the address is loopback or RFC 1918 private.
|
||||
func isPrivateIP(addr string) bool {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate()
|
||||
}
|
||||
|
||||
// sessionID extracts the session ID from the authenticated user's metadata.
|
||||
func sessionID(user *auth.User) string {
|
||||
if user == nil || user.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
sid, _ := user.Metadata["sid"].(string)
|
||||
return sid
|
||||
}
|
||||
288
services/persona-api/internal/api/handlers/auth_flows.go
Normal file
288
services/persona-api/internal/api/handlers/auth_flows.go
Normal file
@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// --- Request types for auth flows ---
|
||||
|
||||
// EmailRequest is used by OTP send, magic link, and forgot password.
|
||||
type EmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
// OTPVerifyRequest verifies a one-time password.
|
||||
type OTPVerifyRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required,len=6"`
|
||||
}
|
||||
|
||||
// MagicLinkVerifyRequest verifies a magic link token.
|
||||
type MagicLinkVerifyRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest sets a new password using a reset token.
|
||||
type ResetPasswordRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Token string `json:"token" validate:"required"`
|
||||
NewPassword string `json:"newPassword" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
// VerifyEmailRequest verifies an email with a code.
|
||||
type VerifyEmailRequest struct {
|
||||
Code string `json:"code" validate:"required,len=6"`
|
||||
}
|
||||
|
||||
// SessionResponse is a single session in the list.
|
||||
type SessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
DeviceLabel string `json:"deviceLabel"`
|
||||
LastActiveAt string `json:"lastActiveAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
// --- OTP handlers ---
|
||||
|
||||
// SendOTP sends a one-time password to the user's email.
|
||||
//
|
||||
// POST /api/{service}/auth/otp/send
|
||||
func (h *Auth) SendOTP(w http.ResponseWriter, r *http.Request) error {
|
||||
var req EmailRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.SendOTP(r.Context(), req.Email, clientIP(r)); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a code has been sent"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies a one-time password and returns a login token.
|
||||
//
|
||||
// POST /api/{service}/auth/otp/verify
|
||||
func (h *Auth) VerifyOTP(w http.ResponseWriter, r *http.Request) error {
|
||||
var req OTPVerifyRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.VerifyOTP(r.Context(), req.Email, req.Code, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toLoginResponse(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Magic Link handlers ---
|
||||
|
||||
// SendMagicLink sends a magic link to the user's email.
|
||||
//
|
||||
// POST /api/{service}/auth/magic-link
|
||||
func (h *Auth) SendMagicLink(w http.ResponseWriter, r *http.Request) error {
|
||||
var req EmailRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.SendMagicLink(r.Context(), req.Email, clientIP(r)); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a link has been sent"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyMagicLink verifies a magic link token and returns a login token.
|
||||
//
|
||||
// POST /api/{service}/auth/magic-link/verify
|
||||
func (h *Auth) VerifyMagicLink(w http.ResponseWriter, r *http.Request) error {
|
||||
var req MagicLinkVerifyRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.VerifyMagicLink(r.Context(), req.Email, req.Token, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toLoginResponse(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Forgot / Reset Password handlers ---
|
||||
|
||||
// ForgotPassword sends a password reset token.
|
||||
//
|
||||
// POST /api/{service}/auth/forgot-password
|
||||
func (h *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) error {
|
||||
var req EmailRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.ForgotPassword(r.Context(), req.Email, clientIP(r)); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a reset link has been sent"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password using a reset token.
|
||||
//
|
||||
// POST /api/{service}/auth/reset-password
|
||||
func (h *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) error {
|
||||
var req ResetPasswordRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.ResetPassword(r.Context(), req.Email, req.Token, req.NewPassword); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "Password has been reset. Please sign in."})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Email Verification handlers ---
|
||||
|
||||
// SendVerifyEmail sends a verification code to the current user's email.
|
||||
//
|
||||
// POST /api/{service}/auth/verify-email/send
|
||||
func (h *Auth) SendVerifyEmail(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
if err := h.svc.SendVerifyEmail(r.Context(), user.ID); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "Verification code sent"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyEmail verifies the current user's email with a code.
|
||||
//
|
||||
// POST /api/{service}/auth/verify-email
|
||||
func (h *Auth) VerifyEmail(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
var req VerifyEmailRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.svc.VerifyEmail(r.Context(), user.ID, req.Code); err != nil {
|
||||
return mapAuthError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{"message": "Email verified"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Session Management handlers ---
|
||||
|
||||
// ListSessions returns all active sessions for the current user.
|
||||
//
|
||||
// GET /api/{service}/auth/sessions
|
||||
func (h *Auth) ListSessions(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
currentSID := sessionID(user)
|
||||
|
||||
sessions, err := h.svc.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]SessionResponse, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
result = append(result, SessionResponse{
|
||||
ID: string(s.ID),
|
||||
IPAddress: s.IPAddress,
|
||||
DeviceLabel: s.DeviceLabel,
|
||||
LastActiveAt: s.LastActiveAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
IsCurrent: string(s.ID) == currentSID,
|
||||
})
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeSession revokes a specific session.
|
||||
//
|
||||
// DELETE /api/{service}/auth/sessions/{id}
|
||||
func (h *Auth) RevokeSession(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
sid := chi.URLParam(r, "id")
|
||||
if sid == "" {
|
||||
return httperror.BadRequest("session id required")
|
||||
}
|
||||
|
||||
if err := h.svc.RevokeSession(r.Context(), user.ID, sid); err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
return httperror.NotFound("session not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAllSessions revokes all sessions except the current one.
|
||||
//
|
||||
// DELETE /api/{service}/auth/sessions
|
||||
func (h *Auth) RevokeAllSessions(w http.ResponseWriter, r *http.Request) error {
|
||||
user, err := auth.GetUserOrError(r.Context())
|
||||
if err != nil {
|
||||
return httperror.Unauthorized("not authenticated")
|
||||
}
|
||||
|
||||
currentSID := sessionID(user)
|
||||
var except *string
|
||||
if currentSID != "" {
|
||||
except = ¤tSID
|
||||
}
|
||||
|
||||
if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
94
services/persona-api/internal/api/handlers/chat.go
Normal file
94
services/persona-api/internal/api/handlers/chat.go
Normal file
@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/realtime"
|
||||
)
|
||||
|
||||
// Chat handles HTTP requests for chat messaging with AI responses.
|
||||
// User messages are broadcast immediately via SSE.
|
||||
// AI responses are enqueued and processed by the worker with streaming chunks.
|
||||
type Chat struct {
|
||||
queue queue.Producer
|
||||
sseHub *realtime.SSEHub
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewChat creates a new Chat handler.
|
||||
func NewChat(q queue.Producer, hub *realtime.SSEHub, logger *logging.Logger) *Chat {
|
||||
return &Chat{
|
||||
queue: q,
|
||||
sseHub: hub,
|
||||
logger: logger.WithComponent("ChatHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessageRequest is the request body for sending a chat message.
|
||||
type SendMessageRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=5000"`
|
||||
}
|
||||
|
||||
// SendMessage broadcasts a chat message to a channel via SSE
|
||||
// and enqueues an AI response job for the worker.
|
||||
func (h *Chat) SendMessage(w http.ResponseWriter, r *http.Request) error {
|
||||
var req SendMessageRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get user info
|
||||
userID := "anonymous"
|
||||
userName := "Anonymous"
|
||||
if user := auth.GetUser(r.Context()); user != nil {
|
||||
userID = user.ID
|
||||
if name, ok := user.Metadata["name"].(string); ok && name != "" {
|
||||
userName = name
|
||||
} else if user.Email != "" {
|
||||
userName = user.Email
|
||||
}
|
||||
}
|
||||
|
||||
msgID := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Broadcast user message to channel:general immediately (synchronous — users
|
||||
// see their own messages instantly without waiting for the queue)
|
||||
h.sseHub.SendToChannel("channel:general", &realtime.SSEEvent{
|
||||
Type: "chat",
|
||||
Timestamp: now,
|
||||
JobID: msgID,
|
||||
Message: req.Content,
|
||||
Result: map[string]any{
|
||||
"id": msgID,
|
||||
"content": req.Content,
|
||||
"userId": userID,
|
||||
"userName": userName,
|
||||
"timestamp": now.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
|
||||
// Enqueue AI response job — worker streams chunks via Redis → SSE
|
||||
if _, err := h.queue.Enqueue(r.Context(), "ai_chat_response", map[string]any{
|
||||
"content": req.Content,
|
||||
"userID": userID,
|
||||
"channel": "channel:general",
|
||||
}); err != nil {
|
||||
h.logger.Error("failed to enqueue AI chat response", "error", err)
|
||||
// Don't fail the request — user message was already delivered
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"id": msgID,
|
||||
"status": "sent",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
170
services/persona-api/internal/api/handlers/example.go
Normal file
170
services/persona-api/internal/api/handlers/example.go
Normal file
@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// Example handles HTTP requests for example resources.
|
||||
type Example struct {
|
||||
svc *service.ExampleService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExample creates a new Example handler with injected dependencies.
|
||||
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
|
||||
return &Example{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("ExampleHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequest is the request body for creating an example.
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateRequest is the request body for updating an example.
|
||||
type UpdateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
}
|
||||
|
||||
// ExampleResponse is the response for an example resource.
|
||||
type ExampleResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// toResponse converts a domain example to an API response.
|
||||
func toResponse(e *domain.Example) ExampleResponse {
|
||||
return ExampleResponse{
|
||||
ID: e.ID.String(),
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
|
||||
examples, err := h.svc.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]ExampleResponse, len(examples))
|
||||
for i, e := range examples {
|
||||
result[i] = toResponse(&e)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Validate UUID format
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a new example.
|
||||
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
||||
var req CreateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Create(r.Context(), service.CreateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing example.
|
||||
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toResponse(example))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return httperror.BadRequest("invalid id format")
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
|
||||
return mapDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.NoContent(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapDomainError converts domain errors to HTTP errors.
|
||||
func mapDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrExampleNotFound):
|
||||
return httperror.NotFound("example not found")
|
||||
case errors.Is(err, domain.ErrDuplicateExample):
|
||||
return httperror.Conflict("example with this name already exists")
|
||||
case errors.Is(err, domain.ErrInvalidExampleName):
|
||||
return httperror.BadRequest("invalid example name")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
402
services/persona-api/internal/api/handlers/example_test.go
Normal file
402
services/persona-api/internal/api/handlers/example_test.go
Normal file
@ -0,0 +1,402 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newTestHandler() (*Example, *mockExampleRepository) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := service.NewExampleService(repo, logging.Nop())
|
||||
handler := NewExample(svc, logging.Nop())
|
||||
return handler, repo
|
||||
}
|
||||
|
||||
func TestExample_List(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.List(w, r); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
items, ok := data.([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' to be an array")
|
||||
}
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Errorf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Get(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid uuid - found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid uuid - not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid uuid",
|
||||
id: "not-a-uuid",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Get(w, r); err != nil {
|
||||
// Map error to status for testing
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Create(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed existing data for duplicate test
|
||||
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
body: CreateRequest{
|
||||
Name: "New Example",
|
||||
Description: "A test description",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "duplicate name",
|
||||
body: CreateRequest{
|
||||
Name: "Existing Name",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Create(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Delete(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
|
||||
_ = repo.Create(context.Background(), ex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "existing example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
wantStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "non-existent example",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Delete(w, r); err != nil {
|
||||
if tt.wantStatus == http.StatusNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExample_Update(t *testing.T) {
|
||||
handler, repo := newTestHandler()
|
||||
|
||||
// Seed data
|
||||
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
|
||||
_ = repo.Create(context.Background(), ex1)
|
||||
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
|
||||
_ = repo.Create(context.Background(), ex2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
body UpdateRequest
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid update",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "name conflict",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
body: UpdateRequest{
|
||||
Name: "Example 2",
|
||||
Description: "Conflict",
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
id: "550e8400-e29b-41d4-a716-446655440099",
|
||||
body: UpdateRequest{
|
||||
Name: "Whatever",
|
||||
Description: "",
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Update(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(tt.body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
234
services/persona-api/internal/api/handlers/generate.go
Normal file
234
services/persona-api/internal/api/handlers/generate.go
Normal file
@ -0,0 +1,234 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/realtime"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Generate handles HTTP requests for AI generation endpoints.
|
||||
// All generation is async: validate request, enqueue job, return 202 with job ID.
|
||||
// The worker processes jobs and sends results via Redis → SSE.
|
||||
// Job status can be polled via GET /generate/jobs/{id} as a fallback to SSE.
|
||||
type Generate struct {
|
||||
queue queue.Producer
|
||||
jobReader queue.JobReader
|
||||
sseHub *realtime.SSEHub
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewGenerate creates a new Generate handler with injected dependencies.
|
||||
func NewGenerate(q queue.Producer, jr queue.JobReader, hub *realtime.SSEHub, logger *logging.Logger) *Generate {
|
||||
return &Generate{
|
||||
queue: q,
|
||||
jobReader: jr,
|
||||
sseHub: hub,
|
||||
logger: logger.WithComponent("GenerateHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image generation (async - returns job ID, results via SSE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateImageRequest is the request body for image generation.
|
||||
type GenerateImageRequest struct {
|
||||
Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
|
||||
Count int `json:"count"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
}
|
||||
|
||||
// GenerateAccepted is the immediate HTTP response with the job ID.
|
||||
type GenerateAccepted struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// GenerateImage queues an image generation job.
|
||||
// Returns immediately with job ID. Results come via SSE events:
|
||||
// - generation_started: Job accepted
|
||||
// - generation_progress: Progress updates
|
||||
// - generation_complete: Images available
|
||||
// - generation_failed: Error occurred
|
||||
//
|
||||
// Client should subscribe to SSE channel `user:<userId>` before calling.
|
||||
func (h *Generate) GenerateImage(w http.ResponseWriter, r *http.Request) error {
|
||||
var req GenerateImageRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.Count == 0 {
|
||||
req.Count = 1
|
||||
}
|
||||
if req.Count > 4 {
|
||||
req.Count = 4
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"count": req.Count,
|
||||
"aspectRatio": req.AspectRatio,
|
||||
"userID": user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue image job", "error", err)
|
||||
return httperror.Internal("failed to queue image generation")
|
||||
}
|
||||
|
||||
h.logger.Info("image generation queued", "jobId", jobID, "userID", user.ID)
|
||||
|
||||
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video generation (async - takes 2-5 minutes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateVideoRequest is the request body for video generation.
|
||||
type GenerateVideoRequest struct {
|
||||
Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
|
||||
AspectRatio string `json:"aspectRatio"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// GenerateVideo queues a video generation job.
|
||||
// Returns immediately with job ID. Results come via SSE events.
|
||||
func (h *Generate) GenerateVideo(w http.ResponseWriter, r *http.Request) error {
|
||||
var req GenerateVideoRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate video aspect ratio (Veo only supports 16:9 and 9:16)
|
||||
if req.AspectRatio != "" && req.AspectRatio != "16:9" && req.AspectRatio != "9:16" {
|
||||
return httperror.BadRequest("video only supports 16:9 and 9:16 aspect ratios")
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.queue.Enqueue(r.Context(), "generate_video", map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"aspectRatio": req.AspectRatio,
|
||||
"duration": req.Duration,
|
||||
"userID": user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue video job", "error", err)
|
||||
return httperror.Internal("failed to queue video generation")
|
||||
}
|
||||
|
||||
h.logger.Info("video generation queued", "jobId", jobID, "userID", user.ID)
|
||||
|
||||
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generation (async - returns job ID, results via SSE with streaming chunks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateTextRequest is the request body for text generation.
|
||||
type GenerateTextRequest struct {
|
||||
Prompt string `json:"prompt" validate:"required,min=1,max=5000"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
}
|
||||
|
||||
// GenerateText queues a text generation job.
|
||||
// Returns immediately with job ID. Chunks come via SSE as ai_chat_chunk events.
|
||||
func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error {
|
||||
var req GenerateTextRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.queue.Enqueue(r.Context(), "generate_text", map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"systemPrompt": req.SystemPrompt,
|
||||
"maxTokens": req.MaxTokens,
|
||||
"userID": user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue text job", "error", err)
|
||||
return httperror.Internal("failed to queue text generation")
|
||||
}
|
||||
|
||||
h.logger.Info("text generation queued", "jobId", jobID, "userID", user.ID)
|
||||
|
||||
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job status (poll fallback for SSE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetJobStatus returns the current status of a generation job.
|
||||
// This is a poll-based fallback for clients that can't use SSE.
|
||||
func (h *Generate) GetJobStatus(w http.ResponseWriter, r *http.Request) error {
|
||||
jobID := chi.URLParam(r, "id")
|
||||
if jobID == "" {
|
||||
return httperror.BadRequest("job ID is required")
|
||||
}
|
||||
|
||||
job, err := h.jobReader.GetJob(r.Context(), jobID)
|
||||
if err != nil {
|
||||
if err == queue.ErrJobNotFound {
|
||||
return httperror.NotFound("job not found")
|
||||
}
|
||||
h.logger.Error("failed to get job status", "error", err, "job_id", jobID)
|
||||
return httperror.Internal("failed to get job status")
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"id": job.ID,
|
||||
"type": job.Type,
|
||||
"status": string(job.Status),
|
||||
"createdAt": job.CreatedAt,
|
||||
}
|
||||
if job.StartedAt != nil {
|
||||
resp["startedAt"] = job.StartedAt
|
||||
}
|
||||
if job.CompletedAt != nil {
|
||||
resp["completedAt"] = job.CompletedAt
|
||||
}
|
||||
if job.Error != "" {
|
||||
resp["error"] = job.Error
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE Events endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Events returns the SSE handler for event subscriptions.
|
||||
// Mount at /api/events.
|
||||
func (h *Generate) Events() http.Handler {
|
||||
return realtime.NewSSEHandler(h.sseHub, h.logger.Logger)
|
||||
}
|
||||
26
services/persona-api/internal/api/handlers/health.go
Normal file
26
services/persona-api/internal/api/handlers/health.go
Normal file
@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
)
|
||||
|
||||
// Health handles health check endpoints.
|
||||
type Health struct {
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewHealth creates a new Health handler.
|
||||
func NewHealth(logger *logging.Logger) *Health {
|
||||
return &Health{logger: logger}
|
||||
}
|
||||
|
||||
// Check returns the service health status.
|
||||
func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
|
||||
httpresponse.OK(w, r, map[string]string{
|
||||
"service": "persona-api",
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
372
services/persona-api/internal/api/handlers/media.go
Normal file
372
services/persona-api/internal/api/handlers/media.go
Normal file
@ -0,0 +1,372 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/storage"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// maxUploadSize is the maximum allowed file size for uploads (500MB).
|
||||
const maxUploadSize = 500 << 20
|
||||
|
||||
// allowedMediaTypes is the allowlist of MIME types permitted for upload.
|
||||
var allowedMediaTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/svg+xml": true,
|
||||
"video/mp4": true,
|
||||
"video/webm": true,
|
||||
"video/quicktime": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/wav": true,
|
||||
"audio/ogg": true,
|
||||
"audio/webm": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
// Media handles media upload and library operations.
|
||||
type Media struct {
|
||||
store storage.Store
|
||||
repo port.MediaRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewMedia creates a new media handler.
|
||||
func NewMedia(store storage.Store, repo port.MediaRepository, logger *logging.Logger) *Media {
|
||||
return &Media{store: store, repo: repo, logger: logger.WithComponent("MediaHandler")}
|
||||
}
|
||||
|
||||
// Routes returns the media subrouter.
|
||||
func (h *Media) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Post("/upload/init", app.Wrap(h.InitUpload))
|
||||
r.Post("/upload/complete", app.Wrap(h.CompleteUpload))
|
||||
r.Get("/", app.Wrap(h.List))
|
||||
r.Get("/{id}", app.Wrap(h.GetOne))
|
||||
r.Get("/{id}/url", app.Wrap(h.RefreshURL))
|
||||
r.Delete("/{id}", app.Wrap(h.Delete))
|
||||
return r
|
||||
}
|
||||
|
||||
// sanitizeFilename removes path separators and dangerous characters from filenames.
|
||||
func sanitizeFilename(name string) string {
|
||||
// Remove any directory components
|
||||
name = filepath.Base(name)
|
||||
// Replace any remaining path separators (e.g., from URL encoding)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "..", "_")
|
||||
// Remove null bytes
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" || name == "." {
|
||||
name = "unnamed"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// initUploadRequest is the request body for POST /media/upload/init.
|
||||
type initUploadRequest struct {
|
||||
Filename string `json:"filename" validate:"required"`
|
||||
ContentType string `json:"contentType" validate:"required"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// InitUpload returns a presigned URL for direct client-to-storage upload.
|
||||
// The metadata record is created in CompleteUpload after the file is actually stored.
|
||||
func (h *Media) InitUpload(w http.ResponseWriter, r *http.Request) error {
|
||||
var req initUploadRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate MIME type against allowlist
|
||||
if !allowedMediaTypes[req.ContentType] {
|
||||
return httperror.BadRequest("unsupported file type: " + req.ContentType)
|
||||
}
|
||||
|
||||
// Validate file size if provided
|
||||
if req.Size > maxUploadSize {
|
||||
return httperror.BadRequest(fmt.Sprintf("file too large: %d bytes (max %d)", req.Size, maxUploadSize))
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
// Sanitize filename to prevent path traversal
|
||||
safeName := sanitizeFilename(req.Filename)
|
||||
|
||||
// Build object path: media/{userID}/{uuid}/{filename}
|
||||
objectPath := fmt.Sprintf("media/%s/%s/%s", user.ID, uuid.New().String(), safeName)
|
||||
|
||||
presigned, err := h.store.UploadPresigned(r.Context(), objectPath, req.ContentType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create presigned upload", "error", err)
|
||||
return httperror.Internal("failed to create upload URL")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{
|
||||
"uploadURL": presigned.URL,
|
||||
"objectPath": objectPath,
|
||||
"filename": safeName,
|
||||
"headers": presigned.Headers,
|
||||
"method": presigned.Method,
|
||||
"expires": presigned.Expires,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeUploadRequest is the request body for POST /media/upload/complete.
|
||||
type completeUploadRequest struct {
|
||||
ObjectPath string `json:"objectPath" validate:"required"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// CompleteUpload confirms an upload is done, creates the metadata record, and returns the final URL.
|
||||
func (h *Media) CompleteUpload(w http.ResponseWriter, r *http.Request) error {
|
||||
var req completeUploadRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify the object path belongs to the authenticated user
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
expectedPrefix := fmt.Sprintf("media/%s/", user.ID)
|
||||
if !strings.HasPrefix(req.ObjectPath, expectedPrefix) {
|
||||
return httperror.Forbidden("cannot complete upload for another user's media")
|
||||
}
|
||||
|
||||
url, err := h.store.GetURL(r.Context(), req.ObjectPath)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get object URL", "error", err, "path", req.ObjectPath)
|
||||
return httperror.Internal("failed to confirm upload")
|
||||
}
|
||||
|
||||
// Create the metadata record now that the file is in storage.
|
||||
now := time.Now()
|
||||
filename := sanitizeFilename(req.Filename)
|
||||
if filename == "unnamed" {
|
||||
// Extract filename from the object path (last segment)
|
||||
parts := strings.Split(req.ObjectPath, "/")
|
||||
if len(parts) > 0 {
|
||||
filename = parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
mediaObj := &domain.MediaObject{
|
||||
ID: domain.MediaObjectID("med_" + uuid.New().String()),
|
||||
UserID: domain.UserID(user.ID),
|
||||
Path: req.ObjectPath,
|
||||
Filename: filename,
|
||||
ContentType: req.ContentType,
|
||||
Size: req.Size,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := h.repo.Create(r.Context(), mediaObj); err != nil {
|
||||
h.logger.Error("failed to create media record", "error", err)
|
||||
return httperror.Internal("failed to create upload record")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{
|
||||
"id": string(mediaObj.ID),
|
||||
"url": url,
|
||||
"path": req.ObjectPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the user's media objects with pagination.
|
||||
func (h *Media) List(w http.ResponseWriter, r *http.Request) error {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
opts := port.ListMediaOptions{
|
||||
ContentTypePrefix: r.URL.Query().Get("type"),
|
||||
Limit: intQueryParam(r, "limit", 50),
|
||||
Offset: intQueryParam(r, "offset", 0),
|
||||
}
|
||||
|
||||
objects, total, err := h.repo.ListByUser(r.Context(), domain.UserID(user.ID), opts)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list media", "error", err)
|
||||
return httperror.Internal("failed to list media")
|
||||
}
|
||||
|
||||
// Enrich each object with a fresh signed URL
|
||||
type mediaItem struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
items := make([]mediaItem, 0, len(objects))
|
||||
for _, obj := range objects {
|
||||
url, urlErr := h.store.GetURL(r.Context(), obj.Path)
|
||||
if urlErr != nil {
|
||||
h.logger.Warn("failed to get URL for media object", "path", obj.Path, "error", urlErr)
|
||||
continue
|
||||
}
|
||||
items = append(items, mediaItem{
|
||||
ID: string(obj.ID),
|
||||
Path: obj.Path,
|
||||
URL: url,
|
||||
Filename: obj.Filename,
|
||||
ContentType: obj.ContentType,
|
||||
Size: obj.Size,
|
||||
CreatedAt: obj.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"count": len(items),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOne returns a single media object with a fresh URL.
|
||||
func (h *Media) GetOne(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
return httperror.BadRequest("media ID is required")
|
||||
}
|
||||
|
||||
obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return httperror.NotFound("media object not found")
|
||||
}
|
||||
return httperror.Internal("failed to get media object")
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil || domain.UserID(user.ID) != obj.UserID {
|
||||
return httperror.Forbidden("access denied")
|
||||
}
|
||||
|
||||
url, err := h.store.GetURL(r.Context(), obj.Path)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get URL", "error", err, "path", obj.Path)
|
||||
return httperror.Internal("failed to get media URL")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{
|
||||
"id": string(obj.ID),
|
||||
"path": obj.Path,
|
||||
"url": url,
|
||||
"filename": obj.Filename,
|
||||
"contentType": obj.ContentType,
|
||||
"size": obj.Size,
|
||||
"createdAt": obj.CreatedAt,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshURL returns a fresh signed URL for a media object.
|
||||
func (h *Media) RefreshURL(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
return httperror.BadRequest("media ID is required")
|
||||
}
|
||||
|
||||
obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return httperror.NotFound("media object not found")
|
||||
}
|
||||
return httperror.Internal("failed to get media object")
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil || domain.UserID(user.ID) != obj.UserID {
|
||||
return httperror.Forbidden("access denied")
|
||||
}
|
||||
|
||||
url, err := h.store.GetURL(r.Context(), obj.Path)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to refresh URL", "error", err, "path", obj.Path)
|
||||
return httperror.Internal("failed to refresh media URL")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{
|
||||
"id": string(obj.ID),
|
||||
"url": url,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete soft-deletes a media object.
|
||||
func (h *Media) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
return httperror.BadRequest("media ID is required")
|
||||
}
|
||||
|
||||
obj, err := h.repo.Get(r.Context(), domain.MediaObjectID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return httperror.NotFound("media object not found")
|
||||
}
|
||||
return httperror.Internal("failed to get media object")
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil || domain.UserID(user.ID) != obj.UserID {
|
||||
return httperror.Forbidden("cannot delete another user's media")
|
||||
}
|
||||
|
||||
if err := h.repo.SoftDelete(r.Context(), domain.MediaObjectID(id)); err != nil {
|
||||
h.logger.Error("failed to delete media", "error", err, "id", id)
|
||||
return httperror.Internal("failed to delete media")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, map[string]any{"deleted": id})
|
||||
return nil
|
||||
}
|
||||
|
||||
// intQueryParam parses an integer query parameter with a default value.
|
||||
func intQueryParam(r *http.Request, key string, defaultVal int) int {
|
||||
val := r.URL.Query().Get(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(val, "%d", &n); err != nil || n < 0 {
|
||||
return defaultVal
|
||||
}
|
||||
return n
|
||||
}
|
||||
85
services/persona-api/internal/api/handlers/persona.go
Normal file
85
services/persona-api/internal/api/handlers/persona.go
Normal file
@ -0,0 +1,85 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httperror"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
)
|
||||
|
||||
// Persona handles HTTP requests for persona generation.
|
||||
// All generation is async: validate request, enqueue job, return 202 with job ID.
|
||||
// Results are delivered via SSE events to the user's `user:<userId>` channel:
|
||||
//
|
||||
// - persona_spec_started: LLM pipeline started
|
||||
// - persona_spec_complete: Persona profile generated
|
||||
// - persona_image_started: Starting a specific image position
|
||||
// - persona_image_progress: Image position complete with URL
|
||||
// - persona_image_complete: All 20 images generated
|
||||
// - persona_video_started: Starting a video motion type
|
||||
// - persona_video_complete: Video complete with URL
|
||||
// - persona_failed: Generation failed (check error field)
|
||||
type Persona struct {
|
||||
queue queue.Producer
|
||||
jobReader queue.JobReader
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewPersona creates a new Persona handler with injected dependencies.
|
||||
func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
|
||||
return &Persona{
|
||||
queue: q,
|
||||
jobReader: jr,
|
||||
logger: logger.WithComponent("PersonaHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePersonaRequest is the request body for persona generation.
|
||||
type GeneratePersonaRequest struct {
|
||||
// Description is a natural-language persona concept (required).
|
||||
// Example: "mysterious woman with dark hair who loves poetry"
|
||||
Description string `json:"description" validate:"required,min=3,max=1000"`
|
||||
|
||||
// Gender is the gender identity: "woman", "man", or "non_binary" (required).
|
||||
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
|
||||
|
||||
// Name is an optional name override for the generated persona.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// GeneratePersona queues a persona generation job.
|
||||
// Returns immediately with job ID. Full lifecycle results come via SSE.
|
||||
//
|
||||
// Subscribe to SSE channel `user:<userId>` at /api/persona-api/events before calling.
|
||||
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
|
||||
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
|
||||
var req GeneratePersonaRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
|
||||
"description": req.Description,
|
||||
"gender": req.Gender,
|
||||
"name": req.Name,
|
||||
"userID": user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue persona job", "error", err)
|
||||
return httperror.Internal("failed to queue persona generation")
|
||||
}
|
||||
|
||||
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
|
||||
|
||||
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||
return nil
|
||||
}
|
||||
184
services/persona-api/internal/api/routes.go
Normal file
184
services/persona-api/internal/api/routes.go
Normal file
@ -0,0 +1,184 @@
|
||||
// Package api provides HTTP routing and handlers for the persona-api service.
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/app"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/middleware"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/realtime"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/storage"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/config"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/service"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
// Routes are mounted under /api/persona-api to match the ingress path routing.
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/persona-api/health
|
||||
// - https://domain/api/persona-api/examples
|
||||
// - https://domain/api/persona-api/events?channel=user:123 (SSE)
|
||||
func RegisterRoutes(application *app.App, deps *Dependencies) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(deps.ExampleService, logger)
|
||||
authHandler := handlers.NewAuth(deps.AuthService, logger)
|
||||
generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
|
||||
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
|
||||
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
|
||||
albumHandler := handlers.NewAlbum(deps.AlbumService, logger)
|
||||
personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
|
||||
// JWT validator for protected routes
|
||||
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "persona-community-2",
|
||||
})
|
||||
|
||||
// Dev email preview (development only — not mounted in production).
|
||||
if cfg.AppConfig.Environment == "development" && deps.EmailRenderer != nil {
|
||||
devHandler := emailpkg.NewDevHandler(deps.EmailRenderer)
|
||||
application.Router().Get("/dev/emails", devHandler.List)
|
||||
application.Router().Get("/dev/emails/{purpose}", devHandler.Preview)
|
||||
}
|
||||
|
||||
// Register API routes under /api/{service-name} to match ingress path routing.
|
||||
// The ingress routes /api/persona-api/* to this service.
|
||||
application.Route("/api/persona-api", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// ----- Public auth routes (rate-limited) -----
|
||||
// Auth attempts: 20/min per IP (login, register, verify, reset).
|
||||
authAttemptLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 20, Window: time.Minute})
|
||||
// Code sends: 5/min per IP (prevents email bombing via OTP/magic-link/forgot-password).
|
||||
codeSendLimit := middleware.RateLimit(middleware.RateLimitConfig{Requests: 5, Window: time.Minute})
|
||||
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(authAttemptLimit)
|
||||
r.Post("/auth/login", app.Wrap(authHandler.Login))
|
||||
r.Post("/auth/register", app.Wrap(authHandler.Register))
|
||||
r.Post("/auth/otp/verify", app.Wrap(authHandler.VerifyOTP))
|
||||
r.Post("/auth/magic-link/verify", app.Wrap(authHandler.VerifyMagicLink))
|
||||
r.Post("/auth/reset-password", app.Wrap(authHandler.ResetPassword))
|
||||
})
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(codeSendLimit)
|
||||
r.Post("/auth/otp/send", app.Wrap(authHandler.SendOTP))
|
||||
r.Post("/auth/magic-link", app.Wrap(authHandler.SendMagicLink))
|
||||
r.Post("/auth/forgot-password", app.Wrap(authHandler.ForgotPassword))
|
||||
})
|
||||
|
||||
// Refresh accepts expired tokens (still validates signature).
|
||||
// The service layer checks session validity to prevent abuse.
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: jwtValidator,
|
||||
AllowExpired: true,
|
||||
}))
|
||||
r.Post("/auth/refresh", app.Wrap(authHandler.RefreshToken))
|
||||
})
|
||||
|
||||
// Session checker for revocation enforcement.
|
||||
sessionChecker := deps.AuthService.CheckSession
|
||||
|
||||
// ----- Protected auth routes -----
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: jwtValidator,
|
||||
}))
|
||||
r.Use(auth.SessionCheck(sessionChecker))
|
||||
|
||||
r.Get("/auth/me", app.Wrap(authHandler.Me))
|
||||
r.Put("/auth/me", app.Wrap(authHandler.UpdateMe))
|
||||
r.Post("/auth/change-password", app.Wrap(authHandler.ChangePassword))
|
||||
r.Post("/auth/logout", app.Wrap(authHandler.Logout))
|
||||
r.Post("/auth/verify-email/send", app.Wrap(authHandler.SendVerifyEmail))
|
||||
r.Post("/auth/verify-email", app.Wrap(authHandler.VerifyEmail))
|
||||
r.Get("/auth/sessions", app.Wrap(authHandler.ListSessions))
|
||||
r.Delete("/auth/sessions", app.Wrap(authHandler.RevokeAllSessions))
|
||||
r.Delete("/auth/sessions/{id}", app.Wrap(authHandler.RevokeSession))
|
||||
})
|
||||
|
||||
// ----- SSE Events -----
|
||||
// Server-Sent Events for async job updates (generation progress, etc.)
|
||||
r.Mount("/events", generateHandler.Events())
|
||||
|
||||
// ----- Example routes -----
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: jwtValidator,
|
||||
}))
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
})
|
||||
|
||||
// ----- Chat + Generate + Media routes (auth required) -----
|
||||
// Auth is required because SSE events are delivered to user:<userId> channels.
|
||||
// Without a real user identity, events go to user:anonymous and never reach the client.
|
||||
r.Group(func(r app.Router) {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: jwtValidator,
|
||||
}))
|
||||
r.Use(auth.SessionCheck(sessionChecker))
|
||||
|
||||
// Chat messaging
|
||||
r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
|
||||
|
||||
// Media generation (all queue-based, returns 202)
|
||||
r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
|
||||
r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
|
||||
r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
|
||||
r.Get("/generate/jobs/{id}", app.Wrap(generateHandler.GetJobStatus))
|
||||
|
||||
// Media library (upload, list, delete)
|
||||
r.Mount("/media", mediaHandler.Routes())
|
||||
|
||||
// Album generation (anchor + shots)
|
||||
r.Get("/albums", app.Wrap(albumHandler.List))
|
||||
r.Post("/albums", app.Wrap(albumHandler.Create))
|
||||
r.Get("/albums/{id}", app.Wrap(albumHandler.Get))
|
||||
r.Delete("/albums/{id}", app.Wrap(albumHandler.Delete))
|
||||
r.Post("/albums/{id}/anchor", app.Wrap(albumHandler.GenerateAnchor))
|
||||
r.Post("/albums/{id}/shots", app.Wrap(albumHandler.GenerateAllShots))
|
||||
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
|
||||
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
|
||||
|
||||
// Persona generation (5-stage LLM + 20 images + 4 videos, all async)
|
||||
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Dependencies holds all service dependencies for route registration.
|
||||
type Dependencies struct {
|
||||
ExampleService *service.ExampleService
|
||||
AuthService *service.AuthService
|
||||
AlbumService *service.AlbumService
|
||||
Queue queue.Producer
|
||||
JobReader queue.JobReader
|
||||
SSEHub *realtime.SSEHub
|
||||
Store storage.Store
|
||||
MediaRepo port.MediaRepository
|
||||
EmailRenderer *emailpkg.Renderer
|
||||
}
|
||||
112
services/persona-api/internal/api/spec.go
Normal file
112
services/persona-api/internal/api/spec.go
Normal file
@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import "git.threesix.ai/jordan/persona-community-2/pkg/openapi"
|
||||
|
||||
// NewServiceSpec builds the OpenAPI specification for the persona-api service.
|
||||
func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
spec := openapi.NewOpenAPISpec("persona-api API", "1.0.0").
|
||||
WithDescription("REST API for the persona-api service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique identifier"),
|
||||
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
|
||||
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
|
||||
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "name"))
|
||||
|
||||
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
|
||||
}, "name"))
|
||||
|
||||
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/persona-api/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
"tags": []string{"Health"},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
|
||||
"service": openapi.String(),
|
||||
"status": openapi.String(),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/persona-api/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
"description": "Returns a paginated list of examples.",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
|
||||
},
|
||||
})
|
||||
|
||||
// Get example
|
||||
spec.AddPath("/api/persona-api/examples/{id}", "get", map[string]any{
|
||||
"summary": "Get example by ID",
|
||||
"tags": []string{"Examples"},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Create example
|
||||
spec.AddPath("/api/persona-api/examples", "post", map[string]any{
|
||||
"summary": "Create example",
|
||||
"description": "Creates a new example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Update example
|
||||
spec.AddPath("/api/persona-api/examples/{id}", "put", map[string]any{
|
||||
"summary": "Update example",
|
||||
"description": "Updates an existing example. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Delete example
|
||||
spec.AddPath("/api/persona-api/examples/{id}", "delete", map[string]any{
|
||||
"summary": "Delete example",
|
||||
"description": "Deletes an example by ID. Requires authentication.",
|
||||
"tags": []string{"Examples"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"parameters": []any{openapi.IDParam()},
|
||||
"responses": map[string]any{
|
||||
"204": openapi.OpResponseNoContent(),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
90
services/persona-api/internal/config/config.go
Normal file
90
services/persona-api/internal/config/config.go
Normal file
@ -0,0 +1,90 @@
|
||||
// Package config provides service-specific configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/config"
|
||||
)
|
||||
|
||||
// Config extends the base config with persona-api-specific settings.
|
||||
type Config struct {
|
||||
config.AppConfig
|
||||
Server config.ServerConfig
|
||||
Database config.DatabaseConfig
|
||||
Logging config.LoggingConfig
|
||||
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
|
||||
// Redis for cross-process SSE event delivery
|
||||
RedisURL string
|
||||
|
||||
// Notify service for email delivery (OTP, magic links, password reset, etc.)
|
||||
// When NotifyURL is empty, emails are logged to stdout (dev mode).
|
||||
NotifyURL string
|
||||
NotifyAPIKey string
|
||||
NotifyHost string
|
||||
NotifyFrom string
|
||||
|
||||
// Email branding — injected into every transactional email.
|
||||
AppName string // APP_NAME, default: "persona-api"
|
||||
AppURL string // APP_URL, default: ""
|
||||
SupportEmail string // SUPPORT_EMAIL, default: NOTIFY_FROM value
|
||||
LogoURL string // LOGO_URL, default: "" (hides logo area)
|
||||
BrandColor string // BRAND_COLOR, default: "#6366f1"
|
||||
|
||||
// Dev mode seed user — seeded into the in-memory user store on startup so the
|
||||
// developer's email is always available without re-registering after each restart.
|
||||
// No effect when DATABASE_URL is set (production uses real persistence).
|
||||
DevUserEmail string // DEV_USER_EMAIL, e.g. "you@example.com"
|
||||
DevUserPassword string // DEV_USER_PASSWORD, default: "DevPassword1"
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
func Load() *Config {
|
||||
regEnabled := true
|
||||
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||
regEnabled = strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
notifyFrom := getEnvDefault("NOTIFY_FROM", "noreply@persona-community-2.com")
|
||||
|
||||
cfg := &Config{
|
||||
AppConfig: config.ReadAppConfig(),
|
||||
Server: config.ReadServerConfig(),
|
||||
Database: config.ReadDatabaseConfig(),
|
||||
Logging: config.ReadLoggingConfig(),
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
RegistrationEnabled: regEnabled,
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
|
||||
NotifyURL: os.Getenv("NOTIFY_URL"),
|
||||
NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"),
|
||||
NotifyHost: os.Getenv("NOTIFY_HOST"),
|
||||
NotifyFrom: notifyFrom,
|
||||
|
||||
AppName: getEnvDefault("APP_NAME", "persona-api"),
|
||||
AppURL: os.Getenv("APP_URL"),
|
||||
SupportEmail: getEnvDefault("SUPPORT_EMAIL", notifyFrom),
|
||||
LogoURL: os.Getenv("LOGO_URL"),
|
||||
BrandColor: getEnvDefault("BRAND_COLOR", "#6366f1"),
|
||||
|
||||
DevUserEmail: os.Getenv("DEV_USER_EMAIL"),
|
||||
DevUserPassword: getEnvDefault("DEV_USER_PASSWORD", "DevPassword1"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnvDefault(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
32
services/persona-api/internal/domain/auth_code.go
Normal file
32
services/persona-api/internal/domain/auth_code.go
Normal file
@ -0,0 +1,32 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// AuthCodePurpose identifies what an auth code is used for.
|
||||
type AuthCodePurpose string
|
||||
|
||||
const (
|
||||
PurposeLoginOTP AuthCodePurpose = "login_otp"
|
||||
PurposeMagicLink AuthCodePurpose = "magic_link"
|
||||
PurposePasswordReset AuthCodePurpose = "password_reset"
|
||||
PurposeEmailVerify AuthCodePurpose = "email_verify"
|
||||
)
|
||||
|
||||
// AuthCode is a single-use, time-limited code for authentication flows.
|
||||
// Used by OTP login, magic links, password reset, and email verification.
|
||||
type AuthCode struct {
|
||||
ID string
|
||||
UserID *UserID // Nullable for magic link signup
|
||||
Email string
|
||||
Code string
|
||||
Purpose AuthCodePurpose
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
IPAddress string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// IsValid returns true if the code has not been used and has not expired.
|
||||
func (c *AuthCode) IsValid() bool {
|
||||
return c.UsedAt == nil && time.Now().Before(c.ExpiresAt)
|
||||
}
|
||||
36
services/persona-api/internal/domain/errors.go
Normal file
36
services/persona-api/internal/domain/errors.go
Normal file
@ -0,0 +1,36 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
// These types represent the core business concepts of the service.
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain errors - these are business-level errors that should be translated
|
||||
// to appropriate HTTP status codes by the handler layer.
|
||||
var (
|
||||
// ErrNotFound indicates a requested resource does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrExampleNotFound indicates the requested example does not exist.
|
||||
ErrExampleNotFound = errors.New("example not found")
|
||||
|
||||
// ErrDuplicateExample indicates an example with the same name already exists.
|
||||
ErrDuplicateExample = errors.New("example with this name already exists")
|
||||
|
||||
// ErrInvalidExampleName indicates the example name is invalid.
|
||||
ErrInvalidExampleName = errors.New("invalid example name")
|
||||
|
||||
// Auth errors
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrDuplicateEmail = errors.New("email already registered")
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSessionRevoked = errors.New("session has been revoked")
|
||||
ErrInvalidAuthCode = errors.New("invalid or expired code")
|
||||
ErrExpiredAuthCode = errors.New("code has expired")
|
||||
ErrWeakPassword = errors.New("password does not meet requirements")
|
||||
ErrUserSuspended = errors.New("account is suspended")
|
||||
ErrRegistrationDisabled = errors.New("registration is disabled")
|
||||
ErrNameTooLong = errors.New("name exceeds maximum length")
|
||||
ErrEmailTooLong = errors.New("email exceeds maximum length")
|
||||
ErrInvalidAvatarURL = errors.New("avatar URL must use http or https")
|
||||
)
|
||||
89
services/persona-api/internal/domain/example.go
Normal file
89
services/persona-api/internal/domain/example.go
Normal file
@ -0,0 +1,89 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ExampleID is a strongly-typed identifier for examples.
|
||||
type ExampleID string
|
||||
|
||||
// String returns the string representation of the ID.
|
||||
func (id ExampleID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero returns true if the ID is empty.
|
||||
func (id ExampleID) IsZero() bool {
|
||||
return id == ""
|
||||
}
|
||||
|
||||
// Example name constraints.
|
||||
const (
|
||||
MinExampleNameLen = 1
|
||||
MaxExampleNameLen = 100
|
||||
MaxDescriptionLen = 500
|
||||
)
|
||||
|
||||
// Example represents an example domain entity.
|
||||
// This is a pure domain model with no external dependencies.
|
||||
type Example struct {
|
||||
ID ExampleID
|
||||
Name string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewExample creates a new Example with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func NewExample(id ExampleID, name, description string) (*Example, error) {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
return &Example{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: description,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update modifies the example's mutable fields with validation.
|
||||
// Returns ErrInvalidExampleName if the name is invalid.
|
||||
func (e *Example) Update(name, description string) error {
|
||||
if err := validateExampleName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDescription(description); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.Name = name
|
||||
e.Description = description
|
||||
e.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExampleName validates an example name.
|
||||
func validateExampleName(name string) error {
|
||||
length := utf8.RuneCountInString(name)
|
||||
if length < MinExampleNameLen || length > MaxExampleNameLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDescription validates a description.
|
||||
func validateDescription(desc string) error {
|
||||
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
|
||||
return ErrInvalidExampleName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
services/persona-api/internal/domain/media.go
Normal file
27
services/persona-api/internal/domain/media.go
Normal file
@ -0,0 +1,27 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// MediaObjectID is a typed media object identifier with prefix "med_".
|
||||
type MediaObjectID string
|
||||
|
||||
// MediaObject tracks a stored media file with ownership and metadata.
|
||||
// The actual file is stored in GCS (production) or MemoryStore (dev).
|
||||
// This record enables querying, soft deletes, and provenance tracking.
|
||||
type MediaObject struct {
|
||||
ID MediaObjectID
|
||||
UserID UserID
|
||||
Path string // Storage path (e.g., "media/usr_123/uuid/photo.png")
|
||||
Filename string // Original filename
|
||||
ContentType string
|
||||
Size int64
|
||||
GenerationJobID string // Non-empty if created by AI generation
|
||||
DeletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsDeleted returns true if the media object has been soft-deleted.
|
||||
func (m *MediaObject) IsDeleted() bool {
|
||||
return m.DeletedAt != nil
|
||||
}
|
||||
25
services/persona-api/internal/domain/session.go
Normal file
25
services/persona-api/internal/domain/session.go
Normal file
@ -0,0 +1,25 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// SessionID is a typed session identifier with prefix "ses_".
|
||||
type SessionID string
|
||||
|
||||
// Session tracks a user login with device and location information.
|
||||
// The session ID is embedded in the JWT token for revocation support.
|
||||
type Session struct {
|
||||
ID SessionID
|
||||
UserID UserID
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
DeviceLabel string
|
||||
LastActiveAt time.Time
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// IsActive returns true if the session has not been revoked and has not expired.
|
||||
func (s *Session) IsActive() bool {
|
||||
return s.RevokedAt == nil && time.Now().Before(s.ExpiresAt)
|
||||
}
|
||||
52
services/persona-api/internal/domain/user.go
Normal file
52
services/persona-api/internal/domain/user.go
Normal file
@ -0,0 +1,52 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// UserID is a typed user identifier with prefix "usr_".
|
||||
type UserID string
|
||||
|
||||
// UserStatus represents the account state.
|
||||
type UserStatus string
|
||||
|
||||
const (
|
||||
UserStatusActive UserStatus = "active"
|
||||
UserStatusSuspended UserStatus = "suspended"
|
||||
UserStatusDeactivated UserStatus = "deactivated"
|
||||
)
|
||||
|
||||
// User is the full domain model for a registered user.
|
||||
// This is the database-backed identity, separate from auth.User which is the
|
||||
// lightweight JWT-derived identity carried in request context.
|
||||
type User struct {
|
||||
ID UserID
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Name string
|
||||
AvatarURL string
|
||||
Status UserStatus
|
||||
Roles []string
|
||||
LastLoginAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validation constants for user fields.
|
||||
const (
|
||||
MaxNameLen = 100
|
||||
MaxEmailLen = 254 // RFC 5321
|
||||
)
|
||||
|
||||
// NewUser creates a new user with default values.
|
||||
func NewUser(id UserID, email, name string) *User {
|
||||
now := time.Now()
|
||||
return &User{
|
||||
ID: id,
|
||||
Email: email,
|
||||
EmailVerified: false,
|
||||
Name: name,
|
||||
Status: UserStatusActive,
|
||||
Roles: []string{"user"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
8
services/persona-api/internal/email/embed.go
Normal file
8
services/persona-api/internal/email/embed.go
Normal file
@ -0,0 +1,8 @@
|
||||
// Package email provides embedded email templates for persona-api transactional emails.
|
||||
// The TemplateFS is passed to pkg/email.NewRendererFromFS at startup.
|
||||
package email
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:templates
|
||||
var TemplateFS embed.FS
|
||||
202
services/persona-api/internal/email/renderer_test.go
Normal file
202
services/persona-api/internal/email/renderer_test.go
Normal file
@ -0,0 +1,202 @@
|
||||
package email_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
emailpkg "git.threesix.ai/jordan/persona-community-2/pkg/email"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/email"
|
||||
)
|
||||
|
||||
var testBrand = emailpkg.BrandConfig{
|
||||
AppName: "Test App",
|
||||
AppURL: "https://example.com",
|
||||
SupportEmail: "support@example.com",
|
||||
PrimaryColor: "#6366f1",
|
||||
}
|
||||
|
||||
func newTestRenderer(t *testing.T) *emailpkg.Renderer {
|
||||
t.Helper()
|
||||
r, err := emailpkg.NewRendererFromFS(email.TemplateFS, "templates", testBrand)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRendererFromFS: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRendererLoads(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
|
||||
purposes := r.Purposes()
|
||||
want := []string{"email_verify", "login_otp", "magic_link", "password_reset", "welcome"}
|
||||
if len(purposes) != len(want) {
|
||||
t.Fatalf("expected %d purposes, got %d: %v", len(want), len(purposes), purposes)
|
||||
}
|
||||
for i, p := range want {
|
||||
if purposes[i] != p {
|
||||
t.Errorf("purpose[%d]: want %q, got %q", i, p, purposes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLoginOTP(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("login_otp", emailpkg.EmailContext{
|
||||
Code: "482916",
|
||||
ExpiresIn: 10,
|
||||
Purpose: "sign in",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render login_otp: %v", err)
|
||||
}
|
||||
if out.Subject == "" {
|
||||
t.Error("Subject is empty")
|
||||
}
|
||||
if !strings.Contains(out.Subject, "Test App") {
|
||||
t.Errorf("Subject %q does not contain app name", out.Subject)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "482916") {
|
||||
t.Error("HTML does not contain OTP code")
|
||||
}
|
||||
if !strings.Contains(out.HTML, "code-box") {
|
||||
t.Error("HTML does not contain code-box element")
|
||||
}
|
||||
if out.PlainText == "" {
|
||||
t.Error("PlainText is empty")
|
||||
}
|
||||
if !strings.Contains(out.PlainText, "482916") {
|
||||
t.Error("PlainText does not contain OTP code")
|
||||
}
|
||||
if out.Preheader == "" {
|
||||
t.Error("Preheader is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMagicLink(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("magic_link", emailpkg.EmailContext{
|
||||
ActionURL: "https://example.com/auth/verify?token=abc123",
|
||||
ButtonText: "Sign In \u2192",
|
||||
ExpiresIn: 15,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render magic_link: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "Sign In") {
|
||||
t.Error("HTML does not contain button text")
|
||||
}
|
||||
if !strings.Contains(out.HTML, "auth/verify") {
|
||||
t.Error("HTML does not contain action URL")
|
||||
}
|
||||
if out.PlainText == "" {
|
||||
t.Error("PlainText is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPasswordReset(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("password_reset", emailpkg.EmailContext{
|
||||
ActionURL: "https://example.com/auth/reset?token=xyz789",
|
||||
ButtonText: "Reset Password \u2192",
|
||||
ExpiresIn: 60,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render password_reset: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "Reset Password") {
|
||||
t.Error("HTML does not contain button text")
|
||||
}
|
||||
if !strings.Contains(out.Subject, "Reset") {
|
||||
t.Errorf("Subject %q does not mention reset", out.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderVerifyEmail(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("email_verify", emailpkg.EmailContext{
|
||||
Code: "738201",
|
||||
ExpiresIn: 30,
|
||||
Purpose: "verify your email",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render email_verify: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "738201") {
|
||||
t.Error("HTML does not contain verification code")
|
||||
}
|
||||
if !strings.Contains(out.HTML, "code-box") {
|
||||
t.Error("HTML does not contain code-box element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWelcome(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("welcome", emailpkg.EmailContext{
|
||||
ActionURL: "https://example.com/dashboard",
|
||||
ButtonText: "Get Started \u2192",
|
||||
Name: "Jordan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render welcome: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "Jordan") {
|
||||
t.Error("HTML does not contain user name")
|
||||
}
|
||||
if !strings.Contains(out.HTML, "Welcome") {
|
||||
t.Error("HTML does not contain welcome heading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandColorInjection(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
out, err := r.Render("login_otp", emailpkg.EmailContext{
|
||||
Code: "123456",
|
||||
ExpiresIn: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
// Brand primary color should appear in the inlined styles.
|
||||
if !strings.Contains(out.HTML, "#6366f1") {
|
||||
t.Error("HTML does not contain brand color #6366f1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPurposeReturnsError(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
_, err := r.Render("nonexistent_type", emailpkg.EmailContext{})
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown purpose, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTemplatesHaveSubjectAndPreheader(t *testing.T) {
|
||||
r := newTestRenderer(t)
|
||||
contexts := map[string]emailpkg.EmailContext{
|
||||
"login_otp": {Code: "111111", ExpiresIn: 10},
|
||||
"magic_link": {ActionURL: "https://example.com/auth", ButtonText: "Sign In", ExpiresIn: 15},
|
||||
"password_reset": {ActionURL: "https://example.com/reset", ButtonText: "Reset", ExpiresIn: 60},
|
||||
"email_verify": {Code: "222222", ExpiresIn: 30},
|
||||
"welcome": {ActionURL: "https://example.com", ButtonText: "Get Started", Name: "Alex"},
|
||||
}
|
||||
for _, purpose := range r.Purposes() {
|
||||
ctx := contexts[purpose]
|
||||
out, err := r.Render(purpose, ctx)
|
||||
if err != nil {
|
||||
t.Errorf("%s: render error: %v", purpose, err)
|
||||
continue
|
||||
}
|
||||
if out.Subject == "" {
|
||||
t.Errorf("%s: Subject is empty", purpose)
|
||||
}
|
||||
if out.Preheader == "" {
|
||||
t.Errorf("%s: Preheader is empty", purpose)
|
||||
}
|
||||
if out.HTML == "" {
|
||||
t.Errorf("%s: HTML is empty", purpose)
|
||||
}
|
||||
if out.PlainText == "" {
|
||||
t.Errorf("%s: PlainText is empty", purpose)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{{define "button"}}<div class="btn-wrapper">
|
||||
<a href="{{.ActionURL}}" class="btn btn-primary">{{.ButtonText}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
{{define "code_box"}}<div class="code-box">
|
||||
<span class="code-box-value">{{.Code}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -0,0 +1,7 @@
|
||||
{{define "footer"}}<div class="email-footer">
|
||||
{{- if .SupportEmail}}
|
||||
<p>Questions? <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
|
||||
{{- end}}
|
||||
<p>You received this email because you have an account with {{.AppName}}.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -0,0 +1,8 @@
|
||||
{{define "header"}}<div class="email-header">
|
||||
{{- if .LogoURL}}
|
||||
<a href="{{.AppURL}}"><img src="{{.LogoURL}}" alt="{{.AppName}}" class="app-logo"></a>
|
||||
{{- else}}
|
||||
<a href="{{.AppURL}}" class="app-name">{{.AppName}}</a>
|
||||
{{- end}}
|
||||
</div>
|
||||
{{end}}
|
||||
83
services/persona-api/internal/email/templates/_layout.html
Normal file
83
services/persona-api/internal/email/templates/_layout.html
Normal file
@ -0,0 +1,83 @@
|
||||
{{define "layout"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>{{.AppName}}</title>
|
||||
<style>
|
||||
/* Reset */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background-color: #f9fafb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 16px; line-height: 1.6; color: #374151; }
|
||||
|
||||
/* Page wrapper */
|
||||
.email-wrapper { width: 100%; background-color: #f9fafb; padding: 32px 16px; }
|
||||
|
||||
/* Card */
|
||||
.email-card { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
||||
|
||||
/* Preheader — visually hidden but visible in inbox preview text */
|
||||
.preheader { display: none; max-height: 0; overflow: hidden; mso-hide: all; font-size: 1px; line-height: 1px; color: #f9fafb; opacity: 0; }
|
||||
|
||||
/* Header */
|
||||
.email-header { padding: 24px 32px; border-bottom: 1px solid #e5e7eb; background-color: #ffffff; }
|
||||
.app-name { font-size: 20px; font-weight: 700; color: {{.PrimaryColor}}; text-decoration: none; letter-spacing: -0.01em; display: inline-block; }
|
||||
.app-logo { max-height: 36px; max-width: 160px; display: block; }
|
||||
|
||||
/* Body */
|
||||
.email-body { padding: 40px 32px; }
|
||||
|
||||
/* Typography */
|
||||
h1 { font-size: 24px; font-weight: 700; line-height: 1.3; color: #111827; margin-bottom: 12px; }
|
||||
h2 { font-size: 20px; font-weight: 600; line-height: 1.4; color: #111827; margin-bottom: 12px; }
|
||||
p { font-size: 16px; line-height: 1.6; color: #374151; margin-bottom: 16px; }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
a { color: {{.PrimaryColor}}; text-decoration: underline; }
|
||||
.text-muted { color: #6b7280; font-size: 14px; }
|
||||
|
||||
/* Divider */
|
||||
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 28px 0; }
|
||||
|
||||
/* OTP code box */
|
||||
.code-box { background-color: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px; text-align: center; margin: 28px 0; }
|
||||
.code-box-value { font-family: 'Courier New', Courier, monospace; font-size: 48px; font-weight: 700; letter-spacing: 0.15em; color: #111827; display: block; line-height: 1; }
|
||||
|
||||
/* CTA button */
|
||||
.btn-wrapper { margin: 28px 0; text-align: left; }
|
||||
.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; text-align: center; line-height: 1.4; }
|
||||
.btn-primary { background-color: {{.PrimaryColor}}; color: #ffffff; }
|
||||
|
||||
/* Footer */
|
||||
.email-footer { padding: 24px 32px; border-top: 1px solid #e5e7eb; background-color: #f9fafb; }
|
||||
.email-footer p { font-size: 14px; color: #6b7280; margin-bottom: 4px; line-height: 1.5; }
|
||||
.email-footer p:last-child { margin-bottom: 0; }
|
||||
.email-footer a { color: #6b7280; }
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-wrapper { padding: 0; }
|
||||
.email-card { border-radius: 0; border-left: none; border-right: none; }
|
||||
.email-body { padding: 28px 20px; }
|
||||
.email-header { padding: 20px; }
|
||||
.email-footer { padding: 20px; }
|
||||
.code-box-value { font-size: 36px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<!-- Preheader: visible in inbox preview, invisible in email body -->
|
||||
<span class="preheader">{{.Preheader}}‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ </span>
|
||||
<div class="email-card">
|
||||
{{template "header" .}}
|
||||
<div class="email-body">
|
||||
{{template "body" .}}
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@ -0,0 +1,8 @@
|
||||
{{define "body"}}
|
||||
<p>Hi there,</p>
|
||||
<p>Enter this code to verify your email address for <strong>{{.AppName}}</strong>:</p>
|
||||
|
||||
{{template "code_box" .}}
|
||||
|
||||
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.</p>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: email_verify
|
||||
category: transactional
|
||||
subject: "Verify your {{.AppName}} email address"
|
||||
preheader: "{{.Code}} is your email verification code — expires in {{.ExpiresIn}} minutes."
|
||||
@ -0,0 +1,8 @@
|
||||
{{define "body"}}
|
||||
<p>Hi there,</p>
|
||||
<p>Here is your sign in code for <strong>{{.AppName}}</strong>:</p>
|
||||
|
||||
{{template "code_box" .}}
|
||||
|
||||
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.</p>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: login_otp
|
||||
category: transactional
|
||||
subject: "Your {{.AppName}} sign in code"
|
||||
preheader: "{{.Code}} is your sign in code — expires in {{.ExpiresIn}} minutes."
|
||||
@ -0,0 +1,13 @@
|
||||
{{define "body"}}
|
||||
<h2>Sign in to {{.AppName}}</h2>
|
||||
<p>Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.</p>
|
||||
|
||||
{{template "button" .}}
|
||||
|
||||
<p class="text-muted">Or copy this link into your browser:<br>
|
||||
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<p class="text-muted">If you didn't request this sign-in link, you can safely ignore this email — your account is secure.</p>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: magic_link
|
||||
category: transactional
|
||||
subject: "Sign in to {{.AppName}}"
|
||||
preheader: "Click to sign in to your {{.AppName}} account — link expires in {{.ExpiresIn}} minutes."
|
||||
@ -0,0 +1,13 @@
|
||||
{{define "body"}}
|
||||
<h2>Reset your password</h2>
|
||||
<p>Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.</p>
|
||||
|
||||
{{template "button" .}}
|
||||
|
||||
<p class="text-muted">Or copy this link into your browser:<br>
|
||||
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<p class="text-muted">If you didn't request a password reset, you can safely ignore this email — your password won't change.</p>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: password_reset
|
||||
category: transactional
|
||||
subject: "Reset your {{.AppName}} password"
|
||||
preheader: "You requested a password reset for {{.AppName}} — link expires in {{.ExpiresIn}} minutes."
|
||||
@ -0,0 +1,10 @@
|
||||
{{define "body"}}
|
||||
<h2>Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!</h2>
|
||||
<p>Your account is ready. Start exploring everything {{.AppName}} has to offer.</p>
|
||||
|
||||
{{template "button" .}}
|
||||
|
||||
{{- if .SupportEmail}}
|
||||
<p class="text-muted">Have questions? Reach us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a>.</p>
|
||||
{{- end}}
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: welcome
|
||||
category: transactional
|
||||
subject: "Welcome to {{.AppName}}"
|
||||
preheader: "Your {{.AppName}} account is ready. Get started today."
|
||||
34
services/persona-api/internal/port/album.go
Normal file
34
services/persona-api/internal/port/album.go
Normal file
@ -0,0 +1,34 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/album"
|
||||
)
|
||||
|
||||
// AlbumRepository defines persistence operations for albums.
|
||||
// It extends album.AlbumUpdater so implementations satisfy both interfaces.
|
||||
type AlbumRepository interface {
|
||||
album.AlbumUpdater
|
||||
|
||||
// Create persists a new album. Sets ID, CreatedAt, UpdatedAt.
|
||||
Create(ctx context.Context, a *album.Album) error
|
||||
|
||||
// Get returns an album by ID. Returns ErrAlbumNotFound if not found.
|
||||
Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error)
|
||||
|
||||
// List returns all albums for a user, ordered by CreatedAt DESC.
|
||||
List(ctx context.Context, userID string) ([]album.Album, error)
|
||||
|
||||
// Delete removes an album and all its shots. Does NOT delete stored images.
|
||||
Delete(ctx context.Context, id album.AlbumID, userID string) error
|
||||
|
||||
// ResetShot clears a shot's ImageURL, JobID, Error, and sets Status to pending.
|
||||
ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error
|
||||
|
||||
// UpdateAnchorJobID sets the AnchorJobID when the anchor generation job is enqueued.
|
||||
UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error
|
||||
|
||||
// UpdateShotJobID sets the shot's JobID when a shot generation job is enqueued.
|
||||
UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error
|
||||
}
|
||||
24
services/persona-api/internal/port/auth_code.go
Normal file
24
services/persona-api/internal/port/auth_code.go
Normal file
@ -0,0 +1,24 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// AuthCodeRepository defines the interface for auth code persistence.
|
||||
type AuthCodeRepository interface {
|
||||
// Create persists a new auth code.
|
||||
Create(ctx context.Context, code *domain.AuthCode) error
|
||||
|
||||
// FindValid returns an unused, non-expired code matching the criteria.
|
||||
// Returns domain.ErrInvalidAuthCode if no valid code exists.
|
||||
FindValid(ctx context.Context, email string, code string, purpose domain.AuthCodePurpose) (*domain.AuthCode, error)
|
||||
|
||||
// MarkUsed sets the used_at timestamp on a code, making it single-use.
|
||||
MarkUsed(ctx context.Context, id string) error
|
||||
|
||||
// DeleteExpired removes codes that have passed their expiry time.
|
||||
// Returns the number of codes deleted.
|
||||
DeleteExpired(ctx context.Context) (int, error)
|
||||
}
|
||||
11
services/persona-api/internal/port/email.go
Normal file
11
services/persona-api/internal/port/email.go
Normal file
@ -0,0 +1,11 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
// EmailSender sends emails for authentication flows (OTP, magic link, password reset, etc.).
|
||||
type EmailSender interface {
|
||||
// SendAuthCode sends an authentication code to the given email.
|
||||
// purpose identifies the flow (e.g. "login_otp", "magic_link", "password_reset", "email_verify").
|
||||
// code is the token or OTP to include in the email.
|
||||
SendAuthCode(ctx context.Context, email, code, purpose string) error
|
||||
}
|
||||
37
services/persona-api/internal/port/example.go
Normal file
37
services/persona-api/internal/port/example.go
Normal file
@ -0,0 +1,37 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
// These interfaces define the contracts between the application core and
|
||||
// infrastructure adapters, enabling testability and flexibility.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// ExampleRepository defines the interface for example persistence operations.
|
||||
// Implementations may use databases, in-memory storage, or external services.
|
||||
type ExampleRepository interface {
|
||||
// List returns all examples.
|
||||
List(ctx context.Context) ([]domain.Example, error)
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
|
||||
|
||||
// Create stores a new example.
|
||||
// The example must have a valid ID set.
|
||||
Create(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Update(ctx context.Context, example *domain.Example) error
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
Delete(ctx context.Context, id domain.ExampleID) error
|
||||
|
||||
// ExistsByName checks if an example with the given name exists.
|
||||
// Used for duplicate detection.
|
||||
ExistsByName(ctx context.Context, name string) (bool, error)
|
||||
}
|
||||
38
services/persona-api/internal/port/media.go
Normal file
38
services/persona-api/internal/port/media.go
Normal file
@ -0,0 +1,38 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// MediaRepository defines the interface for media metadata persistence.
|
||||
type MediaRepository interface {
|
||||
// Create persists a new media object record.
|
||||
Create(ctx context.Context, obj *domain.MediaObject) error
|
||||
|
||||
// Get returns a media object by ID. Returns domain.ErrNotFound if not found or soft-deleted.
|
||||
Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error)
|
||||
|
||||
// ListByUser returns non-deleted media objects for a user, ordered by created_at DESC.
|
||||
ListByUser(ctx context.Context, userID domain.UserID, opts ListMediaOptions) ([]domain.MediaObject, int, error)
|
||||
|
||||
// SoftDelete marks a media object as deleted without removing it.
|
||||
SoftDelete(ctx context.Context, id domain.MediaObjectID) error
|
||||
|
||||
// HardDelete permanently removes a media object record.
|
||||
HardDelete(ctx context.Context, id domain.MediaObjectID) error
|
||||
|
||||
// GetByPath returns a media object by its storage path. Returns domain.ErrNotFound if not found.
|
||||
GetByPath(ctx context.Context, path string) (*domain.MediaObject, error)
|
||||
}
|
||||
|
||||
// ListMediaOptions controls filtering and pagination for media queries.
|
||||
type ListMediaOptions struct {
|
||||
// ContentTypePrefix filters by MIME type prefix (e.g., "image/", "video/").
|
||||
ContentTypePrefix string
|
||||
// Limit is the maximum number of results (0 = default 50).
|
||||
Limit int
|
||||
// Offset is the pagination offset.
|
||||
Offset int
|
||||
}
|
||||
33
services/persona-api/internal/port/session.go
Normal file
33
services/persona-api/internal/port/session.go
Normal file
@ -0,0 +1,33 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// SessionRepository defines the interface for session persistence.
|
||||
type SessionRepository interface {
|
||||
// Create persists a new session record.
|
||||
Create(ctx context.Context, session *domain.Session) error
|
||||
|
||||
// Get returns a session by ID. Returns domain.ErrSessionNotFound if not found.
|
||||
Get(ctx context.Context, id domain.SessionID) (*domain.Session, error)
|
||||
|
||||
// ListByUser returns all active (non-revoked) sessions for a user.
|
||||
ListByUser(ctx context.Context, userID domain.UserID) ([]domain.Session, error)
|
||||
|
||||
// UpdateLastActive updates the last_active_at timestamp for a session.
|
||||
UpdateLastActive(ctx context.Context, id domain.SessionID) error
|
||||
|
||||
// Revoke marks a session as revoked by setting revoked_at.
|
||||
Revoke(ctx context.Context, id domain.SessionID) error
|
||||
|
||||
// RevokeAllForUser revokes all sessions for a user.
|
||||
// If exceptID is non-nil, that session is kept active.
|
||||
RevokeAllForUser(ctx context.Context, userID domain.UserID, exceptID *domain.SessionID) error
|
||||
|
||||
// DeleteExpired removes sessions that have passed their expiry time.
|
||||
// Returns the number of sessions deleted.
|
||||
DeleteExpired(ctx context.Context) (int, error)
|
||||
}
|
||||
51
services/persona-api/internal/port/user.go
Normal file
51
services/persona-api/internal/port/user.go
Normal file
@ -0,0 +1,51 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
)
|
||||
|
||||
// UserRepository defines the interface for user persistence.
|
||||
type UserRepository interface {
|
||||
// Create persists a new user.
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
|
||||
// Get returns a user by ID. Returns domain.ErrUserNotFound if not found.
|
||||
Get(ctx context.Context, id domain.UserID) (*domain.User, error)
|
||||
|
||||
// GetByEmail returns a user by email. Returns domain.ErrUserNotFound if not found.
|
||||
GetByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
|
||||
// Update persists changes to an existing user.
|
||||
Update(ctx context.Context, user *domain.User) error
|
||||
|
||||
// UpdateLastLogin sets the last_login_at timestamp.
|
||||
UpdateLastLogin(ctx context.Context, id domain.UserID) error
|
||||
|
||||
// ExistsByEmail returns true if a user with the given email exists.
|
||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||
|
||||
// Password operations (separate from user CRUD because OAuth-only users have no password)
|
||||
|
||||
// SetPassword stores a bcrypt hash for a user. Creates or replaces existing.
|
||||
SetPassword(ctx context.Context, userID domain.UserID, hash string) error
|
||||
|
||||
// GetPasswordHash returns the bcrypt hash for a user.
|
||||
// Returns empty string and nil error if user has no password set.
|
||||
GetPasswordHash(ctx context.Context, userID domain.UserID) (string, error)
|
||||
|
||||
// HasPassword returns true if the user has a password set.
|
||||
HasPassword(ctx context.Context, userID domain.UserID) (bool, error)
|
||||
|
||||
// Role operations
|
||||
|
||||
// AddRole grants a role to a user. No-op if already granted.
|
||||
AddRole(ctx context.Context, userID domain.UserID, role string) error
|
||||
|
||||
// RemoveRole revokes a role from a user. No-op if not granted.
|
||||
RemoveRole(ctx context.Context, userID domain.UserID, role string) error
|
||||
|
||||
// GetRoles returns all roles for a user.
|
||||
GetRoles(ctx context.Context, userID domain.UserID) ([]string, error)
|
||||
}
|
||||
186
services/persona-api/internal/service/album.go
Normal file
186
services/persona-api/internal/service/album.go
Normal file
@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/album"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/queue"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// AlbumService handles album creation, retrieval, and generation orchestration.
|
||||
// All generation is async: service enqueues jobs and returns immediately.
|
||||
// Results arrive via SSE on the user:<userId> channel.
|
||||
type AlbumService struct {
|
||||
albums port.AlbumRepository
|
||||
queue queue.Producer
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewAlbumService creates a new AlbumService.
|
||||
func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService {
|
||||
return &AlbumService{
|
||||
albums: albums,
|
||||
queue: q,
|
||||
logger: logger.WithComponent("AlbumService"),
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a new album with the given shots and persists it.
|
||||
// Shots are provided as ShotTemplate slices (Label + Direction).
|
||||
func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("album name is required")
|
||||
}
|
||||
if subjectDesc == "" {
|
||||
return nil, fmt.Errorf("subject description is required")
|
||||
}
|
||||
if len(shots) == 0 {
|
||||
return nil, fmt.Errorf("at least one shot is required")
|
||||
}
|
||||
if len(shots) > 20 {
|
||||
return nil, fmt.Errorf("maximum 20 shots per album")
|
||||
}
|
||||
|
||||
shotList := make([]album.Shot, len(shots))
|
||||
for i, tmpl := range shots {
|
||||
shotList[i] = album.Shot{
|
||||
Index: i,
|
||||
Label: tmpl.Label,
|
||||
Direction: tmpl.Direction,
|
||||
Status: album.ShotPending,
|
||||
}
|
||||
}
|
||||
|
||||
a := &album.Album{
|
||||
ID: album.AlbumID("alb_" + generateID()),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
SubjectDesc: subjectDesc,
|
||||
Shots: shotList,
|
||||
}
|
||||
|
||||
if err := s.albums.Create(ctx, a); err != nil {
|
||||
return nil, fmt.Errorf("create album: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList))
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Get returns an album by ID, enforcing user ownership.
|
||||
func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
|
||||
a, err := s.albums.Get(ctx, id, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("album not found: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// List returns all albums for a user.
|
||||
func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) {
|
||||
return s.albums.List(ctx, userID)
|
||||
}
|
||||
|
||||
// Delete removes an album. Does NOT delete stored images.
|
||||
func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error {
|
||||
return s.albums.Delete(ctx, id, userID)
|
||||
}
|
||||
|
||||
// GenerateAnchor enqueues an anchor generation job for an album.
|
||||
// Returns the job ID. Result arrives via album_anchor_complete SSE event.
|
||||
func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) {
|
||||
a, err := s.albums.Get(ctx, id, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("album not found: %w", err)
|
||||
}
|
||||
|
||||
jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{
|
||||
"albumId": string(a.ID),
|
||||
"userId": userID,
|
||||
"subjectDesc": a.SubjectDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueue anchor job: %w", err)
|
||||
}
|
||||
|
||||
if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil {
|
||||
s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id))
|
||||
}
|
||||
|
||||
s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||
return jobID, nil
|
||||
}
|
||||
|
||||
// GenerateAllShots enqueues generation jobs for all pending shots.
|
||||
// Returns 422 if the album has no anchor yet (shots require an anchor reference).
|
||||
func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) {
|
||||
a, err := s.albums.Get(ctx, id, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("album not found: %w", err)
|
||||
}
|
||||
if a.AnchorURL == "" {
|
||||
return nil, album.ErrAnchorRequired
|
||||
}
|
||||
|
||||
var jobIDs []string
|
||||
for _, shot := range a.Shots {
|
||||
if shot.Status != album.ShotPending && shot.Status != album.ShotFailed {
|
||||
continue
|
||||
}
|
||||
jobID, err := s.enqueueShotJob(ctx, a, shot.Index)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to enqueue shot", "error", err, "album_id", string(id), "shot_index", shot.Index)
|
||||
continue
|
||||
}
|
||||
jobIDs = append(jobIDs, jobID)
|
||||
}
|
||||
|
||||
s.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
|
||||
return jobIDs, nil
|
||||
}
|
||||
|
||||
// GenerateShot enqueues a generation job for a single shot (for regeneration).
|
||||
func (s *AlbumService) GenerateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) (string, error) {
|
||||
a, err := s.albums.Get(ctx, id, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("album not found: %w", err)
|
||||
}
|
||||
if a.AnchorURL == "" {
|
||||
return "", fmt.Errorf("anchor must be generated before shots")
|
||||
}
|
||||
if shotIndex < 0 || shotIndex >= len(a.Shots) {
|
||||
return "", fmt.Errorf("shot index out of range: %d", shotIndex)
|
||||
}
|
||||
return s.enqueueShotJob(ctx, a, shotIndex)
|
||||
}
|
||||
|
||||
// ResetShot clears a shot back to pending so it can be regenerated.
|
||||
func (s *AlbumService) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
|
||||
return s.albums.ResetShot(ctx, id, userID, shotIndex)
|
||||
}
|
||||
|
||||
// enqueueShotJob is the internal helper that enqueues a single shot generation job.
|
||||
func (s *AlbumService) enqueueShotJob(ctx context.Context, a *album.Album, shotIndex int) (string, error) {
|
||||
shot := a.Shots[shotIndex]
|
||||
jobID, err := s.queue.Enqueue(ctx, "generate_shot", map[string]any{
|
||||
"albumId": string(a.ID),
|
||||
"userId": a.UserID,
|
||||
"shotIndex": shotIndex,
|
||||
"anchorUrl": a.AnchorURL,
|
||||
"subjectDesc": a.SubjectDesc,
|
||||
"direction": shot.Direction,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueue shot job: %w", err)
|
||||
}
|
||||
|
||||
if err := s.albums.UpdateShotJobID(ctx, a.ID, a.UserID, shotIndex, jobID); err != nil {
|
||||
s.logger.Warn("failed to persist shot job ID", "error", err, "shot_index", shotIndex)
|
||||
}
|
||||
|
||||
return jobID, nil
|
||||
}
|
||||
|
||||
644
services/persona-api/internal/service/auth.go
Normal file
644
services/persona-api/internal/service/auth.go
Normal file
@ -0,0 +1,644 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
const (
|
||||
// TokenLifetime is the access token duration (short-lived, requires refresh).
|
||||
TokenLifetime = 15 * time.Minute
|
||||
// SessionLifetime is how long a session stays valid before requiring re-login.
|
||||
SessionLifetime = 30 * 24 * time.Hour // 30 days
|
||||
// OTPExpiry is how long a one-time password is valid.
|
||||
OTPExpiry = 10 * time.Minute
|
||||
// MagicLinkExpiry is how long a magic link token is valid.
|
||||
MagicLinkExpiry = 15 * time.Minute
|
||||
// PasswordResetExpiry is how long a password reset token is valid.
|
||||
PasswordResetExpiry = 1 * time.Hour
|
||||
// EmailVerifyExpiry is how long an email verification code is valid.
|
||||
EmailVerifyExpiry = 24 * time.Hour
|
||||
)
|
||||
|
||||
// AuthService handles all authentication and identity flows.
|
||||
type AuthService struct {
|
||||
users port.UserRepository
|
||||
sessions port.SessionRepository
|
||||
codes port.AuthCodeRepository
|
||||
email port.EmailSender
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
registrationEnabled bool
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service.
|
||||
func NewAuthService(
|
||||
users port.UserRepository,
|
||||
sessions port.SessionRepository,
|
||||
codes port.AuthCodeRepository,
|
||||
email port.EmailSender,
|
||||
jwtSecret string,
|
||||
registrationEnabled bool,
|
||||
logger *logging.Logger,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
users: users,
|
||||
sessions: sessions,
|
||||
codes: codes,
|
||||
email: email,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
issuer: "persona-community-2",
|
||||
registrationEnabled: registrationEnabled,
|
||||
logger: logger.WithService("AuthService"),
|
||||
}
|
||||
}
|
||||
|
||||
// LoginOutput is the result of a successful login or registration.
|
||||
type LoginOutput struct {
|
||||
Token string
|
||||
User *domain.User
|
||||
}
|
||||
|
||||
// Register creates a new user account with email and password.
|
||||
func (s *AuthService) Register(ctx context.Context, email, password, name, ip, userAgent string) (*LoginOutput, error) {
|
||||
if !s.registrationEnabled {
|
||||
return nil, domain.ErrRegistrationDisabled
|
||||
}
|
||||
|
||||
if err := auth.ValidatePasswordStrength(password); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
||||
}
|
||||
|
||||
name = strings.TrimSpace(name)
|
||||
if len(name) > domain.MaxNameLen {
|
||||
return nil, domain.ErrNameTooLong
|
||||
}
|
||||
if len(email) > domain.MaxEmailLen {
|
||||
return nil, domain.ErrEmailTooLong
|
||||
}
|
||||
|
||||
exists, err := s.users.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateEmail
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
userID := domain.UserID("usr_" + generateID())
|
||||
user := domain.NewUser(userID, email, name)
|
||||
|
||||
if err := s.users.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.users.SetPassword(ctx, userID, hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("user registered", "user_id", string(userID), "email", email)
|
||||
|
||||
return s.createSession(ctx, user, ip, userAgent)
|
||||
}
|
||||
|
||||
// LoginWithPassword authenticates a user with email and password.
|
||||
func (s *AuthService) LoginWithPassword(ctx context.Context, email, password, ip, userAgent string) (*LoginOutput, error) {
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserNotFound) {
|
||||
return nil, domain.ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Status == domain.UserStatusSuspended {
|
||||
return nil, domain.ErrUserSuspended
|
||||
}
|
||||
|
||||
hash, err := s.users.GetPasswordHash(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hash == "" || !auth.CheckPassword(password, hash) {
|
||||
s.logger.Warn("invalid password attempt", "email", email)
|
||||
return nil, domain.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
||||
s.logger.Info("user logged in", "user_id", string(user.ID), "email", email)
|
||||
|
||||
return s.createSession(ctx, user, ip, userAgent)
|
||||
}
|
||||
|
||||
// RefreshToken issues a new access token if the session is still active.
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, sessionID string, userID string) (*LoginOutput, error) {
|
||||
sid := domain.SessionID(sessionID)
|
||||
session, err := s.sessions.Get(ctx, sid)
|
||||
if err != nil {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
if !session.IsActive() {
|
||||
return nil, domain.ErrSessionRevoked
|
||||
}
|
||||
|
||||
user, err := s.users.Get(ctx, domain.UserID(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status == domain.UserStatusSuspended {
|
||||
return nil, domain.ErrUserSuspended
|
||||
}
|
||||
|
||||
_ = s.sessions.UpdateLastActive(ctx, sid)
|
||||
|
||||
token, err := s.generateToken(user, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &LoginOutput{Token: token, User: user}, nil
|
||||
}
|
||||
|
||||
// Logout revokes the current session.
|
||||
func (s *AuthService) Logout(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
|
||||
}
|
||||
|
||||
// LogoutAll revokes all sessions for a user, optionally keeping one.
|
||||
func (s *AuthService) LogoutAll(ctx context.Context, userID string, exceptSessionID *string) error {
|
||||
var except *domain.SessionID
|
||||
if exceptSessionID != nil {
|
||||
sid := domain.SessionID(*exceptSessionID)
|
||||
except = &sid
|
||||
}
|
||||
return s.sessions.RevokeAllForUser(ctx, domain.UserID(userID), except)
|
||||
}
|
||||
|
||||
// CheckSession returns whether a session is active (not revoked, not expired).
|
||||
// Used as auth.SessionChecker for the SessionCheck middleware.
|
||||
func (s *AuthService) CheckSession(ctx context.Context, sessionID string) (bool, error) {
|
||||
session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return session.IsActive(), nil
|
||||
}
|
||||
|
||||
// ListSessions returns all active sessions for a user.
|
||||
func (s *AuthService) ListSessions(ctx context.Context, userID string) ([]domain.Session, error) {
|
||||
return s.sessions.ListByUser(ctx, domain.UserID(userID))
|
||||
}
|
||||
|
||||
// RevokeSession revokes a specific session for a user.
|
||||
func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID string) error {
|
||||
session, err := s.sessions.Get(ctx, domain.SessionID(sessionID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.UserID != domain.UserID(userID) {
|
||||
return domain.ErrSessionNotFound
|
||||
}
|
||||
return s.sessions.Revoke(ctx, domain.SessionID(sessionID))
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the full user for the given ID.
|
||||
func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*domain.User, error) {
|
||||
return s.users.Get(ctx, domain.UserID(userID))
|
||||
}
|
||||
|
||||
// UpdateProfile updates a user's name and avatar.
|
||||
func (s *AuthService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*domain.User, error) {
|
||||
user, err := s.users.Get(ctx, domain.UserID(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
name = strings.TrimSpace(name)
|
||||
if len(name) > domain.MaxNameLen {
|
||||
return nil, domain.ErrNameTooLong
|
||||
}
|
||||
user.Name = name
|
||||
}
|
||||
if avatarURL != "" {
|
||||
if err := validateAvatarURL(avatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.AvatarURL = avatarURL
|
||||
}
|
||||
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password after verifying the current one.
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
|
||||
uid := domain.UserID(userID)
|
||||
|
||||
hash, err := s.users.GetPasswordHash(ctx, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hash == "" || !auth.CheckPassword(currentPassword, hash) {
|
||||
return domain.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := auth.ValidatePasswordStrength(newPassword); err != nil {
|
||||
return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
||||
}
|
||||
|
||||
newHash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
return s.users.SetPassword(ctx, uid, newHash)
|
||||
}
|
||||
|
||||
// SendOTP generates a one-time password for the given email.
|
||||
// If the email is not registered and registration is enabled, the code is still
|
||||
// sent — the account will be created when the code is verified. This supports a
|
||||
// unified register+login flow with a single OTP email.
|
||||
func (s *AuthService) SendOTP(ctx context.Context, email, ip string) error {
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||
return err
|
||||
}
|
||||
// Unknown email: only proceed if registration is open.
|
||||
if !s.registrationEnabled {
|
||||
s.logger.Info("OTP requested for unknown email (registration disabled)", "email", email)
|
||||
return nil
|
||||
}
|
||||
// Registration enabled — send code anyway. UserID will be nil until verify.
|
||||
user = nil
|
||||
}
|
||||
|
||||
code := generateOTP()
|
||||
var uid *domain.UserID
|
||||
if user != nil {
|
||||
uid = &user.ID
|
||||
}
|
||||
authCode := &domain.AuthCode{
|
||||
ID: "acd_" + generateID(),
|
||||
UserID: uid,
|
||||
Email: email,
|
||||
Code: code,
|
||||
Purpose: domain.PurposeLoginOTP,
|
||||
ExpiresAt: time.Now().Add(OTPExpiry),
|
||||
IPAddress: ip,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.codes.Create(ctx, authCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("auth code created", "purpose", "login_otp", "email", email, "code_id", authCode.ID)
|
||||
if err := s.email.SendAuthCode(ctx, email, code, string(domain.PurposeLoginOTP)); err != nil {
|
||||
s.logger.Error("failed to send OTP email", "email", email, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies a one-time password and returns a login token.
|
||||
// If the email has no account yet and registration is enabled, the account is
|
||||
// created automatically — OTP delivery proves email ownership.
|
||||
func (s *AuthService) VerifyOTP(ctx context.Context, email, code, ip, userAgent string) (*LoginOutput, error) {
|
||||
authCode, err := s.codes.FindValid(ctx, email, code, domain.PurposeLoginOTP)
|
||||
if err != nil {
|
||||
return nil, domain.ErrInvalidAuthCode
|
||||
}
|
||||
|
||||
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if !s.registrationEnabled {
|
||||
return nil, domain.ErrRegistrationDisabled
|
||||
}
|
||||
// Auto-register: OTP delivery already proved email ownership.
|
||||
user, err = s.autoRegisterViaOTP(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
||||
s.logger.Info("user logged in via OTP", "user_id", string(user.ID), "email", email)
|
||||
|
||||
return s.createSession(ctx, user, ip, userAgent)
|
||||
}
|
||||
|
||||
// autoRegisterViaOTP creates a minimal user account for an email that just
|
||||
// verified an OTP. Email is considered verified because OTP delivery proved
|
||||
// ownership. The name defaults to the local part of the email address.
|
||||
func (s *AuthService) autoRegisterViaOTP(ctx context.Context, email string) (*domain.User, error) {
|
||||
name := email
|
||||
if at := strings.IndexByte(email, '@'); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
userID := domain.UserID("usr_" + generateID())
|
||||
user := domain.NewUser(userID, email, name)
|
||||
user.EmailVerified = true // OTP delivery proves ownership
|
||||
|
||||
if err := s.users.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("user auto-registered via OTP", "user_id", string(userID), "email", email)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SendMagicLink generates and logs a magic link token.
|
||||
func (s *AuthService) SendMagicLink(ctx context.Context, email, ip string) error {
|
||||
// Magic links can work for existing users.
|
||||
// Don't reveal whether email exists — but propagate infrastructure errors.
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil && !errors.Is(err, domain.ErrUserNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
token := generateHexToken()
|
||||
var uid *domain.UserID
|
||||
if user != nil {
|
||||
uid = &user.ID
|
||||
}
|
||||
|
||||
authCode := &domain.AuthCode{
|
||||
ID: "acd_" + generateID(),
|
||||
UserID: uid,
|
||||
Email: email,
|
||||
Code: token,
|
||||
Purpose: domain.PurposeMagicLink,
|
||||
ExpiresAt: time.Now().Add(MagicLinkExpiry),
|
||||
IPAddress: ip,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.codes.Create(ctx, authCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("auth code created", "purpose", "magic_link", "email", email, "code_id", authCode.ID)
|
||||
if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposeMagicLink)); err != nil {
|
||||
s.logger.Error("failed to send magic link email", "email", email, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyMagicLink verifies a magic link token and returns a login token.
|
||||
func (s *AuthService) VerifyMagicLink(ctx context.Context, email, token, ip, userAgent string) (*LoginOutput, error) {
|
||||
authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposeMagicLink)
|
||||
if err != nil {
|
||||
return nil, domain.ErrInvalidAuthCode
|
||||
}
|
||||
|
||||
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
||||
s.logger.Info("user logged in via magic link", "user_id", string(user.ID), "email", email)
|
||||
|
||||
return s.createSession(ctx, user, ip, userAgent)
|
||||
}
|
||||
|
||||
// ForgotPassword generates a password reset token.
|
||||
func (s *AuthService) ForgotPassword(ctx context.Context, email, ip string) error {
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserNotFound) {
|
||||
// Don't reveal whether email exists
|
||||
s.logger.Info("password reset requested for unknown email", "email", email)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
token := generateHexToken()
|
||||
uid := user.ID
|
||||
authCode := &domain.AuthCode{
|
||||
ID: "acd_" + generateID(),
|
||||
UserID: &uid,
|
||||
Email: email,
|
||||
Code: token,
|
||||
Purpose: domain.PurposePasswordReset,
|
||||
ExpiresAt: time.Now().Add(PasswordResetExpiry),
|
||||
IPAddress: ip,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.codes.Create(ctx, authCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("auth code created", "purpose", "password_reset", "email", email, "code_id", authCode.ID)
|
||||
if err := s.email.SendAuthCode(ctx, email, token, string(domain.PurposePasswordReset)); err != nil {
|
||||
s.logger.Error("failed to send password reset email", "email", email, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword sets a new password using a reset token and revokes all sessions.
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error {
|
||||
authCode, err := s.codes.FindValid(ctx, email, token, domain.PurposePasswordReset)
|
||||
if err != nil {
|
||||
return domain.ErrInvalidAuthCode
|
||||
}
|
||||
|
||||
if err := auth.ValidatePasswordStrength(newPassword); err != nil {
|
||||
return fmt.Errorf("%w: %w", domain.ErrWeakPassword, err)
|
||||
}
|
||||
|
||||
user, err := s.users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
|
||||
if err := s.users.SetPassword(ctx, user.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Revoke all sessions — user must re-login with new password.
|
||||
_ = s.sessions.RevokeAllForUser(ctx, user.ID, nil)
|
||||
s.logger.Info("password reset completed", "user_id", string(user.ID), "email", email)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerifyEmail generates an email verification code.
|
||||
func (s *AuthService) SendVerifyEmail(ctx context.Context, userID string) error {
|
||||
user, err := s.users.Get(ctx, domain.UserID(userID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.EmailVerified {
|
||||
return nil
|
||||
}
|
||||
|
||||
code := generateOTP()
|
||||
uid := user.ID
|
||||
authCode := &domain.AuthCode{
|
||||
ID: "acd_" + generateID(),
|
||||
UserID: &uid,
|
||||
Email: user.Email,
|
||||
Code: code,
|
||||
Purpose: domain.PurposeEmailVerify,
|
||||
ExpiresAt: time.Now().Add(EmailVerifyExpiry),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.codes.Create(ctx, authCode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("auth code created", "purpose", "email_verify", "email", user.Email, "code_id", authCode.ID)
|
||||
if err := s.email.SendAuthCode(ctx, user.Email, code, string(domain.PurposeEmailVerify)); err != nil {
|
||||
s.logger.Error("failed to send email verification", "email", user.Email, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyEmail marks the user's email as verified.
|
||||
func (s *AuthService) VerifyEmail(ctx context.Context, userID, code string) error {
|
||||
user, err := s.users.Get(ctx, domain.UserID(userID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authCode, err := s.codes.FindValid(ctx, user.Email, code, domain.PurposeEmailVerify)
|
||||
if err != nil {
|
||||
return domain.ErrInvalidAuthCode
|
||||
}
|
||||
|
||||
if err := s.codes.MarkUsed(ctx, authCode.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.EmailVerified = true
|
||||
if err := s.users.Update(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("email verified", "user_id", userID, "email", user.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSession creates a session record and generates a JWT.
|
||||
func (s *AuthService) createSession(ctx context.Context, user *domain.User, ip, userAgent string) (*LoginOutput, error) {
|
||||
sessionID := "ses_" + generateID()
|
||||
now := time.Now()
|
||||
|
||||
session := &domain.Session{
|
||||
ID: domain.SessionID(sessionID),
|
||||
UserID: user.ID,
|
||||
IPAddress: ip,
|
||||
UserAgent: userAgent,
|
||||
DeviceLabel: auth.ParseDeviceLabel(userAgent),
|
||||
LastActiveAt: now,
|
||||
ExpiresAt: now.Add(SessionLifetime),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.sessions.Create(ctx, session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := s.generateToken(user, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &LoginOutput{Token: token, User: user}, nil
|
||||
}
|
||||
|
||||
// generateToken creates a JWT for the user with the given session ID.
|
||||
func (s *AuthService) generateToken(user *domain.User, sessionID string) (string, error) {
|
||||
authUser := &auth.User{
|
||||
ID: string(user.ID),
|
||||
Email: user.Email,
|
||||
Roles: user.Roles,
|
||||
}
|
||||
return auth.GenerateTokenWithSession(
|
||||
s.jwtSecret, authUser, TokenLifetime, s.issuer, s.issuer, sessionID,
|
||||
)
|
||||
}
|
||||
|
||||
// generateID returns a random hex string suitable for entity IDs.
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateOTP returns a 6-digit numeric one-time password.
|
||||
func generateOTP() string {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64())
|
||||
}
|
||||
|
||||
// validateAvatarURL checks that the URL uses http or https.
|
||||
func validateAvatarURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return domain.ErrInvalidAvatarURL
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return domain.ErrInvalidAvatarURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateHexToken returns a 32-character hex token for magic links and resets.
|
||||
func generateHexToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
137
services/persona-api/internal/service/example.go
Normal file
137
services/persona-api/internal/service/example.go
Normal file
@ -0,0 +1,137 @@
|
||||
// Package service provides business logic / use cases for the application.
|
||||
// Services orchestrate domain operations using port interfaces.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// ExampleService handles example-related business logic.
|
||||
type ExampleService struct {
|
||||
repo port.ExampleRepository
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewExampleService creates a new example service.
|
||||
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
|
||||
return &ExampleService{
|
||||
repo: repo,
|
||||
logger: logger.WithService("ExampleService"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns all examples.
|
||||
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// Get returns an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// CreateInput contains the data needed to create an example.
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Create creates a new example with duplicate detection.
|
||||
// Returns domain.ErrDuplicateExample if name already exists.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
|
||||
// Check for duplicates
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
|
||||
// Generate new ID
|
||||
id := domain.ExampleID(uuid.New().String())
|
||||
|
||||
// Create domain entity (validates name)
|
||||
example, err := domain.NewExample(id, input.Name, input.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Create(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example created", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// UpdateInput contains the data needed to update an example.
|
||||
type UpdateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Update modifies an existing example.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
|
||||
// Returns domain.ErrInvalidExampleName if name is invalid.
|
||||
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
|
||||
// Fetch existing
|
||||
example, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for name conflicts (only if name changed)
|
||||
if example.Name != input.Name {
|
||||
exists, err := s.repo.ExistsByName(ctx, input.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateExample
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain entity (validates name)
|
||||
if err := example.Update(input.Name, input.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Update(ctx, example); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("example updated", "id", id, "name", input.Name)
|
||||
return example, nil
|
||||
}
|
||||
|
||||
// Delete removes an example by ID.
|
||||
// Returns domain.ErrExampleNotFound if not found.
|
||||
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
// Verify exists before delete
|
||||
if _, err := s.repo.Get(ctx, id); err != nil {
|
||||
if errors.Is(err, domain.ErrExampleNotFound) {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("example deleted", "id", id)
|
||||
return nil
|
||||
}
|
||||
282
services/persona-api/internal/service/example_test.go
Normal file
282
services/persona-api/internal/service/example_test.go
Normal file
@ -0,0 +1,282 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/persona-community-2/pkg/logging"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
|
||||
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
|
||||
)
|
||||
|
||||
// mockExampleRepository implements port.ExampleRepository for testing.
|
||||
type mockExampleRepository struct {
|
||||
mu sync.RWMutex
|
||||
examples map[domain.ExampleID]*domain.Example
|
||||
}
|
||||
|
||||
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
|
||||
|
||||
func newMockExampleRepository() *mockExampleRepository {
|
||||
return &mockExampleRepository{
|
||||
examples: make(map[domain.ExampleID]*domain.Example),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]domain.Example, 0, len(m.examples))
|
||||
for _, e := range m.examples {
|
||||
result = append(result, *e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
e, ok := m.examples[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrExampleNotFound
|
||||
}
|
||||
// Return a copy to avoid mutation
|
||||
copy := *e
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[example.ID]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
// Store a copy
|
||||
copy := *example
|
||||
m.examples[example.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, ok := m.examples[id]; !ok {
|
||||
return domain.ErrExampleNotFound
|
||||
}
|
||||
delete(m.examples, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, e := range m.examples {
|
||||
if e.Name == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TestExampleService_Create(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("creates example successfully", func(t *testing.T) {
|
||||
example, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "A test description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Test Example" {
|
||||
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
|
||||
}
|
||||
if example.ID.IsZero() {
|
||||
t.Error("expected non-empty ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects duplicate name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Test Example",
|
||||
Description: "Another description",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty name", func(t *testing.T) {
|
||||
_, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "",
|
||||
Description: "Description",
|
||||
})
|
||||
if err != domain.ErrInvalidExampleName {
|
||||
t.Errorf("expected ErrInvalidExampleName, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Get(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Get Test",
|
||||
Description: "Description",
|
||||
})
|
||||
|
||||
t.Run("returns existing example", func(t *testing.T) {
|
||||
example, err := svc.Get(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if example.Name != "Get Test" {
|
||||
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Get(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Update(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create examples
|
||||
example1, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 1",
|
||||
Description: "Original",
|
||||
})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Other",
|
||||
})
|
||||
|
||||
t.Run("updates example successfully", func(t *testing.T) {
|
||||
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Updated description",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Name != "Updated Name" {
|
||||
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows same name on same example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Updated Name",
|
||||
Description: "Same name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error updating with same name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects name conflict", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
|
||||
Name: "Update Test 2",
|
||||
Description: "Conflict",
|
||||
})
|
||||
if err != domain.ErrDuplicateExample {
|
||||
t.Errorf("expected ErrDuplicateExample, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
|
||||
Name: "Anything",
|
||||
Description: "",
|
||||
})
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_Delete(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
// Create an example first
|
||||
created, _ := svc.Create(context.Background(), CreateInput{
|
||||
Name: "Delete Test",
|
||||
Description: "To be deleted",
|
||||
})
|
||||
|
||||
t.Run("deletes example successfully", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
_, err = svc.Get(context.Background(), created.ID)
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns not found for missing example", func(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), "nonexistent-id")
|
||||
if err != domain.ErrExampleNotFound {
|
||||
t.Errorf("expected ErrExampleNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleService_List(t *testing.T) {
|
||||
repo := newMockExampleRepository()
|
||||
svc := NewExampleService(repo, logging.Nop())
|
||||
|
||||
t.Run("returns empty list initially", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 0 {
|
||||
t.Errorf("expected 0 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
|
||||
// Create some examples
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
|
||||
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
|
||||
|
||||
t.Run("returns all examples", func(t *testing.T) {
|
||||
examples, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(examples) != 2 {
|
||||
t.Errorf("expected 2 examples, got %d", len(examples))
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user