diff --git a/.woodpecker.yml b/.woodpecker.yml index d9f0490..64b7092 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -57,12 +57,239 @@ steps: event: push # COMPONENT_STEPS_BELOW + + # Woodpecker CI step for creator-ui React app + # Add this step to your .woodpecker.yml + + build-creator-ui: + depends_on: [preflight] + image: woodpeckerci/plugin-kaniko + settings: + registry: registry.threesix.ai + repo: persona-community-1/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-1/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-1-creator-ui persona-community-1-creator-ui=registry.threesix.ai/persona-community-1/creator-ui:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/persona-community-1-creator-ui -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment persona-community-1-creator-ui" + ACTUAL_IMAGE=$(kubectl get deployment/persona-community-1-creator-ui -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/persona-community-1/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-1-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-1/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-1/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-1-media-worker persona-community-1-media-worker=registry.threesix.ai/persona-community-1/media-worker:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/persona-community-1-media-worker -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment persona-community-1-media-worker" + ACTUAL_IMAGE=$(kubectl get deployment/persona-community-1-media-worker -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/persona-community-1/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-1-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-1/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-1/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-1-persona-api persona-community-1-persona-api=registry.threesix.ai/persona-community-1/persona-api:${CI_COMMIT_SHA:0:8} -n projects + - kubectl patch deployment/persona-community-1-persona-api -n projects -p '{"spec":{"replicas":1}}' + - | + echo "==> Verifying deployment persona-community-1-persona-api" + ACTUAL_IMAGE=$(kubectl get deployment/persona-community-1-persona-api -n projects -o jsonpath='{.spec.template.spec.containers[0].image}') + EXPECTED_IMAGE="registry.threesix.ai/persona-community-1/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-1-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" diff --git a/CLAUDE.md b/CLAUDE.md index b344ef2..11ddeb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,4 +94,9 @@ persona-community-1/ ## Components - +| Component | Type | Path | +|-----------|------|------| +| **persona-api** | API service | `services/persona-api/` | +| **media-worker** | Background worker | `workers/media-worker/` | +| **creator-ui** | React app | `apps/creator-ui/` | + diff --git a/Procfile b/Procfile index 8e897c6..322b2dd 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,5 @@ # Local development processes # Components will be added below as they're created +persona-api: cd services/persona-api && make run +media-worker: cd workers/media-worker && make run +creator-ui: cd apps/creator-ui && npm run dev diff --git a/apps/creator-ui/.eslintrc.cjs b/apps/creator-ui/.eslintrc.cjs new file mode 100644 index 0000000..4c99537 --- /dev/null +++ b/apps/creator-ui/.eslintrc.cjs @@ -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 }, + ], + }, +}; diff --git a/apps/creator-ui/Dockerfile b/apps/creator-ui/Dockerfile new file mode 100644 index 0000000..27d2fc9 --- /dev/null +++ b/apps/creator-ui/Dockerfile @@ -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;"] diff --git a/apps/creator-ui/component.yaml b/apps/creator-ui/component.yaml new file mode 100644 index 0000000..4b6f5db --- /dev/null +++ b/apps/creator-ui/component.yaml @@ -0,0 +1,6 @@ +name: creator-ui +type: app +port: 3001 +path: apps/creator-ui +stack: react +dependencies: [] diff --git a/apps/creator-ui/index.html b/apps/creator-ui/index.html new file mode 100644 index 0000000..b1fad01 --- /dev/null +++ b/apps/creator-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + creator-ui | persona-community-1 + + +
+ + + diff --git a/apps/creator-ui/nginx.conf b/apps/creator-ui/nginx.conf new file mode 100644 index 0000000..1e23deb --- /dev/null +++ b/apps/creator-ui/nginx.conf @@ -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; +} diff --git a/apps/creator-ui/package.json b/apps/creator-ui/package.json new file mode 100644 index 0000000..13fe607 --- /dev/null +++ b/apps/creator-ui/package.json @@ -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-1/ai-client": "workspace:*", + "@persona-community-1/api-client": "workspace:*", + "@persona-community-1/auth": "workspace:*", + "@persona-community-1/layout": "workspace:*", + "@persona-community-1/logger": "workspace:*", + "@persona-community-1/realtime": "workspace:*", + "@persona-community-1/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" + } +} diff --git a/apps/creator-ui/postcss.config.cjs b/apps/creator-ui/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/creator-ui/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/creator-ui/public/vite.svg b/apps/creator-ui/public/vite.svg new file mode 100644 index 0000000..6a41099 --- /dev/null +++ b/apps/creator-ui/public/vite.svg @@ -0,0 +1 @@ + diff --git a/apps/creator-ui/src/App.tsx b/apps/creator-ui/src/App.tsx new file mode 100644 index 0000000..cc106c9 --- /dev/null +++ b/apps/creator-ui/src/App.tsx @@ -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-1/auth'; +import { DashboardShell, Sidebar, Header, type NavItem } from '@persona-community-1/layout'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Badge, + Home, + ImageIcon, + Users, + Settings, + BarChart3, + MessageSquare, + Sparkles, + Loader2, + AlertCircle, +} from '@persona-community-1/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 = { + '/': 'Dashboard', + '/chat': 'Chat', + '/generate': 'Generate', + '/media': 'Media', + '/analytics': 'Analytics', + '/users': 'Users', + '/settings': 'Settings', + '/settings/sessions': 'Sessions', + '/settings/verify-email': 'Verify Email', +}; + +function DashboardPage() { + return ( +
+ + + Welcome to creator-ui + + This is part of the{' '} + + persona-community-1 + {' '} + monorepo, using the shared UI library and layout components. + + + +
+ + +
+
+
+ +
+ + + Total Users + 1,234 + + + +12% from last month + + + + + + Active Sessions + 567 + + + Live + + + + + + API Requests + 89.2k + + + High traffic + + +
+ +

+ Edit this file at{' '} + + apps/creator-ui/src/App.tsx + +

