Add components: service/persona-api, worker/media-worker, app-react/creator-ui
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
jordan 2026-02-23 10:54:06 +00:00
parent cb3d4d5786
commit be5dcf1f28
111 changed files with 10367 additions and 2 deletions

View File

@ -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"

View File

@ -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/` |

View File

@ -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

View 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 },
],
},
};

View 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;"]

View File

@ -0,0 +1,6 @@
name: creator-ui
type: app
port: 3001
path: apps/creator-ui
stack: react
dependencies: []

View 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>

View 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;
}

View 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"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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
View 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;

View File

@ -0,0 +1,6 @@
/* Import design system tokens */
@import '@persona-community-2/ui/styles';
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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);

View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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;

View 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" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View 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,
},
});

View File

@ -1,4 +1,6 @@
go 1.25
use ./pkg
use ./services/persona-api
use ./workers/media-worker
// Component modules will be added below

View 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

View 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"]

View 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/

View 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)
}
}
}
}

View 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)
);

View File

@ -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;

View File

@ -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 != '';

View 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

View 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

View File

View 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
}

View 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, &notify.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,
}
}
}

View 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] = &copy
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 &copy, 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
}

View 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
}

View 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 &copy, 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] = &copy
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] = &copy
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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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 = &currentSID
}
if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil {
return err
}
httpresponse.NoContent(w)
return nil
}

View 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
}

View 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
}
}

View 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 &copy, 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] = &copy
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] = &copy
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)
}
})
}
}

View 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)
}

View 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",
})
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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")
)

View 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
}

View 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
}

View 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)
}

View 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,
}
}

View 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

View 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)
}
}
}

View File

@ -0,0 +1,4 @@
{{define "button"}}<div class="btn-wrapper">
<a href="{{.ActionURL}}" class="btn btn-primary">{{.ButtonText}}</a>
</div>
{{end}}

View File

@ -0,0 +1,4 @@
{{define "code_box"}}<div class="code-box">
<span class="code-box-value">{{.Code}}</span>
</div>
{{end}}

View File

@ -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}}

View File

@ -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}}

View 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}}&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;</span>
<div class="email-card">
{{template "header" .}}
<div class="email-body">
{{template "body" .}}
</div>
{{template "footer" .}}
</div>
</div>
</body>
</html>
{{end}}

View File

@ -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}}

View File

@ -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."

View File

@ -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}}

View File

@ -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."

View File

@ -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}}

View File

@ -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."

View File

@ -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}}

View File

@ -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."

View File

@ -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}}

View File

@ -0,0 +1,4 @@
purpose: welcome
category: transactional
subject: "Welcome to {{.AppName}}"
preheader: "Your {{.AppName}} account is ready. Get started today."

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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 &copy, 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] = &copy
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] = &copy
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