+
+ ); +} + +function UsersPage() { + return ( +
+
+
+

All Users

+

Manage your team members and their roles.

+
+ +
+ +
+ {[ + { name: 'Alice Chen', role: 'Admin', status: 'Active' }, + { name: 'Bob Martinez', role: 'Editor', status: 'Active' }, + { name: 'Carol Singh', role: 'Viewer', status: 'Invited' }, + ].map((user) => ( + + + {user.name} + {user.role} + + + + {user.status} + + + + ))} +
+
+ ); +} + +function AnalyticsPage() { + return ( +
+
+ + + Page Views + 24.5k + + + +8% this week + + + + + + Bounce Rate + 32% + + + -3% improvement + + + + + + Avg. Session + 4m 12s + + + Stable + + +
+ + + + Traffic Sources + Where your visitors are coming from this month. + + +
+ {[ + { 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) => ( +
+ {row.source} +
+
+
+
+ {row.visits} +
+
+ ))} +
+ + +
+ ); +} + +function SettingsPage() { + const { logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( +
+ + + General + Manage your application settings and preferences. + + +
+ {[ + { label: 'Application Name', value: 'persona-community-1' }, + { label: 'Environment', value: 'Production' }, + { label: 'Region', value: 'US West' }, + ].map((setting) => ( +
+ {setting.label} + {setting.value} +
+ ))} +
+
+
+ + + + Account + Manage your account settings. + + +
+
+

Sign Out

+

Sign out of your account on this device.

+
+ +
+
+
+ + + + Danger Zone + Irreversible actions for your application. + + +
+
+

Delete Application

+

Permanently remove this application and all its data.

+
+ +
+
+
+
+ ); +} + +function LoadingScreen() { + return ( +
+
+ +

Loading...

+
+
+ ); +} + +function MagicLinkCallbackPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginWithMagicLink } = useAuth(); + const [error, setError] = useState(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 ( +
+ + + {verifying ? 'Verifying Magic Link' : 'Verification Failed'} + + {verifying + ? 'Please wait while we verify your magic link...' + : 'We could not verify your magic link.'} + + + + {verifying ? ( + + ) : ( + <> +
+ +

{error}

+
+ + + + + )} +
+
+
+ ); +} + +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 ( + persona-community-1 + } + items={itemsWithActive} + onNavigate={(href) => navigate(href)} + footer={ +
+ {user?.email || 'v0.0.1'} +
+ } + /> + } + header={ +
+ } + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +function AppRoutes() { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + + } /> + } /> + } /> + } /> + } /> + { + // Navigate to login, storing current location for redirect after login + navigate(path, { state: { from: location.pathname }, replace: true }); + }} + fallback={} + > + + + } + /> + + ); +} + +function App() { + // Determine API base URL from environment or current origin + const apiBaseUrl = import.meta.env.VITE_API_URL || ''; + + return ( + + + + ); +} + +export default App; diff --git a/apps/creator-ui/src/index.css b/apps/creator-ui/src/index.css new file mode 100644 index 0000000..76f6aea --- /dev/null +++ b/apps/creator-ui/src/index.css @@ -0,0 +1,6 @@ +/* Import design system tokens */ +@import '@persona-community-1/ui/styles'; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/creator-ui/src/lib/logger.ts b/apps/creator-ui/src/lib/logger.ts new file mode 100644 index 0000000..6e25fcd --- /dev/null +++ b/apps/creator-ui/src/lib/logger.ts @@ -0,0 +1,11 @@ +import { createLogger, installGlobalHandlers } from '@persona-community-1/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); diff --git a/apps/creator-ui/src/main.tsx b/apps/creator-ui/src/main.tsx new file mode 100644 index 0000000..7757f92 --- /dev/null +++ b/apps/creator-ui/src/main.tsx @@ -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( + + + + + +); diff --git a/apps/creator-ui/src/pages/ChatPage.tsx b/apps/creator-ui/src/pages/ChatPage.tsx new file mode 100644 index 0000000..b191ab2 --- /dev/null +++ b/apps/creator-ui/src/pages/ChatPage.tsx @@ -0,0 +1,190 @@ +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { useAuth } from '@persona-community-1/auth'; +import { useChat } from '@persona-community-1/realtime'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + ChatBubble, + ChatInput, + Badge, + ProviderBadge, +} from '@persona-community-1/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(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(null); + + // Merge user messages + AI messages into a single sorted timeline + const timeline = useMemo(() => { + 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 Connected; + case 'connecting': + return Connecting...; + case 'disconnected': + return Disconnected; + case 'error': + return Error; + default: + return null; + } + }; + + return ( +
+ + +
+ AI Chat + + Chat with AI in real-time + +
+
+ + {onlineUsers.length} online + + {connectionBadge()} +
+
+ + + {/* Messages area */} +
+ {timeline.length === 0 ? ( +
+

+ No messages yet. Start the conversation! +

+
+ ) : ( + timeline.map((msg) => ( +
+ + {msg.provider && ( +
+ +
+ )} +
+ )) + )} +
+
+ + {/* Input area */} +
+ {sendError && ( +
+ {sendError} +
+ )} + +
+ + +
+ ); +} diff --git a/apps/creator-ui/src/pages/ForgotPasswordPage.tsx b/apps/creator-ui/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..7aed8a0 --- /dev/null +++ b/apps/creator-ui/src/pages/ForgotPasswordPage.tsx @@ -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-1/ui'; + +export function ForgotPasswordPage() { + const [isLoading, setIsLoading] = useState(false); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + + const apiPrefix = import.meta.env.VITE_API_URL || ''; + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+ + + Reset your password + + {sent + ? 'Check your email for a reset link' + : "Enter your email and we'll send you a reset link"} + + + + {!sent ? ( +
+ + {error && ( + + {error} + + )} + + + + + + + + Back to sign in + + +
+ ) : ( + +

+ If an account exists with that email, you will receive a password reset link. +

+

+ In dev mode, check the server console for the reset token. +

+ + Back to sign in + +
+ )} +
+
+ ); +} diff --git a/apps/creator-ui/src/pages/GeneratePage.tsx b/apps/creator-ui/src/pages/GeneratePage.tsx new file mode 100644 index 0000000..6cafbc8 --- /dev/null +++ b/apps/creator-ui/src/pages/GeneratePage.tsx @@ -0,0 +1,248 @@ +import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@persona-community-1/auth'; +import { useMediaGeneration } from '@persona-community-1/realtime'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + FormField, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, + ImageGrid, + VideoGrid, + GenerationProgress, + ProviderBadge, + Loader2, +} from '@persona-community-1/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('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({ + endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`, + sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`, + userId: user?.id || 'anonymous', + headers: authHeaders, + }); + + const videoGen = useMediaGeneration({ + 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 ( +
+ + + AI Generation + + Generate images and videos using AI (Gemini / LaoZhang) + + + + {/* Mode toggle */} +
+ + +
+ + setPrompt(e.target.value)} + placeholder={ + mode === 'image' + ? 'A serene mountain landscape at sunset...' + : 'A cat playing piano in a jazz club...' + } + /> + +
+
+ + +
+ + {mode === 'image' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ + +
+
+ + {isGenerating && ( + + +
+ {gen.message || 'Starting...'} + {gen.progress}% +
+ + {gen.sseState !== 'connected' && ( +

+ SSE {gen.sseState} — events may be delayed +

+ )} +
+
+ )} + + {gen.status === 'failed' && gen.error && ( + + + {gen.error} + + + )} + + {gen.status === 'complete' && imageGen.result && mode === 'image' && ( + + + Results +
+ {imageGen.result.provider && } + +
+
+ + ({ + src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`, + alt: prompt, + }))} + columns={imageGen.result.images.length > 1 ? 2 : 1} + /> + +
+ )} + + {gen.status === 'complete' && videoGen.result && mode === 'video' && ( + + + Results +
+ {videoGen.result.provider && } + +
+
+ + ({ + src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`, + mimeType: vid.mimeType, + alt: prompt, + }))} + /> + +
+ )} +
+ ); +} diff --git a/apps/creator-ui/src/pages/LoginPage.tsx b/apps/creator-ui/src/pages/LoginPage.tsx new file mode 100644 index 0000000..3d48379 --- /dev/null +++ b/apps/creator-ui/src/pages/LoginPage.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { useAuth } from '@persona-community-1/auth'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + FormField, + useFormErrors, + Alert, + AlertDescription, + Loader2, +} from '@persona-community-1/ui'; +import { isApiClientError } from '@persona-community-1/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(null); + const [activeTab, setActiveTab] = useState('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) => { + 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) => { + 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) => { + 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) => { + 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 ( +
+ + + Welcome back + Sign in to your persona-community-1 account + + + + {/* Tab switcher */} +
+ + + +
+ + {generalError && ( + + {generalError} + + )} + + {/* Password tab */} + {activeTab === 'password' && ( +
+ + +
+ + Forgot password? + +
+ + + )} + + {/* OTP tab */} + {activeTab === 'otp' && !otpSent && ( +
+ + + + )} + + {activeTab === 'otp' && otpSent && ( +
+

+ A 6-digit code was sent to {otpEmail} +

+ + + + + )} + + {/* Magic Link tab */} + {activeTab === 'magic-link' && !magicLinkSent && ( +
+ + + + )} + + {activeTab === 'magic-link' && magicLinkSent && ( +
+

Check your email

+

+ We sent a sign-in link to your email. Click it to continue. +

+

+ In dev mode, check the server console for the link token. +

+
+ )} +
+ + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ); +} diff --git a/apps/creator-ui/src/pages/MediaPage.tsx b/apps/creator-ui/src/pages/MediaPage.tsx new file mode 100644 index 0000000..448a4ae --- /dev/null +++ b/apps/creator-ui/src/pages/MediaPage.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useAuth } from '@persona-community-1/auth'; +import { useMediaUpload } from '@persona-community-1/realtime'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + MediaUploader, + MediaLibrary, + type MediaItem, + Badge, +} from '@persona-community-1/ui'; + +export function MediaPage() { + const { getToken } = useAuth(); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [deleteError, setDeleteError] = useState(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(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 ( +
+ + + Upload Media + + Upload images and videos to your media library + + + + console.error('Upload error:', err)} + /> + {mediaUpload.error && ( +

{mediaUpload.error}

+ )} +
+
+ + + +
+ Media Library + + Your uploaded and generated media files + +
+ {items.length > 0 && ( + {items.length} files + )} +
+ + {(deleteError || fetchError) && ( +

{deleteError || fetchError}

+ )} + {isLoading ? ( +
Loading...
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/creator-ui/src/pages/RegisterPage.tsx b/apps/creator-ui/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..aed7834 --- /dev/null +++ b/apps/creator-ui/src/pages/RegisterPage.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '@persona-community-1/auth'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + FormField, + useFormErrors, + Alert, + AlertDescription, + Loader2, +} from '@persona-community-1/ui'; + +export function RegisterPage() { + const navigate = useNavigate(); + const { register, isLoading } = useAuth(); + const { setErrors, clearErrors, getError } = useFormErrors(); + const [generalError, setGeneralError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+ + + Create an account + Get started with persona-community-1 + + +
+ + {generalError && ( + + {generalError} + + )} + + + + + + + + + + + + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+
+ ); +} diff --git a/apps/creator-ui/src/pages/ResetPasswordPage.tsx b/apps/creator-ui/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..d79b989 --- /dev/null +++ b/apps/creator-ui/src/pages/ResetPasswordPage.tsx @@ -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-1/ui'; + +export function ResetPasswordPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { setErrors, clearErrors, getError } = useFormErrors(); + const [isLoading, setIsLoading] = useState(false); + const [generalError, setGeneralError] = useState(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) => { + 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 ( +
+ + + Invalid reset link + This password reset link is missing required parameters. + + + + Request a new reset link + + + +
+ ); + } + + return ( +
+ + + Set new password + Enter your new password below + + +
+ + {generalError && ( + + {generalError} + + )} + + + + + + + + + + Back to sign in + + +
+
+
+ ); +} diff --git a/apps/creator-ui/src/pages/SessionsPage.tsx b/apps/creator-ui/src/pages/SessionsPage.tsx new file mode 100644 index 0000000..f5698c8 --- /dev/null +++ b/apps/creator-ui/src/pages/SessionsPage.tsx @@ -0,0 +1,178 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAuth, type Session } from '@persona-community-1/auth'; +import { + Button, + Card, + CardContent, + Badge, + Alert, + AlertDescription, + Loader2, + Trash2, +} from '@persona-community-1/ui'; + +export function SessionsPage() { + const { getToken } = useAuth(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [revokingId, setRevokingId] = useState(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 ( +
+ +
+ ); + } + + return ( +
+
+
+

Active Sessions

+

+ Manage your active login sessions across devices. +

+
+ {sessions.length > 1 && ( + + )} +
+ + {error && ( + + {error} + + )} + +
+ {sessions.length === 0 ? ( + + + No active sessions found. + + + ) : ( + sessions.map((session) => ( + + +
+
+ + {session.deviceLabel || 'Unknown device'} + + {session.isCurrent && ( + Current + )} +
+
+ + {session.ipAddress || 'Unknown IP'} + + + Last active: {formatDate(session.lastActiveAt)} + +
+
+ + {!session.isCurrent && ( + + )} +
+
+ )) + )} +
+
+ ); +} diff --git a/apps/creator-ui/src/pages/VerifyEmailPage.tsx b/apps/creator-ui/src/pages/VerifyEmailPage.tsx new file mode 100644 index 0000000..5554ee1 --- /dev/null +++ b/apps/creator-ui/src/pages/VerifyEmailPage.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { useAuth } from '@persona-community-1/auth'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + FormField, + Alert, + AlertDescription, + Loader2, + Check, +} from '@persona-community-1/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(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) => { + 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 ( +
+ + +
+ +
+ Email verified + Your email address has been verified successfully. +
+
+
+ ); + } + + return ( +
+ + + Verify your email + + {codeSent + ? `Enter the 6-digit code sent to ${user?.email}` + : 'Verify your email address to access all features'} + + + + + {error && ( + + {error} + + )} + + {!codeSent ? ( +
+

+ We'll send a verification code to {user?.email} +

+ +

+ In dev mode, check the server console for the code. +

+
+ ) : ( +
+ + + + + )} +
+
+
+ ); +} diff --git a/apps/creator-ui/src/vite-env.d.ts b/apps/creator-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/creator-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/creator-ui/tailwind.config.ts b/apps/creator-ui/tailwind.config.ts new file mode 100644 index 0000000..b4d1f21 --- /dev/null +++ b/apps/creator-ui/tailwind.config.ts @@ -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; diff --git a/apps/creator-ui/tsconfig.json b/apps/creator-ui/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/apps/creator-ui/tsconfig.json @@ -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" }] +} diff --git a/apps/creator-ui/tsconfig.node.json b/apps/creator-ui/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/apps/creator-ui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/creator-ui/vite.config.ts b/apps/creator-ui/vite.config.ts new file mode 100644 index 0000000..87e1fa5 --- /dev/null +++ b/apps/creator-ui/vite.config.ts @@ -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, + }, +}); diff --git a/go.work b/go.work index 5c79122..bf77df7 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,6 @@ go 1.25 use ./pkg +use ./services/persona-api +use ./workers/media-worker // Component modules will be added below diff --git a/services/persona-api/.env.example b/services/persona-api/.env.example new file mode 100644 index 0000000..e06dd54 --- /dev/null +++ b/services/persona-api/.env.example @@ -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-1?sslmode=disable diff --git a/services/persona-api/Dockerfile b/services/persona-api/Dockerfile new file mode 100644 index 0000000..349ec91 --- /dev/null +++ b/services/persona-api/Dockerfile @@ -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"] diff --git a/services/persona-api/Makefile b/services/persona-api/Makefile new file mode 100644 index 0000000..a396a22 --- /dev/null +++ b/services/persona-api/Makefile @@ -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-1 + +# 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/ diff --git a/services/persona-api/cmd/server/main.go b/services/persona-api/cmd/server/main.go new file mode 100644 index 0000000..d26a83b --- /dev/null +++ b/services/persona-api/cmd/server/main.go @@ -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-1/pkg/album" + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/personagen" + "git.threesix.ai/jordan/persona-community-1/pkg/database" + "git.threesix.ai/jordan/persona-community-1/pkg/gemini" + "git.threesix.ai/jordan/persona-community-1/pkg/laozhang" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/mediagen" + mediagenAdapters "git.threesix.ai/jordan/persona-community-1/pkg/mediagen/adapters" + "git.threesix.ai/jordan/persona-community-1/pkg/generation" + emailpkg "git.threesix.ai/jordan/persona-community-1/pkg/email" + "git.threesix.ai/jordan/persona-community-1/pkg/notify" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/pkg/realtime" + "git.threesix.ai/jordan/persona-community-1/pkg/storage" + "git.threesix.ai/jordan/persona-community-1/pkg/textgen" + textgenAdapters "git.threesix.ai/jordan/persona-community-1/pkg/textgen/adapters" + emailadapter "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/adapter/email" + componentemail "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/email" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/adapter/memory" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/adapter/postgres" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/api" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/config" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" + "git.threesix.ai/jordan/persona-community-1/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) + } + } + } +} diff --git a/services/persona-api/cmd/server/migrations/.gitkeep b/services/persona-api/cmd/server/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/persona-api/cmd/server/migrations/001_create_users.sql b/services/persona-api/cmd/server/migrations/001_create_users.sql new file mode 100644 index 0000000..13877c8 --- /dev/null +++ b/services/persona-api/cmd/server/migrations/001_create_users.sql @@ -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) +); diff --git a/services/persona-api/cmd/server/migrations/002_add_indexes.sql b/services/persona-api/cmd/server/migrations/002_add_indexes.sql new file mode 100644 index 0000000..1013dbd --- /dev/null +++ b/services/persona-api/cmd/server/migrations/002_add_indexes.sql @@ -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; diff --git a/services/persona-api/cmd/server/migrations/003_create_media_objects.sql b/services/persona-api/cmd/server/migrations/003_create_media_objects.sql new file mode 100644 index 0000000..8e96b5c --- /dev/null +++ b/services/persona-api/cmd/server/migrations/003_create_media_objects.sql @@ -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 != ''; diff --git a/services/persona-api/component.yaml b/services/persona-api/component.yaml new file mode 100644 index 0000000..2a75cb5 --- /dev/null +++ b/services/persona-api/component.yaml @@ -0,0 +1,9 @@ +name: persona-api +type: service +port: 8001 +path: services/persona-api +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - other-service diff --git a/services/persona-api/go.mod b/services/persona-api/go.mod new file mode 100644 index 0000000..e36e910 --- /dev/null +++ b/services/persona-api/go.mod @@ -0,0 +1,8 @@ +module git.threesix.ai/jordan/persona-community-1/services/persona-api + +go 1.25 + +require git.threesix.ai/jordan/persona-community-1/pkg v0.0.0 + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/persona-community-1/pkg => ../../pkg diff --git a/services/persona-api/go.sum b/services/persona-api/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/services/persona-api/internal/adapter/email/log.go b/services/persona-api/internal/adapter/email/log.go new file mode 100644 index 0000000..5927ff7 --- /dev/null +++ b/services/persona-api/internal/adapter/email/log.go @@ -0,0 +1,32 @@ +// Package email provides email sending adapters for authentication flows. +package email + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/email/notify.go b/services/persona-api/internal/adapter/email/notify.go new file mode 100644 index 0000000..5b366b4 --- /dev/null +++ b/services/persona-api/internal/adapter/email/notify.go @@ -0,0 +1,112 @@ +package email + +import ( + "context" + "fmt" + + emailpkg "git.threesix.ai/jordan/persona-community-1/pkg/email" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/notify" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" +) + +// Compile-time interface check. +var _ port.EmailSender = (*NotifySender)(nil) + +// NotifySender sends transactional emails via the orchard9 notify service. +// It renders HTML using the email.Renderer before sending so every email +// has a styled layout with inline CSS. +type NotifySender struct { + client *notify.Client + renderer *emailpkg.Renderer + host string + from string + logger *logging.Logger +} + +// NewNotifySender creates a notify-backed email sender with HTML rendering. +func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender { + return &NotifySender{ + client: client, + renderer: renderer, + host: host, + from: from, + logger: logger.WithComponent("EmailSender"), + } +} + +func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error { + // Map (purpose, code) to the correct template context. + emailCtx := purposeToContext(purpose, code) + + rendered, err := s.renderer.Render(purpose, emailCtx) + if err != nil { + s.logger.Error("failed to render email template", "purpose", purpose, "error", err) + return fmt.Errorf("render email %s: %w", purpose, err) + } + if rendered.CSSInlineErr != nil { + s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients", + "purpose", purpose, "error", rendered.CSSInlineErr) + } + + resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{ + To: toEmail, + From: s.from, + Content: notify.Content{ + Subject: rendered.Subject, + HTML: rendered.HTML, + Text: rendered.PlainText, + }, + Meta: notify.Meta{ + Host: s.host, + Category: "critical", + Tags: []string{"auth", purpose}, + }, + Options: notify.Options{ + // Stable idempotency key: same user + same code = same key, safe to retry. + IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code), + }, + }) + if err != nil { + s.logger.Error("failed to send email via notify", "to", toEmail, "purpose", purpose, "error", err) + return fmt.Errorf("send email: %w", err) + } + + s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID) + return nil +} + +// purposeToContext maps (purpose, code) to an EmailContext for template rendering. +// code may be an OTP digit string or a URL depending on the purpose. +func purposeToContext(purpose, code string) emailpkg.EmailContext { + switch purpose { + case "login_otp": + return emailpkg.EmailContext{ + Code: code, + ExpiresIn: 10, + Purpose: "sign in", + } + case "magic_link": + return emailpkg.EmailContext{ + ActionURL: emailpkg.SafeURL(code), + ButtonText: "Sign In \u2192", + ExpiresIn: 15, + } + case "password_reset": + return emailpkg.EmailContext{ + ActionURL: emailpkg.SafeURL(code), + ButtonText: "Reset Password \u2192", + ExpiresIn: 60, + } + case "email_verify": + return emailpkg.EmailContext{ + Code: code, + ExpiresIn: 30, + Purpose: "verify your email", + } + default: + return emailpkg.EmailContext{ + Code: code, + } + } +} diff --git a/services/persona-api/internal/adapter/memory/album.go b/services/persona-api/internal/adapter/memory/album.go new file mode 100644 index 0000000..32f3b8f --- /dev/null +++ b/services/persona-api/internal/adapter/memory/album.go @@ -0,0 +1,177 @@ +package memory + +import ( + "context" + "fmt" + "sync" + "time" + + "git.threesix.ai/jordan/persona-community-1/pkg/album" +) + +// AlbumRepository is an in-memory implementation of port.AlbumRepository. +// Used in standalone dev mode (no DATABASE_URL). Not safe for persistence across restarts. +type AlbumRepository struct { + mu sync.RWMutex + albums map[album.AlbumID]*album.Album +} + +// NewAlbumRepository creates an in-memory album repository. +func NewAlbumRepository() *AlbumRepository { + return &AlbumRepository{ + albums: make(map[album.AlbumID]*album.Album), + } +} + +// Create persists a new album. The caller must set ID, Name, SubjectDesc, Shots before calling. +func (r *AlbumRepository) Create(ctx context.Context, a *album.Album) error { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now().UTC() + a.CreatedAt = now + a.UpdatedAt = now + copy := *a + r.albums[a.ID] = © + return nil +} + +// Get returns an album by ID and userID. Returns error if not found or wrong user. +func (r *AlbumRepository) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) { + r.mu.RLock() + defer r.mu.RUnlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return nil, fmt.Errorf("album not found: %s", id) + } + copy := *a + shots := make([]album.Shot, len(a.Shots)) + copy.Shots = shots + for i, s := range a.Shots { + shots[i] = s + } + return ©, nil +} + +// List returns all albums for a user, ordered by CreatedAt DESC. +func (r *AlbumRepository) List(ctx context.Context, userID string) ([]album.Album, error) { + r.mu.RLock() + defer r.mu.RUnlock() + var result []album.Album + for _, a := range r.albums { + if a.UserID != userID { + continue + } + copy := *a + shots := make([]album.Shot, len(a.Shots)) + for i, s := range a.Shots { + shots[i] = s + } + copy.Shots = shots + result = append(result, copy) + } + // Sort by CreatedAt DESC (simple insertion sort — in-memory is small). + for i := 1; i < len(result); i++ { + for j := i; j > 0 && result[j].CreatedAt.After(result[j-1].CreatedAt); j-- { + result[j], result[j-1] = result[j-1], result[j] + } + } + return result, nil +} + +// Delete removes an album by ID and userID. +func (r *AlbumRepository) Delete(ctx context.Context, id album.AlbumID, userID string) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + delete(r.albums, id) + return nil +} + +// UpdateAnchor stores the generated anchor URL. +func (r *AlbumRepository) UpdateAnchor(ctx context.Context, id album.AlbumID, userID, anchorURL, anchorJobID string) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + a.AnchorURL = anchorURL + a.AnchorJobID = anchorJobID + a.UpdatedAt = time.Now().UTC() + return nil +} + +// UpdateShot stores the generated image URL and status for a specific shot. +func (r *AlbumRepository) UpdateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int, imageURL string, status album.ShotStatus, shotError string) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + if shotIndex < 0 || shotIndex >= len(a.Shots) { + return fmt.Errorf("shot index out of range: %d", shotIndex) + } + now := time.Now().UTC() + a.Shots[shotIndex].ImageURL = imageURL + a.Shots[shotIndex].Status = status + a.Shots[shotIndex].Error = shotError + if status == album.ShotComplete { + a.Shots[shotIndex].GeneratedAt = &now + } + a.UpdatedAt = now + return nil +} + +// ResetShot clears a shot back to pending. +func (r *AlbumRepository) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + if shotIndex < 0 || shotIndex >= len(a.Shots) { + return fmt.Errorf("shot index out of range: %d", shotIndex) + } + a.Shots[shotIndex].ImageURL = "" + a.Shots[shotIndex].JobID = "" + a.Shots[shotIndex].Status = album.ShotPending + a.Shots[shotIndex].Error = "" + a.Shots[shotIndex].GeneratedAt = nil + a.UpdatedAt = time.Now().UTC() + return nil +} + +// UpdateAnchorJobID stores the anchor job ID when the anchor generation is enqueued. +func (r *AlbumRepository) UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + a.AnchorJobID = jobID + a.UpdatedAt = time.Now().UTC() + return nil +} + +// UpdateShotJobID stores the job ID for a shot when its generation is enqueued. +func (r *AlbumRepository) UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error { + r.mu.Lock() + defer r.mu.Unlock() + a, ok := r.albums[id] + if !ok || a.UserID != userID { + return fmt.Errorf("album not found: %s", id) + } + if shotIndex < 0 || shotIndex >= len(a.Shots) { + return fmt.Errorf("shot index out of range: %d", shotIndex) + } + a.Shots[shotIndex].JobID = jobID + a.Shots[shotIndex].Status = album.ShotGenerating + a.UpdatedAt = time.Now().UTC() + return nil +} diff --git a/services/persona-api/internal/adapter/memory/auth_code.go b/services/persona-api/internal/adapter/memory/auth_code.go new file mode 100644 index 0000000..b088a7f --- /dev/null +++ b/services/persona-api/internal/adapter/memory/auth_code.go @@ -0,0 +1,87 @@ +package memory + +import ( + "context" + "log/slog" + "sync" + "time" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/memory/example.go b/services/persona-api/internal/adapter/memory/example.go new file mode 100644 index 0000000..e24f120 --- /dev/null +++ b/services/persona-api/internal/adapter/memory/example.go @@ -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-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" +) + +// Compile-time verification that ExampleRepository implements port.ExampleRepository. +var _ port.ExampleRepository = (*ExampleRepository)(nil) + +// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository. +type ExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +// NewExampleRepository creates a new in-memory example repository. +func NewExampleRepository() *ExampleRepository { + return &ExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +// List returns all examples. +func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]domain.Example, 0, len(r.examples)) + for _, e := range r.examples { + result = append(result, *e) + } + return result, nil +} + +// Get returns an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + e, ok := r.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to prevent external mutation + copy := *e + return ©, nil +} + +// Create stores a new example. +func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Update modifies an existing example. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy to prevent external mutation + copy := *example + r.examples[example.ID] = © + return nil +} + +// Delete removes an example by ID. +// Returns domain.ErrExampleNotFound if not found. +func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(r.examples, id) + return nil +} + +// ExistsByName checks if an example with the given name exists. +func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, e := range r.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/services/persona-api/internal/adapter/memory/media.go b/services/persona-api/internal/adapter/memory/media.go new file mode 100644 index 0000000..5a0bba9 --- /dev/null +++ b/services/persona-api/internal/adapter/memory/media.go @@ -0,0 +1,135 @@ +package memory + +import ( + "context" + "sort" + "strings" + "sync" + "time" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/memory/session.go b/services/persona-api/internal/adapter/memory/session.go new file mode 100644 index 0000000..0781d43 --- /dev/null +++ b/services/persona-api/internal/adapter/memory/session.go @@ -0,0 +1,120 @@ +package memory + +import ( + "context" + "sync" + "time" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/memory/user.go b/services/persona-api/internal/adapter/memory/user.go new file mode 100644 index 0000000..add4aa7 --- /dev/null +++ b/services/persona-api/internal/adapter/memory/user.go @@ -0,0 +1,243 @@ +package memory + +import ( + "context" + "sync" + "time" + + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/postgres/auth_code.go b/services/persona-api/internal/adapter/postgres/auth_code.go new file mode 100644 index 0000000..ec22f16 --- /dev/null +++ b/services/persona-api/internal/adapter/postgres/auth_code.go @@ -0,0 +1,120 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/postgres/media.go b/services/persona-api/internal/adapter/postgres/media.go new file mode 100644 index 0000000..08d7abe --- /dev/null +++ b/services/persona-api/internal/adapter/postgres/media.go @@ -0,0 +1,184 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/postgres/session.go b/services/persona-api/internal/adapter/postgres/session.go new file mode 100644 index 0000000..1bbe2bd --- /dev/null +++ b/services/persona-api/internal/adapter/postgres/session.go @@ -0,0 +1,162 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/adapter/postgres/user.go b/services/persona-api/internal/adapter/postgres/user.go new file mode 100644 index 0000000..b9a055e --- /dev/null +++ b/services/persona-api/internal/adapter/postgres/user.go @@ -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-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/api/handlers/album.go b/services/persona-api/internal/api/handlers/album.go new file mode 100644 index 0000000..5a7d121 --- /dev/null +++ b/services/persona-api/internal/api/handlers/album.go @@ -0,0 +1,281 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/persona-community-1/pkg/album" + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/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: 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 err.Error() == "anchor must be generated before shots" { + 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 := 0 + if idx := chi.URLParam(r, "index"); idx != "" { + if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil { + return httperror.BadRequest("invalid shot index") + } + } + + jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex) + if err != nil { + if err.Error() == "anchor must be generated before shots" { + 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 := 0 + if idx := chi.URLParam(r, "index"); idx != "" { + if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil { + return httperror.BadRequest("invalid shot index") + } + } + + 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 +} diff --git a/services/persona-api/internal/api/handlers/auth.go b/services/persona-api/internal/api/handlers/auth.go new file mode 100644 index 0000000..8bc6c29 --- /dev/null +++ b/services/persona-api/internal/api/handlers/auth.go @@ -0,0 +1,331 @@ +package handlers + +import ( + "errors" + "net" + "net/http" + "strings" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/api/handlers/auth_flows.go b/services/persona-api/internal/api/handlers/auth_flows.go new file mode 100644 index 0000000..b527f38 --- /dev/null +++ b/services/persona-api/internal/api/handlers/auth_flows.go @@ -0,0 +1,288 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" +) + +// --- Request types for auth flows --- + +// EmailRequest is used by OTP send, magic link, and forgot password. +type EmailRequest struct { + Email string `json:"email" validate:"required,email"` +} + +// OTPVerifyRequest verifies a one-time password. +type OTPVerifyRequest struct { + Email string `json:"email" validate:"required,email"` + Code string `json:"code" validate:"required,len=6"` +} + +// MagicLinkVerifyRequest verifies a magic link token. +type MagicLinkVerifyRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` +} + +// ResetPasswordRequest sets a new password using a reset token. +type ResetPasswordRequest struct { + Email string `json:"email" validate:"required,email"` + Token string `json:"token" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=8"` +} + +// VerifyEmailRequest verifies an email with a code. +type VerifyEmailRequest struct { + Code string `json:"code" validate:"required,len=6"` +} + +// SessionResponse is a single session in the list. +type SessionResponse struct { + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + DeviceLabel string `json:"deviceLabel"` + LastActiveAt string `json:"lastActiveAt"` + CreatedAt string `json:"createdAt"` + IsCurrent bool `json:"isCurrent"` +} + +// --- OTP handlers --- + +// SendOTP sends a one-time password to the user's email. +// +// POST /api/{service}/auth/otp/send +func (h *Auth) SendOTP(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.SendOTP(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a code has been sent"}) + return nil +} + +// VerifyOTP verifies a one-time password and returns a login token. +// +// POST /api/{service}/auth/otp/verify +func (h *Auth) VerifyOTP(w http.ResponseWriter, r *http.Request) error { + var req OTPVerifyRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + output, err := h.svc.VerifyOTP(r.Context(), req.Email, req.Code, clientIP(r), r.UserAgent()) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// --- Magic Link handlers --- + +// SendMagicLink sends a magic link to the user's email. +// +// POST /api/{service}/auth/magic-link +func (h *Auth) SendMagicLink(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.SendMagicLink(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a link has been sent"}) + return nil +} + +// VerifyMagicLink verifies a magic link token and returns a login token. +// +// POST /api/{service}/auth/magic-link/verify +func (h *Auth) VerifyMagicLink(w http.ResponseWriter, r *http.Request) error { + var req MagicLinkVerifyRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + output, err := h.svc.VerifyMagicLink(r.Context(), req.Email, req.Token, clientIP(r), r.UserAgent()) + if err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, toLoginResponse(output)) + return nil +} + +// --- Forgot / Reset Password handlers --- + +// ForgotPassword sends a password reset token. +// +// POST /api/{service}/auth/forgot-password +func (h *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) error { + var req EmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.ForgotPassword(r.Context(), req.Email, clientIP(r)); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "If an account exists, a reset link has been sent"}) + return nil +} + +// ResetPassword sets a new password using a reset token. +// +// POST /api/{service}/auth/reset-password +func (h *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) error { + var req ResetPasswordRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.ResetPassword(r.Context(), req.Email, req.Token, req.NewPassword); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Password has been reset. Please sign in."}) + return nil +} + +// --- Email Verification handlers --- + +// SendVerifyEmail sends a verification code to the current user's email. +// +// POST /api/{service}/auth/verify-email/send +func (h *Auth) SendVerifyEmail(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + if err := h.svc.SendVerifyEmail(r.Context(), user.ID); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Verification code sent"}) + return nil +} + +// VerifyEmail verifies the current user's email with a code. +// +// POST /api/{service}/auth/verify-email +func (h *Auth) VerifyEmail(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + var req VerifyEmailRequest + if err := app.BindAndValidate(r, &req); err != nil { + return err + } + + if err := h.svc.VerifyEmail(r.Context(), user.ID, req.Code); err != nil { + return mapAuthError(err) + } + + httpresponse.OK(w, r, map[string]string{"message": "Email verified"}) + return nil +} + +// --- Session Management handlers --- + +// ListSessions returns all active sessions for the current user. +// +// GET /api/{service}/auth/sessions +func (h *Auth) ListSessions(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + currentSID := sessionID(user) + + sessions, err := h.svc.ListSessions(r.Context(), user.ID) + if err != nil { + return err + } + + result := make([]SessionResponse, 0, len(sessions)) + for _, s := range sessions { + result = append(result, SessionResponse{ + ID: string(s.ID), + IPAddress: s.IPAddress, + DeviceLabel: s.DeviceLabel, + LastActiveAt: s.LastActiveAt.Format("2006-01-02T15:04:05Z07:00"), + CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + IsCurrent: string(s.ID) == currentSID, + }) + } + + httpresponse.OK(w, r, result) + return nil +} + +// RevokeSession revokes a specific session. +// +// DELETE /api/{service}/auth/sessions/{id} +func (h *Auth) RevokeSession(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + sid := chi.URLParam(r, "id") + if sid == "" { + return httperror.BadRequest("session id required") + } + + if err := h.svc.RevokeSession(r.Context(), user.ID, sid); err != nil { + if errors.Is(err, domain.ErrSessionNotFound) { + return httperror.NotFound("session not found") + } + return err + } + + httpresponse.NoContent(w) + return nil +} + +// RevokeAllSessions revokes all sessions except the current one. +// +// DELETE /api/{service}/auth/sessions +func (h *Auth) RevokeAllSessions(w http.ResponseWriter, r *http.Request) error { + user, err := auth.GetUserOrError(r.Context()) + if err != nil { + return httperror.Unauthorized("not authenticated") + } + + currentSID := sessionID(user) + var except *string + if currentSID != "" { + except = ¤tSID + } + + if err := h.svc.LogoutAll(r.Context(), user.ID, except); err != nil { + return err + } + + httpresponse.NoContent(w) + return nil +} diff --git a/services/persona-api/internal/api/handlers/chat.go b/services/persona-api/internal/api/handlers/chat.go new file mode 100644 index 0000000..d2f981d --- /dev/null +++ b/services/persona-api/internal/api/handlers/chat.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/google/uuid" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/api/handlers/example.go b/services/persona-api/internal/api/handlers/example.go new file mode 100644 index 0000000..c01215e --- /dev/null +++ b/services/persona-api/internal/api/handlers/example.go @@ -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-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 + } +} diff --git a/services/persona-api/internal/api/handlers/example_test.go b/services/persona-api/internal/api/handlers/example_test.go new file mode 100644 index 0000000..8e36b14 --- /dev/null +++ b/services/persona-api/internal/api/handlers/example_test.go @@ -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-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/service" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func newTestHandler() (*Example, *mockExampleRepository) { + repo := newMockExampleRepository() + svc := service.NewExampleService(repo, logging.Nop()) + handler := NewExample(svc, logging.Nop()) + return handler, repo +} + +func TestExample_List(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("test-id-1", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + r := chi.NewRouter() + r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { + if err := handler.List(w, r); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + data, ok := resp["data"] + if !ok { + t.Fatal("expected 'data' field in response") + } + + items, ok := data.([]any) + if !ok { + t.Fatal("expected 'data' to be an array") + } + + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} + +func TestExample_Get(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "valid uuid - found", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusOK, + }, + { + name: "valid uuid - not found", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + { + name: "invalid uuid", + id: "not-a-uuid", + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Get(w, r); err != nil { + // Map error to status for testing + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusInternalServerError) + } + return + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Create(t *testing.T) { + handler, repo := newTestHandler() + + // Seed existing data for duplicate test + ex, _ := domain.NewExample("existing-id", "Existing Name", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + body any + wantStatus int + }{ + { + name: "valid request", + body: CreateRequest{ + Name: "New Example", + Description: "A test description", + }, + wantStatus: http.StatusCreated, + }, + { + name: "empty body", + body: nil, + wantStatus: http.StatusBadRequest, + }, + { + name: "duplicate name", + body: CreateRequest{ + Name: "Existing Name", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Create(w, r); err != nil { + switch tt.wantStatus { + case http.StatusBadRequest: + w.WriteHeader(http.StatusBadRequest) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusInternalServerError) + } + return + } + }) + + var body []byte + if tt.body != nil { + var err error + body, err = json.Marshal(tt.body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Delete(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "") + _ = repo.Create(context.Background(), ex) + + tests := []struct { + name string + id string + wantStatus int + }{ + { + name: "existing example", + id: "550e8400-e29b-41d4-a716-446655440000", + wantStatus: http.StatusNoContent, + }, + { + name: "non-existent example", + id: "550e8400-e29b-41d4-a716-446655440001", + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Delete(w, r); err != nil { + if tt.wantStatus == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} + +func TestExample_Update(t *testing.T) { + handler, repo := newTestHandler() + + // Seed data + ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "") + _ = repo.Create(context.Background(), ex1) + ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "") + _ = repo.Create(context.Background(), ex2) + + tests := []struct { + name string + id string + body UpdateRequest + wantStatus int + }{ + { + name: "valid update", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Updated Name", + Description: "Updated", + }, + wantStatus: http.StatusOK, + }, + { + name: "name conflict", + id: "550e8400-e29b-41d4-a716-446655440000", + body: UpdateRequest{ + Name: "Example 2", + Description: "Conflict", + }, + wantStatus: http.StatusConflict, + }, + { + name: "not found", + id: "550e8400-e29b-41d4-a716-446655440099", + body: UpdateRequest{ + Name: "Whatever", + Description: "", + }, + wantStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) { + if err := handler.Update(w, r); err != nil { + switch tt.wantStatus { + case http.StatusNotFound: + w.WriteHeader(http.StatusNotFound) + case http.StatusConflict: + w.WriteHeader(http.StatusConflict) + default: + w.WriteHeader(http.StatusBadRequest) + } + return + } + }) + + body, _ := json.Marshal(tt.body) + req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) + } + }) + } +} diff --git a/services/persona-api/internal/api/handlers/generate.go b/services/persona-api/internal/api/handlers/generate.go new file mode 100644 index 0000000..007be70 --- /dev/null +++ b/services/persona-api/internal/api/handlers/generate.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/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:` 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) +} diff --git a/services/persona-api/internal/api/handlers/health.go b/services/persona-api/internal/api/handlers/health.go new file mode 100644 index 0000000..0fe101e --- /dev/null +++ b/services/persona-api/internal/api/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/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", + }) +} diff --git a/services/persona-api/internal/api/handlers/media.go b/services/persona-api/internal/api/handlers/media.go new file mode 100644 index 0000000..a137d62 --- /dev/null +++ b/services/persona-api/internal/api/handlers/media.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/storage" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/api/handlers/persona.go b/services/persona-api/internal/api/handlers/persona.go new file mode 100644 index 0000000..5732a56 --- /dev/null +++ b/services/persona-api/internal/api/handlers/persona.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "net/http" + + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/httperror" + "git.threesix.ai/jordan/persona-community-1/pkg/httpresponse" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/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:` 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:` 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 +} diff --git a/services/persona-api/internal/api/routes.go b/services/persona-api/internal/api/routes.go new file mode 100644 index 0000000..729bb20 --- /dev/null +++ b/services/persona-api/internal/api/routes.go @@ -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-1/pkg/email" + "git.threesix.ai/jordan/persona-community-1/pkg/app" + "git.threesix.ai/jordan/persona-community-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/middleware" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/pkg/realtime" + "git.threesix.ai/jordan/persona-community-1/pkg/storage" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/api/handlers" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/config" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" + "git.threesix.ai/jordan/persona-community-1/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-1", + }) + + // 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: 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 +} diff --git a/services/persona-api/internal/api/spec.go b/services/persona-api/internal/api/spec.go new file mode 100644 index 0000000..ae31ed9 --- /dev/null +++ b/services/persona-api/internal/api/spec.go @@ -0,0 +1,112 @@ +package api + +import "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/config/config.go b/services/persona-api/internal/config/config.go new file mode 100644 index 0000000..c567134 --- /dev/null +++ b/services/persona-api/internal/config/config.go @@ -0,0 +1,90 @@ +// Package config provides service-specific configuration. +package config + +import ( + "os" + "strings" + + "git.threesix.ai/jordan/persona-community-1/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-1.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 +} diff --git a/services/persona-api/internal/domain/auth_code.go b/services/persona-api/internal/domain/auth_code.go new file mode 100644 index 0000000..12cdf6f --- /dev/null +++ b/services/persona-api/internal/domain/auth_code.go @@ -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) +} diff --git a/services/persona-api/internal/domain/errors.go b/services/persona-api/internal/domain/errors.go new file mode 100644 index 0000000..a512fcc --- /dev/null +++ b/services/persona-api/internal/domain/errors.go @@ -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") +) diff --git a/services/persona-api/internal/domain/example.go b/services/persona-api/internal/domain/example.go new file mode 100644 index 0000000..4ee48e9 --- /dev/null +++ b/services/persona-api/internal/domain/example.go @@ -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 +} diff --git a/services/persona-api/internal/domain/media.go b/services/persona-api/internal/domain/media.go new file mode 100644 index 0000000..22a4ee4 --- /dev/null +++ b/services/persona-api/internal/domain/media.go @@ -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 +} diff --git a/services/persona-api/internal/domain/session.go b/services/persona-api/internal/domain/session.go new file mode 100644 index 0000000..81e89ae --- /dev/null +++ b/services/persona-api/internal/domain/session.go @@ -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) +} diff --git a/services/persona-api/internal/domain/user.go b/services/persona-api/internal/domain/user.go new file mode 100644 index 0000000..2b24d21 --- /dev/null +++ b/services/persona-api/internal/domain/user.go @@ -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, + } +} diff --git a/services/persona-api/internal/email/embed.go b/services/persona-api/internal/email/embed.go new file mode 100644 index 0000000..a873619 --- /dev/null +++ b/services/persona-api/internal/email/embed.go @@ -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 diff --git a/services/persona-api/internal/email/renderer_test.go b/services/persona-api/internal/email/renderer_test.go new file mode 100644 index 0000000..d43819a --- /dev/null +++ b/services/persona-api/internal/email/renderer_test.go @@ -0,0 +1,202 @@ +package email_test + +import ( + "strings" + "testing" + + emailpkg "git.threesix.ai/jordan/persona-community-1/pkg/email" + "git.threesix.ai/jordan/persona-community-1/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) + } + } +} diff --git a/services/persona-api/internal/email/templates/_button.html b/services/persona-api/internal/email/templates/_button.html new file mode 100644 index 0000000..bbfd435 --- /dev/null +++ b/services/persona-api/internal/email/templates/_button.html @@ -0,0 +1,4 @@ +{{define "button"}} +{{end}} diff --git a/services/persona-api/internal/email/templates/_code_box.html b/services/persona-api/internal/email/templates/_code_box.html new file mode 100644 index 0000000..f0ecadc --- /dev/null +++ b/services/persona-api/internal/email/templates/_code_box.html @@ -0,0 +1,4 @@ +{{define "code_box"}}
+ {{.Code}} +
+{{end}} diff --git a/services/persona-api/internal/email/templates/_footer.html b/services/persona-api/internal/email/templates/_footer.html new file mode 100644 index 0000000..0c98740 --- /dev/null +++ b/services/persona-api/internal/email/templates/_footer.html @@ -0,0 +1,7 @@ +{{define "footer"}} +{{end}} diff --git a/services/persona-api/internal/email/templates/_header.html b/services/persona-api/internal/email/templates/_header.html new file mode 100644 index 0000000..4e59b9e --- /dev/null +++ b/services/persona-api/internal/email/templates/_header.html @@ -0,0 +1,8 @@ +{{define "header"}} +{{end}} diff --git a/services/persona-api/internal/email/templates/_layout.html b/services/persona-api/internal/email/templates/_layout.html new file mode 100644 index 0000000..1db0a29 --- /dev/null +++ b/services/persona-api/internal/email/templates/_layout.html @@ -0,0 +1,83 @@ +{{define "layout"}} + + + + + + + + {{.AppName}} + + + + + + +{{end}} diff --git a/services/persona-api/internal/email/templates/email_verify/body.html b/services/persona-api/internal/email/templates/email_verify/body.html new file mode 100644 index 0000000..47e68e0 --- /dev/null +++ b/services/persona-api/internal/email/templates/email_verify/body.html @@ -0,0 +1,8 @@ +{{define "body"}} +

Hi there,

+

Enter this code to verify your email address for {{.AppName}}:

+ +{{template "code_box" .}} + +

This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.

+{{end}} diff --git a/services/persona-api/internal/email/templates/email_verify/meta.yaml b/services/persona-api/internal/email/templates/email_verify/meta.yaml new file mode 100644 index 0000000..8978ac6 --- /dev/null +++ b/services/persona-api/internal/email/templates/email_verify/meta.yaml @@ -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." diff --git a/services/persona-api/internal/email/templates/login_otp/body.html b/services/persona-api/internal/email/templates/login_otp/body.html new file mode 100644 index 0000000..5d4315d --- /dev/null +++ b/services/persona-api/internal/email/templates/login_otp/body.html @@ -0,0 +1,8 @@ +{{define "body"}} +

Hi there,

+

Here is your sign in code for {{.AppName}}:

+ +{{template "code_box" .}} + +

This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.

+{{end}} diff --git a/services/persona-api/internal/email/templates/login_otp/meta.yaml b/services/persona-api/internal/email/templates/login_otp/meta.yaml new file mode 100644 index 0000000..1eac2d6 --- /dev/null +++ b/services/persona-api/internal/email/templates/login_otp/meta.yaml @@ -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." diff --git a/services/persona-api/internal/email/templates/magic_link/body.html b/services/persona-api/internal/email/templates/magic_link/body.html new file mode 100644 index 0000000..9e7044a --- /dev/null +++ b/services/persona-api/internal/email/templates/magic_link/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +

Sign in to {{.AppName}}

+

Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.

+ +{{template "button" .}} + +

Or copy this link into your browser:
+{{.ActionURL}}

+ +
+ +

If you didn't request this sign-in link, you can safely ignore this email — your account is secure.

+{{end}} diff --git a/services/persona-api/internal/email/templates/magic_link/meta.yaml b/services/persona-api/internal/email/templates/magic_link/meta.yaml new file mode 100644 index 0000000..08ebb3d --- /dev/null +++ b/services/persona-api/internal/email/templates/magic_link/meta.yaml @@ -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." diff --git a/services/persona-api/internal/email/templates/password_reset/body.html b/services/persona-api/internal/email/templates/password_reset/body.html new file mode 100644 index 0000000..e2bdcb6 --- /dev/null +++ b/services/persona-api/internal/email/templates/password_reset/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +

Reset your password

+

Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.

+ +{{template "button" .}} + +

Or copy this link into your browser:
+{{.ActionURL}}

+ +
+ +

If you didn't request a password reset, you can safely ignore this email — your password won't change.

+{{end}} diff --git a/services/persona-api/internal/email/templates/password_reset/meta.yaml b/services/persona-api/internal/email/templates/password_reset/meta.yaml new file mode 100644 index 0000000..67e8b7f --- /dev/null +++ b/services/persona-api/internal/email/templates/password_reset/meta.yaml @@ -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." diff --git a/services/persona-api/internal/email/templates/welcome/body.html b/services/persona-api/internal/email/templates/welcome/body.html new file mode 100644 index 0000000..2eac89b --- /dev/null +++ b/services/persona-api/internal/email/templates/welcome/body.html @@ -0,0 +1,10 @@ +{{define "body"}} +

Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!

+

Your account is ready. Start exploring everything {{.AppName}} has to offer.

+ +{{template "button" .}} + +{{- if .SupportEmail}} +

Have questions? Reach us at {{.SupportEmail}}.

+{{- end}} +{{end}} diff --git a/services/persona-api/internal/email/templates/welcome/meta.yaml b/services/persona-api/internal/email/templates/welcome/meta.yaml new file mode 100644 index 0000000..4f9bc92 --- /dev/null +++ b/services/persona-api/internal/email/templates/welcome/meta.yaml @@ -0,0 +1,4 @@ +purpose: welcome +category: transactional +subject: "Welcome to {{.AppName}}" +preheader: "Your {{.AppName}} account is ready. Get started today." diff --git a/services/persona-api/internal/port/album.go b/services/persona-api/internal/port/album.go new file mode 100644 index 0000000..a03d2a4 --- /dev/null +++ b/services/persona-api/internal/port/album.go @@ -0,0 +1,34 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/port/auth_code.go b/services/persona-api/internal/port/auth_code.go new file mode 100644 index 0000000..0dd04ad --- /dev/null +++ b/services/persona-api/internal/port/auth_code.go @@ -0,0 +1,24 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/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) +} diff --git a/services/persona-api/internal/port/email.go b/services/persona-api/internal/port/email.go new file mode 100644 index 0000000..9b54741 --- /dev/null +++ b/services/persona-api/internal/port/email.go @@ -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 +} diff --git a/services/persona-api/internal/port/example.go b/services/persona-api/internal/port/example.go new file mode 100644 index 0000000..5464467 --- /dev/null +++ b/services/persona-api/internal/port/example.go @@ -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-1/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) +} diff --git a/services/persona-api/internal/port/media.go b/services/persona-api/internal/port/media.go new file mode 100644 index 0000000..7d023a3 --- /dev/null +++ b/services/persona-api/internal/port/media.go @@ -0,0 +1,38 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/port/session.go b/services/persona-api/internal/port/session.go new file mode 100644 index 0000000..5348c91 --- /dev/null +++ b/services/persona-api/internal/port/session.go @@ -0,0 +1,33 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/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) +} diff --git a/services/persona-api/internal/port/user.go b/services/persona-api/internal/port/user.go new file mode 100644 index 0000000..a370401 --- /dev/null +++ b/services/persona-api/internal/port/user.go @@ -0,0 +1,51 @@ +package port + +import ( + "context" + + "git.threesix.ai/jordan/persona-community-1/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) +} diff --git a/services/persona-api/internal/service/album.go b/services/persona-api/internal/service/album.go new file mode 100644 index 0000000..476bae9 --- /dev/null +++ b/services/persona-api/internal/service/album.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "fmt" + + "git.threesix.ai/jordan/persona-community-1/pkg/album" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/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: 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, fmt.Errorf("anchor must be generated before shots") + } + + 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 +} + diff --git a/services/persona-api/internal/service/auth.go b/services/persona-api/internal/service/auth.go new file mode 100644 index 0000000..22c5642 --- /dev/null +++ b/services/persona-api/internal/service/auth.go @@ -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-1/pkg/auth" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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-1", + 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) +} diff --git a/services/persona-api/internal/service/example.go b/services/persona-api/internal/service/example.go new file mode 100644 index 0000000..af8f85b --- /dev/null +++ b/services/persona-api/internal/service/example.go @@ -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-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/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 +} diff --git a/services/persona-api/internal/service/example_test.go b/services/persona-api/internal/service/example_test.go new file mode 100644 index 0000000..39d3333 --- /dev/null +++ b/services/persona-api/internal/service/example_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "sync" + "testing" + + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain" + "git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/port" +) + +// mockExampleRepository implements port.ExampleRepository for testing. +type mockExampleRepository struct { + mu sync.RWMutex + examples map[domain.ExampleID]*domain.Example +} + +var _ port.ExampleRepository = (*mockExampleRepository)(nil) + +func newMockExampleRepository() *mockExampleRepository { + return &mockExampleRepository{ + examples: make(map[domain.ExampleID]*domain.Example), + } +} + +func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]domain.Example, 0, len(m.examples)) + for _, e := range m.examples { + result = append(result, *e) + } + return result, nil +} + +func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + e, ok := m.examples[id] + if !ok { + return nil, domain.ErrExampleNotFound + } + // Return a copy to avoid mutation + copy := *e + return ©, nil +} + +func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[example.ID]; !ok { + return domain.ErrExampleNotFound + } + // Store a copy + copy := *example + m.examples[example.ID] = © + return nil +} + +func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.examples[id]; !ok { + return domain.ErrExampleNotFound + } + delete(m.examples, id) + return nil +} + +func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, e := range m.examples { + if e.Name == name { + return true, nil + } + } + return false, nil +} + +func TestExampleService_Create(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("creates example successfully", func(t *testing.T) { + example, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "A test description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Test Example" { + t.Errorf("expected name 'Test Example', got '%s'", example.Name) + } + if example.ID.IsZero() { + t.Error("expected non-empty ID") + } + }) + + t.Run("rejects duplicate name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "Test Example", + Description: "Another description", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("rejects empty name", func(t *testing.T) { + _, err := svc.Create(context.Background(), CreateInput{ + Name: "", + Description: "Description", + }) + if err != domain.ErrInvalidExampleName { + t.Errorf("expected ErrInvalidExampleName, got %v", err) + } + }) +} + +func TestExampleService_Get(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Get Test", + Description: "Description", + }) + + t.Run("returns existing example", func(t *testing.T) { + example, err := svc.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if example.Name != "Get Test" { + t.Errorf("expected name 'Get Test', got '%s'", example.Name) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Get(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Update(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create examples + example1, _ := svc.Create(context.Background(), CreateInput{ + Name: "Update Test 1", + Description: "Original", + }) + _, _ = svc.Create(context.Background(), CreateInput{ + Name: "Update Test 2", + Description: "Other", + }) + + t.Run("updates example successfully", func(t *testing.T) { + updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Updated description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got '%s'", updated.Name) + } + }) + + t.Run("allows same name on same example", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Updated Name", + Description: "Same name", + }) + if err != nil { + t.Errorf("unexpected error updating with same name: %v", err) + } + }) + + t.Run("rejects name conflict", func(t *testing.T) { + _, err := svc.Update(context.Background(), example1.ID, UpdateInput{ + Name: "Update Test 2", + Description: "Conflict", + }) + if err != domain.ErrDuplicateExample { + t.Errorf("expected ErrDuplicateExample, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + _, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{ + Name: "Anything", + Description: "", + }) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_Delete(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + // Create an example first + created, _ := svc.Create(context.Background(), CreateInput{ + Name: "Delete Test", + Description: "To be deleted", + }) + + t.Run("deletes example successfully", func(t *testing.T) { + err := svc.Delete(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify deleted + _, err = svc.Get(context.Background(), created.ID) + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound after delete, got %v", err) + } + }) + + t.Run("returns not found for missing example", func(t *testing.T) { + err := svc.Delete(context.Background(), "nonexistent-id") + if err != domain.ErrExampleNotFound { + t.Errorf("expected ErrExampleNotFound, got %v", err) + } + }) +} + +func TestExampleService_List(t *testing.T) { + repo := newMockExampleRepository() + svc := NewExampleService(repo, logging.Nop()) + + t.Run("returns empty list initially", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 0 { + t.Errorf("expected 0 examples, got %d", len(examples)) + } + }) + + // Create some examples + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""}) + _, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""}) + + t.Run("returns all examples", func(t *testing.T) { + examples, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(examples) != 2 { + t.Errorf("expected 2 examples, got %d", len(examples)) + } + }) +} diff --git a/workers/media-worker/.env.example b/workers/media-worker/.env.example new file mode 100644 index 0000000..15aa13c --- /dev/null +++ b/workers/media-worker/.env.example @@ -0,0 +1,25 @@ +# media-worker Worker Configuration + +# App +APP_NAME=media-worker +APP_ENVIRONMENT=development +APP_DEBUG=true + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text + +# Database (required for job queue) +# 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-1?sslmode=disable + +# Worker +WORKER_POLL_INTERVAL=10s +WORKER_BATCH_SIZE=10 +WORKER_MAX_RETRIES=3 +WORKER_STALE_JOB_TIMEOUT=5m +WORKER_JOB_TIMEOUT=5m + +# Redis (optional, for cache) +# REDIS_URL=redis://localhost:6379/0 diff --git a/workers/media-worker/Dockerfile b/workers/media-worker/Dockerfile new file mode 100644 index 0000000..bcc8c32 --- /dev/null +++ b/workers/media-worker/Dockerfile @@ -0,0 +1,33 @@ +# 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 worker only +COPY pkg/ ./pkg/ +COPY workers/media-worker/ ./workers/media-worker/ + +# Download dependencies (populates go.sum if empty) +RUN cd pkg && go mod download +RUN cd workers/media-worker && go mod download + +# Build from the worker directory (uses replace directive for ../pkg) +RUN cd workers/media-worker && CGO_ENABLED=0 go build -o /media-worker ./cmd/worker + +# Production stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR / + +COPY --from=builder /media-worker /media-worker + +ENTRYPOINT ["/media-worker"] diff --git a/workers/media-worker/Makefile b/workers/media-worker/Makefile new file mode 100644 index 0000000..c7b31c0 --- /dev/null +++ b/workers/media-worker/Makefile @@ -0,0 +1,38 @@ +.PHONY: build run dev test lint fmt docker-build clean + +WORKER := media-worker +BINARY := bin/$(WORKER) +GO_MODULE := git.threesix.ai/jordan/persona-community-1 + +# Build the worker binary +build: + go build -o $(BINARY) ./cmd/worker + +# Run the worker locally +run: + go run ./cmd/worker + +# Run the worker in development mode (alias for run) +dev: + go run ./cmd/worker + +# 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 $(WORKER):latest -f Dockerfile ../.. + +# Clean build artifacts +clean: + rm -rf bin/ diff --git a/workers/media-worker/cmd/worker/main.go b/workers/media-worker/cmd/worker/main.go new file mode 100644 index 0000000..88a5209 --- /dev/null +++ b/workers/media-worker/cmd/worker/main.go @@ -0,0 +1,269 @@ +// Package main is the entry point for the media-worker worker. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + + "github.com/redis/go-redis/v9" + + "git.threesix.ai/jordan/persona-community-1/pkg/database" + "git.threesix.ai/jordan/persona-community-1/pkg/gemini" + "git.threesix.ai/jordan/persona-community-1/pkg/laozhang" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/mediagen" + mediagenAdapters "git.threesix.ai/jordan/persona-community-1/pkg/mediagen/adapters" + "git.threesix.ai/jordan/persona-community-1/pkg/personagen" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/pkg/realtime" + "git.threesix.ai/jordan/persona-community-1/pkg/storage" + "git.threesix.ai/jordan/persona-community-1/pkg/textgen" + textgenAdapters "git.threesix.ai/jordan/persona-community-1/pkg/textgen/adapters" + "git.threesix.ai/jordan/persona-community-1/workers/media-worker/internal/config" + "git.threesix.ai/jordan/persona-community-1/workers/media-worker/internal/handlers" +) + +func main() { + // Initialize logger first (with defaults) so we can log config errors + logger := logging.New(logging.Config{ + Level: logging.LevelInfo, + Format: logging.FormatJSON, + }).WithService("media-worker") + + // Initialize configuration + cfg, err := config.Load() + if err != nil { + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + // Reconfigure logger with loaded config + logger = logging.New(logging.Config{ + Level: logging.ParseLevel(cfg.Logging.Level), + Format: logging.ParseFormat(cfg.Logging.Format), + Environment: cfg.AppConfig.Environment, + AddSource: cfg.AppConfig.IsDevelopment(), + }).WithService("media-worker") + + logger.Info("starting media-worker worker") + + // Setup graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Connect to database + pool, 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) + } + defer pool.Close() + logger.Info("connected to database", "url", pool.URL) + + // Run queue migrations (idempotent — safe for both service and worker) + 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") + + // Initialize queue + jobQueue := queue.NewQueue(pool.DB, logger) + + // Initialize Redis for SSE event publishing + if cfg.RedisURL == "" { + logger.Error("REDIS_URL is required for worker to publish SSE events") + os.Exit(1) + } + redisOpts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + logger.Error("failed to parse REDIS_URL", "error", err) + os.Exit(1) + } + redisClient := redis.NewClient(redisOpts) + 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") + + ssePub := realtime.NewSSEPublisher(redisClient, logger.Logger) + + // Initialize AI providers + // LaoZhang client (primary provider — pay-per-use, OpenAI-compatible) + var laozhangClient *laozhang.Client + if apiKey := os.Getenv("LAOZHANG_API_KEY"); apiKey != "" { + laozhangClient, 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 { + logger.Info("LaoZhang client initialized") + } + } + + // Gemini client for media generation + var geminiClient *gemini.Client + if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" { + geminiClient, err = gemini.NewClient(ctx, gemini.Config{ + APIKey: apiKey, + Logger: logger.Logger, + }) + if err != nil { + logger.Warn("failed to create Gemini client", "error", err) + } else { + logger.Info("Gemini client initialized") + } + } + + // Create mediagen manager (image + video) + var mediagenManager *mediagen.Manager + { + var laozhangMediaProvider *mediagenAdapters.LaoZhangProvider + var geminiMediaProvider *mediagenAdapters.GeminiProvider + if laozhangClient != nil { + laozhangMediaProvider = mediagenAdapters.NewLaoZhangProvider(laozhangClient) + } + if geminiClient != nil { + geminiMediaProvider = mediagenAdapters.NewGeminiProvider(geminiClient) + } + + if geminiMediaProvider != nil || laozhangMediaProvider != 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) + } + mediagenManager, err = mediagen.NewManager(mgCfg) + if err != nil { + logger.Warn("failed to create mediagen manager", "error", err) + } else { + logger.Info("mediagen manager initialized (image + video)") + } + } + } + + // Create textgen manager (text + streaming) + var textgenManager *textgen.Manager + { + var textProviders []textgen.TextGenerator + if laozhangClient != nil { + textProviders = append(textProviders, textgenAdapters.NewLaoZhangTextProvider(laozhangClient, "")) + } + if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" { + geminiTextProvider, 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, geminiTextProvider) + } + } + if len(textProviders) > 0 { + textgenCfg := textgen.ProductionConfig(textgen.ProviderSet{}, textgen.WithLogger(logger.Logger)) + textgenCfg.Providers = textProviders + textgenManager, err = textgen.NewManager(textgenCfg) + if err != nil { + logger.Warn("failed to create textgen manager", "error", err) + } else { + logger.Info("textgen manager initialized") + } + } + } + + // Initialize and start handler + handler := handlers.New(logger, jobQueue, handlers.Config{ + PollInterval: cfg.Worker.PollInterval, + StaleJobTimeout: cfg.Worker.StaleJobTimeout, + JobTimeout: cfg.Worker.JobTimeout, + }) + + // Initialize storage backend for persisting generated media. + // GCS_BUCKET is injected by the platform; if absent, store is nil (media not persisted). + 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.Warn("failed to create GCS store, generated media will not be persisted", "error", err) + } else { + defer func() { _ = gcsStore.Close() }() + mediaStore = gcsStore + logger.Info("storage initialized (GCS)", "bucket", bucket) + } + } + + // Register job handlers + if mediagenManager != nil { + handler.RegisterHandler("generate_image", handlers.ImageHandler(mediagenManager, mediaStore, ssePub, logger)) + handler.RegisterHandler("generate_video", handlers.VideoHandler(mediagenManager, mediaStore, ssePub, logger)) + } + if textgenManager != nil { + handler.RegisterHandler("generate_text", handlers.TextHandler(textgenManager, ssePub, logger)) + handler.RegisterHandler("ai_chat_response", handlers.ChatResponseHandler(textgenManager, ssePub, logger)) + } + // Persona generation requires both textgen (5-stage LLM pipeline) and mediagen (20 images + 4 videos). + if textgenManager != nil && mediagenManager != nil { + handler.RegisterHandler("persona_generate", personagen.QueueHandler(textgenManager, mediagenManager, mediaStore, ssePub, logger.Logger)) + } + + // Setup signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Start worker in goroutine + go handler.Run(ctx) + + // Start stale job recovery in goroutine + go runStaleJobRecovery(ctx, jobQueue, cfg.Worker.StaleJobTimeout, logger) + + // Wait for shutdown signal + sig := <-sigCh + logger.Info("received shutdown signal", "signal", sig.String()) + + // Trigger graceful shutdown with grace period + logger.Info("initiating graceful shutdown") + cancel() + + // Give in-flight jobs time to complete (grace period) + const shutdownGracePeriod = 5 * time.Second + time.Sleep(shutdownGracePeriod) + + logger.Info("media-worker worker stopped") +} + +// runStaleJobRecovery periodically requeues jobs that have been running too long. +func runStaleJobRecovery(ctx context.Context, q *queue.DBQueue, timeout time.Duration, logger *logging.Logger) { + const staleCheckInterval = time.Minute + ticker := time.NewTicker(staleCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + count, err := q.RequeueStale(ctx, timeout) + if err != nil { + logger.Error("failed to requeue stale jobs", "error", err) + } else if count > 0 { + logger.Info("requeued stale jobs", "count", count) + } + } + } +} diff --git a/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql b/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql new file mode 100644 index 0000000..5af8ef9 --- /dev/null +++ b/workers/media-worker/cmd/worker/migrations/001_create_jobs.sql @@ -0,0 +1,32 @@ +-- Jobs queue table for async job processing. +-- Used by pkg/queue for producer/consumer patterns. +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY, + job_type VARCHAR(255) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + status VARCHAR(50) NOT NULL DEFAULT 'pending', + priority INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + retry_count INT NOT NULL DEFAULT 0, + max_retries INT NOT NULL DEFAULT 3, + error TEXT, + worker_id VARCHAR(255) +); + +-- Index for efficient dequeue: pending jobs ordered by priority (desc) and age (asc). +-- Partial index only includes pending jobs for efficiency. +CREATE INDEX IF NOT EXISTS idx_jobs_dequeue ON jobs (priority DESC, created_at ASC) + WHERE status = 'pending'; + +-- Index for finding stale running jobs that need requeue. +-- Used by RequeueStale to recover from crashed workers. +CREATE INDEX IF NOT EXISTS idx_jobs_stale ON jobs (started_at) + WHERE status = 'running'; + +-- Index for listing/filtering jobs by type. +CREATE INDEX IF NOT EXISTS idx_jobs_type ON jobs (job_type, created_at DESC); + +-- Index for listing jobs by status (useful for monitoring dashboards). +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, created_at DESC); diff --git a/workers/media-worker/component.yaml b/workers/media-worker/component.yaml new file mode 100644 index 0000000..0c326be --- /dev/null +++ b/workers/media-worker/component.yaml @@ -0,0 +1,8 @@ +name: media-worker +type: worker +path: workers/media-worker +dependencies: [] +# Add dependencies as needed: +# - postgres +# - redis +# - rabbitmq diff --git a/workers/media-worker/go.mod b/workers/media-worker/go.mod new file mode 100644 index 0000000..0b52407 --- /dev/null +++ b/workers/media-worker/go.mod @@ -0,0 +1,11 @@ +module git.threesix.ai/jordan/persona-community-1/workers/media-worker + +go 1.25 + +require ( + git.threesix.ai/jordan/persona-community-1/pkg v0.0.0 + github.com/google/uuid v1.6.0 +) + +// Use local workspace modules (for Docker builds without go.work) +replace git.threesix.ai/jordan/persona-community-1/pkg => ../../pkg diff --git a/workers/media-worker/go.sum b/workers/media-worker/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/workers/media-worker/internal/config/config.go b/workers/media-worker/internal/config/config.go new file mode 100644 index 0000000..15520f1 --- /dev/null +++ b/workers/media-worker/internal/config/config.go @@ -0,0 +1,71 @@ +// Package config provides worker-specific configuration. +package config + +import ( + "os" + "time" + + "github.com/spf13/viper" + + "git.threesix.ai/jordan/persona-community-1/pkg/config" +) + +// Config holds media-worker worker configuration. +type Config struct { + config.AppConfig + Database config.DatabaseConfig + Logging config.LoggingConfig + Worker WorkerConfig + + // Redis for publishing SSE events to the service + RedisURL string +} + +// WorkerConfig holds worker-specific settings. +type WorkerConfig struct { + // PollInterval is how often to check for new jobs when queue is empty. + PollInterval time.Duration + + // BatchSize is the max number of jobs to process per poll (for batch workers). + BatchSize int + + // MaxRetries is the default maximum retry attempts for failed jobs. + MaxRetries int + + // StaleJobTimeout is how long a job can run before being considered stale. + // Jobs running longer than this without heartbeat will be requeued. + StaleJobTimeout time.Duration + + // JobTimeout is the maximum time a single job handler can run. + JobTimeout time.Duration +} + +// Load reads configuration from environment variables. +func Load() (*Config, error) { + if err := config.Init(config.Options{ + AppName: "media-worker", + SetDefaults: func() { + viper.SetDefault("WORKER_POLL_INTERVAL", "10s") + viper.SetDefault("WORKER_BATCH_SIZE", 10) + viper.SetDefault("WORKER_MAX_RETRIES", 3) + viper.SetDefault("WORKER_STALE_JOB_TIMEOUT", "5m") + viper.SetDefault("WORKER_JOB_TIMEOUT", "5m") + }, + }); err != nil { + return nil, err + } + + return &Config{ + AppConfig: config.ReadAppConfig(), + Database: config.ReadDatabaseConfig(), + Logging: config.ReadLoggingConfig(), + Worker: WorkerConfig{ + PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"), + BatchSize: viper.GetInt("WORKER_BATCH_SIZE"), + MaxRetries: viper.GetInt("WORKER_MAX_RETRIES"), + StaleJobTimeout: viper.GetDuration("WORKER_STALE_JOB_TIMEOUT"), + JobTimeout: viper.GetDuration("WORKER_JOB_TIMEOUT"), + }, + RedisURL: os.Getenv("REDIS_URL"), + }, nil +} diff --git a/workers/media-worker/internal/handlers/generate.go b/workers/media-worker/internal/handlers/generate.go new file mode 100644 index 0000000..3a1f85b --- /dev/null +++ b/workers/media-worker/internal/handlers/generate.go @@ -0,0 +1,33 @@ +// Package handlers re-exports generation job handlers from the shared package. +// The worker registers these handlers to process queue jobs. +package handlers + +import ( + "git.threesix.ai/jordan/persona-community-1/pkg/generation" + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/mediagen" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" + "git.threesix.ai/jordan/persona-community-1/pkg/realtime" + "git.threesix.ai/jordan/persona-community-1/pkg/storage" + "git.threesix.ai/jordan/persona-community-1/pkg/textgen" +) + +// ImageHandler returns a queue.Handler that processes image generation jobs. +func ImageHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler { + return generation.ImageHandler(mg, store, pub, logger) +} + +// VideoHandler returns a queue.Handler that processes video generation jobs. +func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler { + return generation.VideoHandler(mg, store, pub, logger) +} + +// TextHandler returns a queue.Handler that processes text generation jobs with streaming. +func TextHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler { + return generation.TextHandler(tg, pub, logger) +} + +// ChatResponseHandler returns a queue.Handler that generates AI chat responses. +func ChatResponseHandler(tg *textgen.Manager, pub realtime.EventPublisher, logger *logging.Logger) queue.Handler { + return generation.ChatResponseHandler(tg, pub, logger) +} diff --git a/workers/media-worker/internal/handlers/handler.go b/workers/media-worker/internal/handlers/handler.go new file mode 100644 index 0000000..d153cd7 --- /dev/null +++ b/workers/media-worker/internal/handlers/handler.go @@ -0,0 +1,147 @@ +// Package handlers provides the worker's job processing logic. +package handlers + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + + "git.threesix.ai/jordan/persona-community-1/pkg/logging" + "git.threesix.ai/jordan/persona-community-1/pkg/queue" +) + +// Config holds handler configuration. +type Config struct { + // PollInterval is how often to check for new jobs when queue is empty. + PollInterval time.Duration + + // StaleJobTimeout is how long a job can run before being considered stale. + StaleJobTimeout time.Duration + + // JobTimeout is the maximum time a job handler can run. + JobTimeout time.Duration +} + +// Handler processes background jobs from the queue. +type Handler struct { + logger *logging.Logger + queue queue.Consumer + handlers map[string]queue.Handler + config Config + workerID string + mu sync.RWMutex +} + +// New creates a new Handler. +func New(logger *logging.Logger, q queue.Consumer, cfg Config) *Handler { + // Apply defaults + if cfg.PollInterval == 0 { + cfg.PollInterval = 10 * time.Second + } + if cfg.StaleJobTimeout == 0 { + cfg.StaleJobTimeout = 5 * time.Minute + } + if cfg.JobTimeout == 0 { + cfg.JobTimeout = 5 * time.Minute + } + + return &Handler{ + logger: logger.WithComponent("handler"), + queue: q, + handlers: make(map[string]queue.Handler), + config: cfg, + workerID: uuid.New().String(), + } +} + +// RegisterHandler registers a handler for a specific job type. +// Call this before Run() to set up job processing. +func (h *Handler) RegisterHandler(jobType string, handler queue.Handler) { + h.mu.Lock() + defer h.mu.Unlock() + h.handlers[jobType] = handler + h.logger.Info("registered job handler", "type", jobType) +} + +// Run starts the worker loop and processes jobs until context is cancelled. +func (h *Handler) Run(ctx context.Context) { + h.logger.Info("worker loop started", "worker_id", h.workerID) + + for { + select { + case <-ctx.Done(): + h.logger.Info("worker loop stopping", "worker_id", h.workerID) + return + default: + if err := h.processNextJob(ctx); err != nil { + if errors.Is(err, queue.ErrNoJob) { + // Queue is empty, wait before polling again + select { + case <-ctx.Done(): + return + case <-time.After(h.config.PollInterval): + continue + } + } + // Log error and continue + h.logger.Error("error processing job", "error", err) + time.Sleep(time.Second) // Brief pause on error + } + } + } +} + +// processNextJob dequeues and processes a single job. +func (h *Handler) processNextJob(ctx context.Context) error { + job, err := h.queue.Dequeue(ctx, h.workerID) + if err != nil { + return err + } + + // Get handler for job type + h.mu.RLock() + handler, ok := h.handlers[job.Type] + h.mu.RUnlock() + + if !ok { + h.logger.Error("no handler for job type", "job_id", job.ID, "type", job.Type) + return h.queue.Fail(ctx, job.ID, fmt.Sprintf("unknown job type: %s", job.Type)) + } + + // Apply middleware and process (TimeoutMiddleware handles the deadline) + wrappedHandler := queue.Chain( + queue.RecoveryMiddleware(h.logger), + queue.LoggingMiddleware(h.logger), + queue.TimeoutMiddleware(h.config.JobTimeout), + )(handler) + + // Use parent context - TimeoutMiddleware applies the job timeout + jobCtx := ctx + _ = jobCtx // jobCtx used below + + if err := wrappedHandler(jobCtx, job); err != nil { + // Truncate error message to prevent log bloat and potential data leakage + errMsg := truncateErrorMessage(err.Error(), 1000) + h.logger.Debug("job handler failed", "job_id", job.ID, "error", errMsg) + return h.queue.Fail(ctx, job.ID, errMsg) + } + + return h.queue.Ack(ctx, job.ID) +} + +// WorkerID returns this handler's unique worker identifier. +func (h *Handler) WorkerID() string { + return h.workerID +} + +// truncateErrorMessage limits error message length to prevent log bloat. +func truncateErrorMessage(msg string, maxLen int) string { + if len(msg) <= maxLen { + return msg + } + return msg[:maxLen-3] + "..." +}