Add components: service/persona-api, worker/media-worker, app-react/creator-ui
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
4004f88f4a
commit
fc4c0b22a7
229
.woodpecker.yml
229
.woodpecker.yml
@ -57,12 +57,239 @@ steps:
|
|||||||
event: push
|
event: push
|
||||||
|
|
||||||
# COMPONENT_STEPS_BELOW
|
# 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
|
# Do not remove the marker above - component steps are inserted here
|
||||||
|
|
||||||
# Sync point after all component builds/deploys complete
|
# Sync point after all component builds/deploys complete
|
||||||
# depends_on is updated dynamically when components are added
|
# depends_on is updated dynamically when components are added
|
||||||
build-complete:
|
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
|
image: alpine:3.19
|
||||||
commands:
|
commands:
|
||||||
- echo "All component builds complete"
|
- echo "All component builds complete"
|
||||||
|
|||||||
@ -94,4 +94,9 @@ persona-community-1/
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
<!-- Components will be listed here as they're added -->
|
| Component | Type | Path |
|
||||||
|
|-----------|------|------|
|
||||||
|
| **persona-api** | API service | `services/persona-api/` |
|
||||||
|
| **media-worker** | Background worker | `workers/media-worker/` |
|
||||||
|
| **creator-ui** | React app | `apps/creator-ui/` |
|
||||||
|
|
||||||
|
|||||||
3
Procfile
3
Procfile
@ -1,2 +1,5 @@
|
|||||||
# Local development processes
|
# Local development processes
|
||||||
# Components will be added below as they're created
|
# Components will be added below as they're created
|
||||||
|
persona-api: cd services/persona-api && make run
|
||||||
|
media-worker: cd workers/media-worker && make run
|
||||||
|
creator-ui: cd apps/creator-ui && npm run dev
|
||||||
|
|||||||
18
apps/creator-ui/.eslintrc.cjs
Normal file
18
apps/creator-ui/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
34
apps/creator-ui/Dockerfile
Normal file
34
apps/creator-ui/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Build stage - using pnpm for workspace dependency resolution
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Copy workspace configuration files
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Copy shared packages (required for workspace:* dependencies)
|
||||||
|
COPY packages/ ./packages/
|
||||||
|
|
||||||
|
# Copy the app component
|
||||||
|
COPY apps/creator-ui/ ./apps/creator-ui/
|
||||||
|
|
||||||
|
# Install dependencies using pnpm (resolves workspace:* correctly)
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
WORKDIR /workspace/apps/creator-ui
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built assets
|
||||||
|
COPY --from=build /workspace/apps/creator-ui/dist /usr/share/nginx/html
|
||||||
|
COPY apps/creator-ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
6
apps/creator-ui/component.yaml
Normal file
6
apps/creator-ui/component.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
name: creator-ui
|
||||||
|
type: app
|
||||||
|
port: 3001
|
||||||
|
path: apps/creator-ui
|
||||||
|
stack: react
|
||||||
|
dependencies: []
|
||||||
13
apps/creator-ui/index.html
Normal file
13
apps/creator-ui/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>creator-ui | persona-community-1</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
apps/creator-ui/nginx.conf
Normal file
26
apps/creator-ui/nginx.conf
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 3001;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
41
apps/creator-ui/package.json
Normal file
41
apps/creator-ui/package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "creator-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 3001",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview --port 3001",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@persona-community-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/creator-ui/postcss.config.cjs
Normal file
6
apps/creator-ui/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
apps/creator-ui/public/vite.svg
Normal file
1
apps/creator-ui/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
449
apps/creator-ui/src/App.tsx
Normal file
449
apps/creator-ui/src/App.tsx
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route, useLocation, useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth, ProtectedRoute } from '@persona-community-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<string, string> = {
|
||||||
|
'/': 'Dashboard',
|
||||||
|
'/chat': 'Chat',
|
||||||
|
'/generate': 'Generate',
|
||||||
|
'/media': 'Media',
|
||||||
|
'/analytics': 'Analytics',
|
||||||
|
'/users': 'Users',
|
||||||
|
'/settings': 'Settings',
|
||||||
|
'/settings/sessions': 'Sessions',
|
||||||
|
'/settings/verify-email': 'Verify Email',
|
||||||
|
};
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Welcome to creator-ui</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This is part of the{' '}
|
||||||
|
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded text-sm">
|
||||||
|
persona-community-1
|
||||||
|
</code>{' '}
|
||||||
|
monorepo, using the shared UI library and layout components.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button>Get Started</Button>
|
||||||
|
<Button variant="outline">Documentation</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Total Users</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">1,234</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="success">+12% from last month</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Active Sessions</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">567</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="info">Live</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>API Requests</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">89.2k</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="warning">High traffic</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
Edit this file at{' '}
|
||||||
|
<code className="bg-[var(--surface-200)] px-1.5 py-0.5 rounded">
|
||||||
|
apps/creator-ui/src/App.tsx
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">All Users</h2>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Manage your team members and their roles.</p>
|
||||||
|
</div>
|
||||||
|
<Button>Add User</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ name: 'Alice Chen', role: 'Admin', status: 'Active' },
|
||||||
|
{ name: 'Bob Martinez', role: 'Editor', status: 'Active' },
|
||||||
|
{ name: 'Carol Singh', role: 'Viewer', status: 'Invited' },
|
||||||
|
].map((user) => (
|
||||||
|
<Card key={user.name}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{user.name}</CardTitle>
|
||||||
|
<CardDescription>{user.role}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant={user.status === 'Active' ? 'success' : 'info'}>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Page Views</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">24.5k</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="success">+8% this week</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Bounce Rate</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">32%</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="success">-3% improvement</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Avg. Session</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">4m 12s</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge variant="info">Stable</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Traffic Sources</CardTitle>
|
||||||
|
<CardDescription>Where your visitors are coming from this month.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ source: 'Direct', visits: '8,421', pct: 34 },
|
||||||
|
{ source: 'Search', visits: '6,312', pct: 26 },
|
||||||
|
{ source: 'Social', visits: '5,105', pct: 21 },
|
||||||
|
{ source: 'Referral', visits: '4,662', pct: 19 },
|
||||||
|
].map((row) => (
|
||||||
|
<div key={row.source} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">{row.source}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-32 h-2 rounded-full bg-[var(--surface-200)] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[var(--accent)]"
|
||||||
|
style={{ width: `${row.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--text-muted)] w-16 text-right">{row.visits}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>General</CardTitle>
|
||||||
|
<CardDescription>Manage your application settings and preferences.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Application Name', value: 'persona-community-1' },
|
||||||
|
{ label: 'Environment', value: 'Production' },
|
||||||
|
{ label: 'Region', value: 'US West' },
|
||||||
|
].map((setting) => (
|
||||||
|
<div key={setting.label} className="flex items-center justify-between py-2 border-b border-[var(--border-muted)] last:border-0">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">{setting.label}</span>
|
||||||
|
<Badge variant="outline">{setting.value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account</CardTitle>
|
||||||
|
<CardDescription>Manage your account settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">Sign Out</p>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Sign out of your account on this device.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={handleLogout}>Sign Out</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Danger Zone</CardTitle>
|
||||||
|
<CardDescription>Irreversible actions for your application.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-primary)]">Delete Application</p>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Permanently remove this application and all its data.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MagicLinkCallbackPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { loginWithMagicLink } = useAuth();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const email = searchParams.get('email');
|
||||||
|
|
||||||
|
if (!token || !email) {
|
||||||
|
setError('Invalid magic link. Missing token or email.');
|
||||||
|
setVerifying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithMagicLink({ email, token })
|
||||||
|
.then(() => {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Magic link verification failed.');
|
||||||
|
setVerifying(false);
|
||||||
|
});
|
||||||
|
}, [searchParams, loginWithMagicLink, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>{verifying ? 'Verifying Magic Link' : 'Verification Failed'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{verifying
|
||||||
|
? 'Please wait while we verify your magic link...'
|
||||||
|
: 'We could not verify your magic link.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4">
|
||||||
|
{verifying ? (
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-[var(--text-error, #ef4444)]">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="outline">Back to Login</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const itemsWithActive = navItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
active: location.pathname === item.href,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pageTitle = pageTitles[location.pathname] || 'Dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
logo={
|
||||||
|
<span className="font-semibold text-lg">persona-community-1</span>
|
||||||
|
}
|
||||||
|
items={itemsWithActive}
|
||||||
|
onNavigate={(href) => navigate(href)}
|
||||||
|
footer={
|
||||||
|
<div className="text-sm text-[var(--text-muted)]">
|
||||||
|
{user?.email || 'v0.0.1'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
<Header
|
||||||
|
title={pageTitle}
|
||||||
|
showSearch
|
||||||
|
searchPlaceholder="Search..."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
<Route path="/generate" element={<GeneratePage />} />
|
||||||
|
<Route path="/media" element={<MediaPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/settings/sessions" element={<SessionsPage />} />
|
||||||
|
<Route path="/settings/verify-email" element={<VerifyEmailPage />} />
|
||||||
|
</Routes>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/auth/magic-link/callback" element={<MagicLinkCallbackPage />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute
|
||||||
|
redirectTo="/login"
|
||||||
|
onRedirect={(path) => {
|
||||||
|
// Navigate to login, storing current location for redirect after login
|
||||||
|
navigate(path, { state: { from: location.pathname }, replace: true });
|
||||||
|
}}
|
||||||
|
fallback={<LoadingScreen />}
|
||||||
|
>
|
||||||
|
<AppLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// Determine API base URL from environment or current origin
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthProvider authBaseUrl={`${apiBaseUrl}/api/{{SERVICE_NAME}}`}>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
6
apps/creator-ui/src/index.css
Normal file
6
apps/creator-ui/src/index.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/* Import design system tokens */
|
||||||
|
@import '@persona-community-1/ui/styles';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
11
apps/creator-ui/src/lib/logger.ts
Normal file
11
apps/creator-ui/src/lib/logger.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createLogger, installGlobalHandlers } from '@persona-community-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);
|
||||||
19
apps/creator-ui/src/main.tsx
Normal file
19
apps/creator-ui/src/main.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
import './lib/logger';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
190
apps/creator-ui/src/pages/ChatPage.tsx
Normal file
190
apps/creator-ui/src/pages/ChatPage.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useAuth } from '@persona-community-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<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// API base URL from environment
|
||||||
|
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const authHeaders = useMemo(() => {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
aiMessages,
|
||||||
|
streamingMessages,
|
||||||
|
sendMessage,
|
||||||
|
connectionState,
|
||||||
|
onlineUsers,
|
||||||
|
} = useChat({
|
||||||
|
endpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/chat/messages`,
|
||||||
|
sseEndpoint: `${apiBaseUrl}/api/{{SERVICE_NAME}}/events`,
|
||||||
|
channel: 'channel:general',
|
||||||
|
userId: user?.id || 'anonymous',
|
||||||
|
userName: user?.name || user?.email || 'Anonymous',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track send errors for user feedback
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Merge user messages + AI messages into a single sorted timeline
|
||||||
|
const timeline = useMemo<TimelineMessage[]>(() => {
|
||||||
|
const combined: TimelineMessage[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
combined.push({
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
role: msg.userId === user?.id ? 'user' : 'assistant',
|
||||||
|
timestamp: new Date(msg.timestamp),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of aiMessages) {
|
||||||
|
combined.push({
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date(msg.timestamp),
|
||||||
|
provider: msg.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add in-progress streaming messages
|
||||||
|
for (const [, stream] of streamingMessages) {
|
||||||
|
combined.push({
|
||||||
|
id: stream.streamId,
|
||||||
|
content: stream.content,
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date(stream.timestamp),
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
combined.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||||
|
return combined;
|
||||||
|
}, [messages, aiMessages, streamingMessages, user?.id]);
|
||||||
|
|
||||||
|
// Handle sending a message (wraps async sendMessage for ChatInput)
|
||||||
|
const handleSendMessage = useCallback((content: string) => {
|
||||||
|
sendMessage(content).catch(() => {
|
||||||
|
setSendError('Failed to send message. Please try again.');
|
||||||
|
setTimeout(() => setSendError(null), 3000);
|
||||||
|
});
|
||||||
|
}, [sendMessage]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new messages arrive
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [timeline]);
|
||||||
|
|
||||||
|
const connectionBadge = () => {
|
||||||
|
switch (connectionState) {
|
||||||
|
case 'connected':
|
||||||
|
return <Badge variant="success">Connected</Badge>;
|
||||||
|
case 'connecting':
|
||||||
|
return <Badge variant="warning">Connecting...</Badge>;
|
||||||
|
case 'disconnected':
|
||||||
|
return <Badge variant="error">Disconnected</Badge>;
|
||||||
|
case 'error':
|
||||||
|
return <Badge variant="error">Error</Badge>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-8rem)] flex flex-col">
|
||||||
|
<Card className="flex-1 flex flex-col">
|
||||||
|
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>AI Chat</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Chat with AI in real-time
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-[var(--text-muted)]">
|
||||||
|
{onlineUsers.length} online
|
||||||
|
</span>
|
||||||
|
{connectionBadge()}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Messages area */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 pr-2 mb-4">
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<p className="text-[var(--text-muted)] text-sm">
|
||||||
|
No messages yet. Start the conversation!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
timeline.map((msg) => (
|
||||||
|
<div key={msg.id}>
|
||||||
|
<ChatBubble
|
||||||
|
role={msg.role}
|
||||||
|
content={msg.content}
|
||||||
|
timestamp={msg.timestamp}
|
||||||
|
isStreaming={msg.isStreaming}
|
||||||
|
/>
|
||||||
|
{msg.provider && (
|
||||||
|
<div className={msg.role === 'user' ? 'text-right' : 'text-left'}>
|
||||||
|
<ProviderBadge provider={msg.provider} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="flex-shrink-0 space-y-2">
|
||||||
|
{sendError && (
|
||||||
|
<div className="px-3 py-2 text-sm text-[var(--error)] bg-[var(--error)]/10 rounded-lg">
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChatInput
|
||||||
|
onSubmit={handleSendMessage}
|
||||||
|
disabled={connectionState !== 'connected'}
|
||||||
|
placeholder={
|
||||||
|
connectionState === 'connected'
|
||||||
|
? 'Type a message... (Cmd+Enter to send)'
|
||||||
|
: 'Connecting...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/creator-ui/src/pages/ForgotPasswordPage.tsx
Normal file
110
apps/creator-ui/src/pages/ForgotPasswordPage.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
FormField,
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
Loader2,
|
||||||
|
} from '@persona-community-1/ui';
|
||||||
|
|
||||||
|
export function ForgotPasswordPage() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error?.message || body.message || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Reset your password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sent
|
||||||
|
? 'Check your email for a reset link'
|
||||||
|
: "Enter your email and we'll send you a reset link"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{!sent ? (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Send reset link
|
||||||
|
</Button>
|
||||||
|
<Link to="/login" className="text-sm text-center text-[var(--accent)] hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<CardContent className="space-y-4 text-center">
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
If an account exists with that email, you will receive a password reset link.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
In dev mode, check the server console for the reset token.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" className="text-sm text-[var(--accent)] hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/creator-ui/src/pages/GeneratePage.tsx
Normal file
248
apps/creator-ui/src/pages/GeneratePage.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@persona-community-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<GenerateMode>('image');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [aspectRatio, setAspectRatio] = useState('1:1');
|
||||||
|
const [count, setCount] = useState(1);
|
||||||
|
const [duration, setDuration] = useState('5s');
|
||||||
|
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const authHeaders = useMemo(() => {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
const imageGen = useMediaGeneration<ImageResult>({
|
||||||
|
endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/image`,
|
||||||
|
sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
|
||||||
|
userId: user?.id || 'anonymous',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoGen = useMediaGeneration<VideoResult>({
|
||||||
|
endpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/generate/video`,
|
||||||
|
sseEndpoint: `${apiPrefix}/api/{{SERVICE_NAME}}/events`,
|
||||||
|
userId: user?.id || 'anonymous',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gen = mode === 'image' ? imageGen : videoGen;
|
||||||
|
const isGenerating = gen.status === 'pending' || gen.status === 'generating';
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
gen.reset();
|
||||||
|
const request = mode === 'image'
|
||||||
|
? { prompt, count, aspectRatio }
|
||||||
|
: { prompt, aspectRatio, duration };
|
||||||
|
await gen.generate(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI Generation</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate images and videos using AI (Gemini / LaoZhang)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={mode === 'image' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setMode('image')}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
Images
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={mode === 'video' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('video');
|
||||||
|
if (aspectRatio === '1:1') setAspectRatio('16:9');
|
||||||
|
}}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
Video
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
mode === 'image'
|
||||||
|
? 'A serene mountain landscape at sunset...'
|
||||||
|
: 'A cat playing piano in a jazz club...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Aspect Ratio</label>
|
||||||
|
<Select value={aspectRatio} onValueChange={(v) => setAspectRatio(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{mode === 'image' && <SelectItem value="1:1">Square (1:1)</SelectItem>}
|
||||||
|
<SelectItem value="16:9">Landscape (16:9)</SelectItem>
|
||||||
|
<SelectItem value="9:16">Portrait (9:16)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'image' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Count</label>
|
||||||
|
<Select value={String(count)} onValueChange={(v) => setCount(Number(v))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 image</SelectItem>
|
||||||
|
<SelectItem value="2">2 images</SelectItem>
|
||||||
|
<SelectItem value="4">4 images</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-[var(--text-primary)]">Duration</label>
|
||||||
|
<Select value={duration} onValueChange={(v) => setDuration(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5s">5 seconds</SelectItem>
|
||||||
|
<SelectItem value="10s">10 seconds</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleGenerate} disabled={isGenerating || !prompt.trim()}>
|
||||||
|
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isGenerating ? 'Generating...' : `Generate ${mode === 'image' ? 'Images' : 'Video'}`}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isGenerating && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-[var(--text-muted)]">{gen.message || 'Starting...'}</span>
|
||||||
|
<span className="text-[var(--text-muted)]">{gen.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<GenerationProgress percent={gen.progress} />
|
||||||
|
{gen.sseState !== 'connected' && (
|
||||||
|
<p className="text-xs text-[var(--warning)]">
|
||||||
|
SSE {gen.sseState} — events may be delayed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gen.status === 'failed' && gen.error && (
|
||||||
|
<Card className="border-[var(--error)]">
|
||||||
|
<CardContent className="py-4 text-[var(--error)]">
|
||||||
|
{gen.error}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gen.status === 'complete' && imageGen.result && mode === 'image' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Results</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{imageGen.result.provider && <ProviderBadge provider={imageGen.result.provider} />}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
|
||||||
|
View in Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ImageGrid
|
||||||
|
images={imageGen.result.images.map((img) => ({
|
||||||
|
src: img.isUrl ? img.data : `data:image/png;base64,${img.data}`,
|
||||||
|
alt: prompt,
|
||||||
|
}))}
|
||||||
|
columns={imageGen.result.images.length > 1 ? 2 : 1}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gen.status === 'complete' && videoGen.result && mode === 'video' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Results</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{videoGen.result.provider && <ProviderBadge provider={videoGen.result.provider} />}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate('/media')}>
|
||||||
|
View in Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<VideoGrid
|
||||||
|
videos={videoGen.result.videos.map((vid) => ({
|
||||||
|
src: vid.isUrl ? vid.data : `data:${vid.mimeType};base64,${vid.data}`,
|
||||||
|
mimeType: vid.mimeType,
|
||||||
|
alt: prompt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
apps/creator-ui/src/pages/LoginPage.tsx
Normal file
270
apps/creator-ui/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@persona-community-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<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<LoginTab>('password');
|
||||||
|
const [otpSent, setOtpSent] = useState(false);
|
||||||
|
const [otpEmail, setOtpEmail] = useState('');
|
||||||
|
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||||
|
|
||||||
|
const from = (location.state as { from?: string })?.from || '/';
|
||||||
|
|
||||||
|
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ email, password });
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiClientError(error)) {
|
||||||
|
if (error.isValidationError()) {
|
||||||
|
setErrors(error.getFieldErrors());
|
||||||
|
} else {
|
||||||
|
setGeneralError(error.message);
|
||||||
|
}
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
setGeneralError(error.message);
|
||||||
|
} else {
|
||||||
|
setGeneralError('An unexpected error occurred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendOTP = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOTP(email);
|
||||||
|
setOtpEmail(email);
|
||||||
|
setOtpSent(true);
|
||||||
|
} catch (error) {
|
||||||
|
setGeneralError(error instanceof Error ? error.message : 'Failed to send code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOTP = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const code = formData.get('code') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginWithOTP({ email: otpEmail, code });
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
setGeneralError(error instanceof Error ? error.message : 'Invalid code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMagicLink = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMagicLink(email);
|
||||||
|
setMagicLinkSent(true);
|
||||||
|
} catch (error) {
|
||||||
|
setGeneralError(error instanceof Error ? error.message : 'Failed to send link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (tab: LoginTab) =>
|
||||||
|
`flex-1 py-2 text-sm font-medium text-center rounded-md transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'bg-[var(--surface-100)] text-[var(--text-primary)] shadow-sm'
|
||||||
|
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||||
|
<CardDescription>Sign in to your persona-community-1 account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Tab switcher */}
|
||||||
|
<div className="flex gap-1 p-1 rounded-lg bg-[var(--surface-200)]">
|
||||||
|
<button type="button" className={tabClass('password')} onClick={() => { setActiveTab('password'); clearErrors(); setGeneralError(null); }}>
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
<button type="button" className={tabClass('otp')} onClick={() => { setActiveTab('otp'); clearErrors(); setGeneralError(null); setOtpSent(false); }}>
|
||||||
|
OTP
|
||||||
|
</button>
|
||||||
|
<button type="button" className={tabClass('magic-link')} onClick={() => { setActiveTab('magic-link'); clearErrors(); setGeneralError(null); setMagicLinkSent(false); }}>
|
||||||
|
Magic Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generalError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{generalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password tab */}
|
||||||
|
{activeTab === 'password' && (
|
||||||
|
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
error={getError('email')}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
error={getError('password')}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<div className="text-right">
|
||||||
|
<Link to="/forgot-password" className="text-sm text-[var(--accent)] hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OTP tab */}
|
||||||
|
{activeTab === 'otp' && !otpSent && (
|
||||||
|
<form onSubmit={handleSendOTP} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Send code
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'otp' && otpSent && (
|
||||||
|
<form onSubmit={handleVerifyOTP} className="space-y-4">
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
A 6-digit code was sent to <strong>{otpEmail}</strong>
|
||||||
|
</p>
|
||||||
|
<FormField
|
||||||
|
label="Code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||||
|
onClick={() => setOtpSent(false)}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Magic Link tab */}
|
||||||
|
{activeTab === 'magic-link' && !magicLinkSent && (
|
||||||
|
<form onSubmit={handleSendMagicLink} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Send magic link
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'magic-link' && magicLinkSent && (
|
||||||
|
<div className="text-center space-y-3 py-4">
|
||||||
|
<p className="text-sm text-[var(--text-primary)]">Check your email</p>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
We sent a sign-in link to your email. Click it to continue.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
In dev mode, check the server console for the link token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-center text-[var(--text-muted)]">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-[var(--accent)] hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/creator-ui/src/pages/MediaPage.tsx
Normal file
129
apps/creator-ui/src/pages/MediaPage.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useAuth } from '@persona-community-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<MediaItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const authHeaders = useMemo(() => {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
const mediaUpload = useMediaUpload({
|
||||||
|
apiPrefix,
|
||||||
|
serviceName: '{{SERVICE_NAME}}',
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMedia = useCallback(async () => {
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media`, {
|
||||||
|
headers: { ...authHeaders },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setFetchError(`Failed to load media (${res.status})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setItems(data.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
setFetchError(err instanceof Error ? err.message : 'Failed to load media');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiPrefix, authHeaders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMedia();
|
||||||
|
}, [fetchMedia]);
|
||||||
|
|
||||||
|
const handleUploadComplete = useCallback(() => {
|
||||||
|
fetchMedia();
|
||||||
|
}, [fetchMedia]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
|
setDeleteError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/media/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { ...authHeaders },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(err instanceof Error ? err.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
}, [apiPrefix, authHeaders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Upload Media</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Upload images and videos to your media library
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MediaUploader
|
||||||
|
upload={mediaUpload.upload}
|
||||||
|
isUploading={mediaUpload.isUploading}
|
||||||
|
progress={mediaUpload.progress}
|
||||||
|
onUploadComplete={handleUploadComplete}
|
||||||
|
onError={(err) => console.error('Upload error:', err)}
|
||||||
|
/>
|
||||||
|
{mediaUpload.error && (
|
||||||
|
<p className="mt-2 text-sm text-[var(--error)]">{mediaUpload.error}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Media Library</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your uploaded and generated media files
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Badge variant="outline">{items.length} files</Badge>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(deleteError || fetchError) && (
|
||||||
|
<p className="mb-4 text-sm text-[var(--error)]">{deleteError || fetchError}</p>
|
||||||
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-[var(--text-muted)]">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<MediaLibrary
|
||||||
|
items={items}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/creator-ui/src/pages/RegisterPage.tsx
Normal file
128
apps/creator-ui/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '@persona-community-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<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({ email, password, name });
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setGeneralError(error.message);
|
||||||
|
} else {
|
||||||
|
setGeneralError('An unexpected error occurred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||||
|
<CardDescription>Get started with persona-community-1</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{generalError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{generalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
error={getError('name')}
|
||||||
|
autoComplete="name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
error={getError('email')}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
error={getError('password')}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
description="Must contain uppercase, lowercase, and a number"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Confirm Password"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
error={getError('confirmPassword')}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-sm text-center text-[var(--text-muted)]">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-[var(--accent)] hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
apps/creator-ui/src/pages/ResetPasswordPage.tsx
Normal file
135
apps/creator-ui/src/pages/ResetPasswordPage.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
FormField,
|
||||||
|
useFormErrors,
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
Loader2,
|
||||||
|
} from '@persona-community-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<string | null>(null);
|
||||||
|
|
||||||
|
const token = searchParams.get('token') || '';
|
||||||
|
const email = searchParams.get('email') || '';
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
setGeneralError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const newPassword = formData.get('newPassword') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setErrors({ confirmPassword: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, token, newPassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error?.message || body.message || 'Reset failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/login', { state: { message: 'Password reset successfully. Please sign in.' } });
|
||||||
|
} catch (err) {
|
||||||
|
setGeneralError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token || !email) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Invalid reset link</CardTitle>
|
||||||
|
<CardDescription>This password reset link is missing required parameters.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<Link to="/forgot-password" className="text-sm text-[var(--accent)] hover:underline">
|
||||||
|
Request a new reset link
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Set new password</CardTitle>
|
||||||
|
<CardDescription>Enter your new password below</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{generalError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{generalError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="New Password"
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
error={getError('newPassword')}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
description="Must contain uppercase, lowercase, and a number"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Confirm Password"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your new password"
|
||||||
|
error={getError('confirmPassword')}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
<Link to="/login" className="text-sm text-center text-[var(--accent)] hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
apps/creator-ui/src/pages/SessionsPage.tsx
Normal file
178
apps/creator-ui/src/pages/SessionsPage.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAuth, type Session } from '@persona-community-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<Session[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const authHeaders = useCallback(() => {
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load sessions');
|
||||||
|
const data = await res.json();
|
||||||
|
setSessions(data.data || data || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiPrefix, authHeaders]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
const revokeSession = async (sessionId: string) => {
|
||||||
|
setRevokingId(sessionId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions/${sessionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to revoke session');
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeAll = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/sessions`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to revoke sessions');
|
||||||
|
await loadSessions();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDay = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMin < 1) return 'Just now';
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Active Sessions</h2>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">
|
||||||
|
Manage your active login sessions across devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{sessions.length > 1 && (
|
||||||
|
<Button variant="outline" onClick={revokeAll}>
|
||||||
|
Revoke all other sessions
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-[var(--text-muted)]">
|
||||||
|
No active sessions found.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<Card key={session.id}>
|
||||||
|
<CardContent className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-[var(--text-primary)]">
|
||||||
|
{session.deviceLabel || 'Unknown device'}
|
||||||
|
</span>
|
||||||
|
{session.isCurrent && (
|
||||||
|
<Badge variant="success">Current</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">
|
||||||
|
{session.ipAddress || 'Unknown IP'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">
|
||||||
|
Last active: {formatDate(session.lastActiveAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!session.isCurrent && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revokeSession(session.id)}
|
||||||
|
disabled={revokingId === session.id}
|
||||||
|
>
|
||||||
|
{revokingId === session.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
apps/creator-ui/src/pages/VerifyEmailPage.tsx
Normal file
161
apps/creator-ui/src/pages/VerifyEmailPage.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '@persona-community-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<string | null>(null);
|
||||||
|
|
||||||
|
const apiPrefix = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
|
const authHeaders = () => {
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendCode = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error?.message || body.message || 'Failed to send code');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodeSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const code = formData.get('code') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiPrefix}/api/{{SERVICE_NAME}}/auth/verify-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error?.message || body.message || 'Verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerified(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<Check className="h-12 w-12 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Email verified</CardTitle>
|
||||||
|
<CardDescription>Your email address has been verified successfully.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>Verify your email</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{codeSent
|
||||||
|
? `Enter the 6-digit code sent to ${user?.email}`
|
||||||
|
: 'Verify your email address to access all features'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!codeSent ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-[var(--text-muted)] mb-4">
|
||||||
|
We'll send a verification code to <strong>{user?.email}</strong>
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleSendCode} disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Send verification code
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] mt-2">
|
||||||
|
In dev mode, check the server console for the code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerify} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Verification Code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Verify email
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||||
|
onClick={handleSendCode}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/creator-ui/src/vite-env.d.ts
vendored
Normal file
1
apps/creator-ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
17
apps/creator-ui/tailwind.config.ts
Normal file
17
apps/creator-ui/tailwind.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
// Include shared packages for Tailwind classes
|
||||||
|
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'../../packages/layout/src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
25
apps/creator-ui/tsconfig.json
Normal file
25
apps/creator-ui/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
apps/creator-ui/tsconfig.node.json
Normal file
11
apps/creator-ui/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
34
apps/creator-ui/vite.config.ts
Normal file
34
apps/creator-ui/vite.config.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
proxy: {
|
||||||
|
// SSE events endpoint — must disable buffering for streaming
|
||||||
|
'/api/{{SERVICE_NAME}}/events': {
|
||||||
|
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||||
|
changeOrigin: true,
|
||||||
|
// Disable response buffering so SSE events stream immediately
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyRes', (proxyRes) => {
|
||||||
|
// Prevent Vite from buffering SSE responses
|
||||||
|
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
||||||
|
proxyRes.headers['cache-control'] = 'no-cache';
|
||||||
|
proxyRes.headers['x-accel-buffering'] = 'no';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:{{SERVICE_PORT}}',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3001,
|
||||||
|
},
|
||||||
|
});
|
||||||
2
go.work
2
go.work
@ -1,4 +1,6 @@
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
use ./pkg
|
use ./pkg
|
||||||
|
use ./services/persona-api
|
||||||
|
use ./workers/media-worker
|
||||||
// Component modules will be added below
|
// Component modules will be added below
|
||||||
|
|||||||
31
services/persona-api/.env.example
Normal file
31
services/persona-api/.env.example
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# persona-api Service Configuration
|
||||||
|
|
||||||
|
# Server
|
||||||
|
SERVER_PORT=8001
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_NAME=persona-api
|
||||||
|
APP_ENVIRONMENT=development
|
||||||
|
APP_DEBUG=true
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
LOG_FORMAT=text
|
||||||
|
|
||||||
|
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
JWT_SECRET=dev-secret-change-in-production # Required — server refuses to start with empty secret
|
||||||
|
REGISTRATION_ENABLED=true
|
||||||
|
|
||||||
|
# Email delivery (notify service)
|
||||||
|
# When NOTIFY_URL is empty, auth codes are logged to stdout (dev mode).
|
||||||
|
# NOTIFY_URL=https://notify.threesix.ai
|
||||||
|
# NOTIFY_API_KEY=notify_send_xxx
|
||||||
|
# NOTIFY_HOST=myapp.threesix.ai
|
||||||
|
# NOTIFY_FROM=noreply@myapp.threesix.ai
|
||||||
|
|
||||||
|
# Database (if needed)
|
||||||
|
# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
|
||||||
|
# The postgres:// scheme works for both — CockroachDB is wire-compatible.
|
||||||
|
DATABASE_URL=postgres://dev:dev@localhost:5432/persona-community-1?sslmode=disable
|
||||||
35
services/persona-api/Dockerfile
Normal file
35
services/persona-api/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Configure Go private modules
|
||||||
|
# Disable workspace mode - each component builds independently with replace directives
|
||||||
|
ENV GOPRIVATE=git.threesix.ai/*
|
||||||
|
ENV GOWORK=off
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy shared pkg and this service only
|
||||||
|
COPY pkg/ ./pkg/
|
||||||
|
COPY services/persona-api/ ./services/persona-api/
|
||||||
|
|
||||||
|
# Download dependencies (populates go.sum if empty)
|
||||||
|
RUN cd pkg && go mod download
|
||||||
|
RUN cd services/persona-api && go mod download
|
||||||
|
|
||||||
|
# Build from the service directory (uses replace directive for ../pkg)
|
||||||
|
RUN cd services/persona-api && CGO_ENABLED=0 go build -o /persona-api ./cmd/server
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
COPY --from=builder /persona-api /persona-api
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
ENTRYPOINT ["/persona-api"]
|
||||||
38
services/persona-api/Makefile
Normal file
38
services/persona-api/Makefile
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
.PHONY: build run dev test lint fmt docker-build clean
|
||||||
|
|
||||||
|
SERVICE := persona-api
|
||||||
|
BINARY := bin/$(SERVICE)
|
||||||
|
GO_MODULE := git.threesix.ai/jordan/persona-community-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/
|
||||||
420
services/persona-api/cmd/server/main.go
Normal file
420
services/persona-api/cmd/server/main.go
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
// Package main is the entry point for the persona-api service.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
services/persona-api/cmd/server/migrations/.gitkeep
Normal file
0
services/persona-api/cmd/server/migrations/.gitkeep
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- 001_create_users.sql
|
||||||
|
-- Auth tables for user management, sessions, and authentication codes.
|
||||||
|
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||||
|
|
||||||
|
-- Core user identity. Email is the primary identifier for humans.
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
email_verified BOOL NOT NULL DEFAULT FALSE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Password credentials. Separate table because OAuth-only users have no password.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_passwords (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- OAuth provider connections (Google, GitHub, Apple, etc.).
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_connections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_user_id TEXT NOT NULL,
|
||||||
|
provider_email TEXT NOT NULL DEFAULT '',
|
||||||
|
access_token TEXT NOT NULL DEFAULT '',
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
token_expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_connections_user_id ON oauth_connections (user_id);
|
||||||
|
|
||||||
|
-- Verification codes for OTP login, magic links, password reset, and email verification.
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_codes_email_purpose ON auth_codes (email, purpose, expires_at)
|
||||||
|
WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
-- Sessions track where and when users are logged in.
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
device_label TEXT NOT NULL DEFAULT '',
|
||||||
|
last_active_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id)
|
||||||
|
WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- User roles. Separate table so users can have multiple roles.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, role)
|
||||||
|
);
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- 002_add_indexes.sql
|
||||||
|
-- Additional indexes for query performance.
|
||||||
|
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||||
|
|
||||||
|
-- Speed up OTP/magic-link/reset token lookup in FindValid queries.
|
||||||
|
-- The existing partial index on (email, purpose, expires_at) doesn't cover the code column,
|
||||||
|
-- so queries filtering on code must scan all matching rows.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes (code)
|
||||||
|
WHERE used_at IS NULL;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- 003_create_media_objects.sql
|
||||||
|
-- Media metadata table for tracking uploads, generation provenance, and soft deletes.
|
||||||
|
-- Compatible with both PostgreSQL (local dev) and CockroachDB (production).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_objects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
filename TEXT NOT NULL DEFAULT '',
|
||||||
|
content_type TEXT NOT NULL DEFAULT '',
|
||||||
|
size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
generation_job_id TEXT NOT NULL DEFAULT '',
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_objects_user_id ON media_objects (user_id, created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_objects_generation_job ON media_objects (generation_job_id)
|
||||||
|
WHERE generation_job_id != '';
|
||||||
9
services/persona-api/component.yaml
Normal file
9
services/persona-api/component.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
name: persona-api
|
||||||
|
type: service
|
||||||
|
port: 8001
|
||||||
|
path: services/persona-api
|
||||||
|
dependencies: []
|
||||||
|
# Add dependencies as needed:
|
||||||
|
# - postgres
|
||||||
|
# - redis
|
||||||
|
# - other-service
|
||||||
8
services/persona-api/go.mod
Normal file
8
services/persona-api/go.mod
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module git.threesix.ai/jordan/persona-community-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
|
||||||
0
services/persona-api/go.sum
Normal file
0
services/persona-api/go.sum
Normal file
32
services/persona-api/internal/adapter/email/log.go
Normal file
32
services/persona-api/internal/adapter/email/log.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// Package email provides email sending adapters for authentication flows.
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
112
services/persona-api/internal/adapter/email/notify.go
Normal file
112
services/persona-api/internal/adapter/email/notify.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
emailpkg "git.threesix.ai/jordan/persona-community-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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
services/persona-api/internal/adapter/memory/album.go
Normal file
177
services/persona-api/internal/adapter/memory/album.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
87
services/persona-api/internal/adapter/memory/auth_code.go
Normal file
87
services/persona-api/internal/adapter/memory/auth_code.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
106
services/persona-api/internal/adapter/memory/example.go
Normal file
106
services/persona-api/internal/adapter/memory/example.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
// Package memory provides in-memory implementations of repository interfaces.
|
||||||
|
// Useful for development, testing, and prototyping.
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
135
services/persona-api/internal/adapter/memory/media.go
Normal file
135
services/persona-api/internal/adapter/memory/media.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
120
services/persona-api/internal/adapter/memory/session.go
Normal file
120
services/persona-api/internal/adapter/memory/session.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
243
services/persona-api/internal/adapter/memory/user.go
Normal file
243
services/persona-api/internal/adapter/memory/user.go
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
120
services/persona-api/internal/adapter/postgres/auth_code.go
Normal file
120
services/persona-api/internal/adapter/postgres/auth_code.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
184
services/persona-api/internal/adapter/postgres/media.go
Normal file
184
services/persona-api/internal/adapter/postgres/media.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
162
services/persona-api/internal/adapter/postgres/session.go
Normal file
162
services/persona-api/internal/adapter/postgres/session.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
260
services/persona-api/internal/adapter/postgres/user.go
Normal file
260
services/persona-api/internal/adapter/postgres/user.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
281
services/persona-api/internal/api/handlers/album.go
Normal file
281
services/persona-api/internal/api/handlers/album.go
Normal file
@ -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:<userId> channel.
|
||||||
|
type Album struct {
|
||||||
|
albums *service.AlbumService
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbum creates a new Album handler.
|
||||||
|
func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album {
|
||||||
|
return &Album{
|
||||||
|
albums: albums,
|
||||||
|
logger: logger.WithComponent("AlbumHandler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request/response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateAlbumRequest is the request body for POST /albums.
|
||||||
|
type CreateAlbumRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"`
|
||||||
|
Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"`
|
||||||
|
TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShotTemplateBody is a single shot spec in the create request.
|
||||||
|
type ShotTemplateBody struct {
|
||||||
|
Label string `json:"label" validate:"required"`
|
||||||
|
Direction string `json:"direction" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumJobResponse is the response for generation enqueue endpoints.
|
||||||
|
type AlbumJobResponse struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumJobsResponse is the response for bulk generation enqueue.
|
||||||
|
type AlbumJobsResponse struct {
|
||||||
|
JobIDs []string `json:"jobIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Create handles POST /albums — creates a new album with shot specs.
|
||||||
|
func (h *Album) Create(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateAlbumRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a template set was provided, use it (overrides explicit shots).
|
||||||
|
var shots []album.ShotTemplate
|
||||||
|
if req.TemplateSet != "" {
|
||||||
|
set, ok := album.ShotTemplateSets[req.TemplateSet]
|
||||||
|
if !ok {
|
||||||
|
return httperror.BadRequest("unknown template set: " + req.TemplateSet)
|
||||||
|
}
|
||||||
|
shots = set
|
||||||
|
} else {
|
||||||
|
// Convert body shots to ShotTemplate.
|
||||||
|
shots = make([]album.ShotTemplate, len(req.Shots))
|
||||||
|
for i, s := range req.Shots {
|
||||||
|
shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to create album", "error", err, "user_id", user.ID)
|
||||||
|
return httperror.BadRequest(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.Created(w, r, a)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /albums — returns all albums for the authenticated user.
|
||||||
|
func (h *Album) List(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := h.albums.List(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to list albums", "error", err, "user_id", user.ID)
|
||||||
|
return httperror.Internal("failed to list albums")
|
||||||
|
}
|
||||||
|
|
||||||
|
if albums == nil {
|
||||||
|
albums = []album.Album{}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, albums)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /albums/{id} — returns a single album with all shot statuses.
|
||||||
|
func (h *Album) Get(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := h.albums.Get(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, a)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /albums/{id} — deletes an album.
|
||||||
|
func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.albums.Delete(r.Context(), id, user.ID); err != nil {
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.NoContent(w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generation (async — returns 202)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation.
|
||||||
|
// Returns 202 with job ID. Result arrives via album_anchor_complete SSE event.
|
||||||
|
func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id))
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||||
|
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots.
|
||||||
|
// Returns 422 if the album has no anchor yet.
|
||||||
|
// Returns 202 with job IDs for all enqueued shots.
|
||||||
|
func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if 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
|
||||||
|
}
|
||||||
331
services/persona-api/internal/api/handlers/auth.go
Normal file
331
services/persona-api/internal/api/handlers/auth.go
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
288
services/persona-api/internal/api/handlers/auth_flows.go
Normal file
288
services/persona-api/internal/api/handlers/auth_flows.go
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
94
services/persona-api/internal/api/handlers/chat.go
Normal file
94
services/persona-api/internal/api/handlers/chat.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
170
services/persona-api/internal/api/handlers/example.go
Normal file
170
services/persona-api/internal/api/handlers/example.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
|
}
|
||||||
402
services/persona-api/internal/api/handlers/example_test.go
Normal file
402
services/persona-api/internal/api/handlers/example_test.go
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
234
services/persona-api/internal/api/handlers/generate.go
Normal file
234
services/persona-api/internal/api/handlers/generate.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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:<userId>` before calling.
|
||||||
|
func (h *Generate) GenerateImage(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req GenerateImageRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if req.Count == 0 {
|
||||||
|
req.Count = 1
|
||||||
|
}
|
||||||
|
if req.Count > 4 {
|
||||||
|
req.Count = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.queue.Enqueue(r.Context(), "generate_image", map[string]any{
|
||||||
|
"prompt": req.Prompt,
|
||||||
|
"count": req.Count,
|
||||||
|
"aspectRatio": req.AspectRatio,
|
||||||
|
"userID": user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue image job", "error", err)
|
||||||
|
return httperror.Internal("failed to queue image generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("image generation queued", "jobId", jobID, "userID", user.ID)
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Video generation (async - takes 2-5 minutes)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GenerateVideoRequest is the request body for video generation.
|
||||||
|
type GenerateVideoRequest struct {
|
||||||
|
Prompt string `json:"prompt" validate:"required,min=1,max=2000"`
|
||||||
|
AspectRatio string `json:"aspectRatio"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVideo queues a video generation job.
|
||||||
|
// Returns immediately with job ID. Results come via SSE events.
|
||||||
|
func (h *Generate) GenerateVideo(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req GenerateVideoRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate video aspect ratio (Veo only supports 16:9 and 9:16)
|
||||||
|
if req.AspectRatio != "" && req.AspectRatio != "16:9" && req.AspectRatio != "9:16" {
|
||||||
|
return httperror.BadRequest("video only supports 16:9 and 9:16 aspect ratios")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.queue.Enqueue(r.Context(), "generate_video", map[string]any{
|
||||||
|
"prompt": req.Prompt,
|
||||||
|
"aspectRatio": req.AspectRatio,
|
||||||
|
"duration": req.Duration,
|
||||||
|
"userID": user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue video job", "error", err)
|
||||||
|
return httperror.Internal("failed to queue video generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("video generation queued", "jobId", jobID, "userID", user.ID)
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text generation (async - returns job ID, results via SSE with streaming chunks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GenerateTextRequest is the request body for text generation.
|
||||||
|
type GenerateTextRequest struct {
|
||||||
|
Prompt string `json:"prompt" validate:"required,min=1,max=5000"`
|
||||||
|
SystemPrompt string `json:"systemPrompt"`
|
||||||
|
MaxTokens int `json:"maxTokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateText queues a text generation job.
|
||||||
|
// Returns immediately with job ID. Chunks come via SSE as ai_chat_chunk events.
|
||||||
|
func (h *Generate) GenerateText(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req GenerateTextRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.queue.Enqueue(r.Context(), "generate_text", map[string]any{
|
||||||
|
"prompt": req.Prompt,
|
||||||
|
"systemPrompt": req.SystemPrompt,
|
||||||
|
"maxTokens": req.MaxTokens,
|
||||||
|
"userID": user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue text job", "error", err)
|
||||||
|
return httperror.Internal("failed to queue text generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("text generation queued", "jobId", jobID, "userID", user.ID)
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job status (poll fallback for SSE)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetJobStatus returns the current status of a generation job.
|
||||||
|
// This is a poll-based fallback for clients that can't use SSE.
|
||||||
|
func (h *Generate) GetJobStatus(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
jobID := chi.URLParam(r, "id")
|
||||||
|
if jobID == "" {
|
||||||
|
return httperror.BadRequest("job ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := h.jobReader.GetJob(r.Context(), jobID)
|
||||||
|
if err != nil {
|
||||||
|
if err == queue.ErrJobNotFound {
|
||||||
|
return httperror.NotFound("job not found")
|
||||||
|
}
|
||||||
|
h.logger.Error("failed to get job status", "error", err, "job_id", jobID)
|
||||||
|
return httperror.Internal("failed to get job status")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"id": job.ID,
|
||||||
|
"type": job.Type,
|
||||||
|
"status": string(job.Status),
|
||||||
|
"createdAt": job.CreatedAt,
|
||||||
|
}
|
||||||
|
if job.StartedAt != nil {
|
||||||
|
resp["startedAt"] = job.StartedAt
|
||||||
|
}
|
||||||
|
if job.CompletedAt != nil {
|
||||||
|
resp["completedAt"] = job.CompletedAt
|
||||||
|
}
|
||||||
|
if job.Error != "" {
|
||||||
|
resp["error"] = job.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSE Events endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Events returns the SSE handler for event subscriptions.
|
||||||
|
// Mount at /api/events.
|
||||||
|
func (h *Generate) Events() http.Handler {
|
||||||
|
return realtime.NewSSEHandler(h.sseHub, h.logger.Logger)
|
||||||
|
}
|
||||||
26
services/persona-api/internal/api/handlers/health.go
Normal file
26
services/persona-api/internal/api/handlers/health.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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",
|
||||||
|
})
|
||||||
|
}
|
||||||
372
services/persona-api/internal/api/handlers/media.go
Normal file
372
services/persona-api/internal/api/handlers/media.go
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
85
services/persona-api/internal/api/handlers/persona.go
Normal file
85
services/persona-api/internal/api/handlers/persona.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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:<userId>` channel:
|
||||||
|
//
|
||||||
|
// - persona_spec_started: LLM pipeline started
|
||||||
|
// - persona_spec_complete: Persona profile generated
|
||||||
|
// - persona_image_started: Starting a specific image position
|
||||||
|
// - persona_image_progress: Image position complete with URL
|
||||||
|
// - persona_image_complete: All 20 images generated
|
||||||
|
// - persona_video_started: Starting a video motion type
|
||||||
|
// - persona_video_complete: Video complete with URL
|
||||||
|
// - persona_failed: Generation failed (check error field)
|
||||||
|
type Persona struct {
|
||||||
|
queue queue.Producer
|
||||||
|
jobReader queue.JobReader
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPersona creates a new Persona handler with injected dependencies.
|
||||||
|
func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
|
||||||
|
return &Persona{
|
||||||
|
queue: q,
|
||||||
|
jobReader: jr,
|
||||||
|
logger: logger.WithComponent("PersonaHandler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePersonaRequest is the request body for persona generation.
|
||||||
|
type GeneratePersonaRequest struct {
|
||||||
|
// Description is a natural-language persona concept (required).
|
||||||
|
// Example: "mysterious woman with dark hair who loves poetry"
|
||||||
|
Description string `json:"description" validate:"required,min=3,max=1000"`
|
||||||
|
|
||||||
|
// Gender is the gender identity: "woman", "man", or "non_binary" (required).
|
||||||
|
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
|
||||||
|
|
||||||
|
// Name is an optional name override for the generated persona.
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePersona queues a persona generation job.
|
||||||
|
// Returns immediately with job ID. Full lifecycle results come via SSE.
|
||||||
|
//
|
||||||
|
// Subscribe to SSE channel `user:<userId>` at /api/persona-api/events before calling.
|
||||||
|
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
|
||||||
|
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req GeneratePersonaRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
|
||||||
|
"description": req.Description,
|
||||||
|
"gender": req.Gender,
|
||||||
|
"name": req.Name,
|
||||||
|
"userID": user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue persona job", "error", err)
|
||||||
|
return httperror.Internal("failed to queue persona generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
184
services/persona-api/internal/api/routes.go
Normal file
184
services/persona-api/internal/api/routes.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// Package api provides HTTP routing and handlers for the persona-api service.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
emailpkg "git.threesix.ai/jordan/persona-community-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:<userId> channels.
|
||||||
|
// Without a real user identity, events go to user:anonymous and never reach the client.
|
||||||
|
r.Group(func(r app.Router) {
|
||||||
|
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||||
|
Validator: jwtValidator,
|
||||||
|
}))
|
||||||
|
r.Use(auth.SessionCheck(sessionChecker))
|
||||||
|
|
||||||
|
// Chat messaging
|
||||||
|
r.Post("/chat/messages", app.Wrap(chatHandler.SendMessage))
|
||||||
|
|
||||||
|
// Media generation (all queue-based, returns 202)
|
||||||
|
r.Post("/generate/image", app.Wrap(generateHandler.GenerateImage))
|
||||||
|
r.Post("/generate/video", app.Wrap(generateHandler.GenerateVideo))
|
||||||
|
r.Post("/generate/text", app.Wrap(generateHandler.GenerateText))
|
||||||
|
r.Get("/generate/jobs/{id}", app.Wrap(generateHandler.GetJobStatus))
|
||||||
|
|
||||||
|
// Media library (upload, list, delete)
|
||||||
|
r.Mount("/media", mediaHandler.Routes())
|
||||||
|
|
||||||
|
// Album generation (anchor + shots)
|
||||||
|
r.Get("/albums", app.Wrap(albumHandler.List))
|
||||||
|
r.Post("/albums", app.Wrap(albumHandler.Create))
|
||||||
|
r.Get("/albums/{id}", app.Wrap(albumHandler.Get))
|
||||||
|
r.Delete("/albums/{id}", app.Wrap(albumHandler.Delete))
|
||||||
|
r.Post("/albums/{id}/anchor", app.Wrap(albumHandler.GenerateAnchor))
|
||||||
|
r.Post("/albums/{id}/shots", app.Wrap(albumHandler.GenerateAllShots))
|
||||||
|
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
|
||||||
|
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
|
||||||
|
|
||||||
|
// Persona generation (5-stage LLM + 20 images + 4 videos, all async)
|
||||||
|
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies holds all service dependencies for route registration.
|
||||||
|
type Dependencies struct {
|
||||||
|
ExampleService *service.ExampleService
|
||||||
|
AuthService *service.AuthService
|
||||||
|
AlbumService *service.AlbumService
|
||||||
|
Queue queue.Producer
|
||||||
|
JobReader queue.JobReader
|
||||||
|
SSEHub *realtime.SSEHub
|
||||||
|
Store storage.Store
|
||||||
|
MediaRepo port.MediaRepository
|
||||||
|
EmailRenderer *emailpkg.Renderer
|
||||||
|
}
|
||||||
112
services/persona-api/internal/api/spec.go
Normal file
112
services/persona-api/internal/api/spec.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
90
services/persona-api/internal/config/config.go
Normal file
90
services/persona-api/internal/config/config.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
// Package config provides service-specific configuration.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
32
services/persona-api/internal/domain/auth_code.go
Normal file
32
services/persona-api/internal/domain/auth_code.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AuthCodePurpose identifies what an auth code is used for.
|
||||||
|
type AuthCodePurpose string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PurposeLoginOTP AuthCodePurpose = "login_otp"
|
||||||
|
PurposeMagicLink AuthCodePurpose = "magic_link"
|
||||||
|
PurposePasswordReset AuthCodePurpose = "password_reset"
|
||||||
|
PurposeEmailVerify AuthCodePurpose = "email_verify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthCode is a single-use, time-limited code for authentication flows.
|
||||||
|
// Used by OTP login, magic links, password reset, and email verification.
|
||||||
|
type AuthCode struct {
|
||||||
|
ID string
|
||||||
|
UserID *UserID // Nullable for magic link signup
|
||||||
|
Email string
|
||||||
|
Code string
|
||||||
|
Purpose AuthCodePurpose
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
IPAddress string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid returns true if the code has not been used and has not expired.
|
||||||
|
func (c *AuthCode) IsValid() bool {
|
||||||
|
return c.UsedAt == nil && time.Now().Before(c.ExpiresAt)
|
||||||
|
}
|
||||||
36
services/persona-api/internal/domain/errors.go
Normal file
36
services/persona-api/internal/domain/errors.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Package domain contains pure domain models with no external dependencies.
|
||||||
|
// These types represent the core business concepts of the service.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Domain errors - these are business-level errors that should be translated
|
||||||
|
// to appropriate HTTP status codes by the handler layer.
|
||||||
|
var (
|
||||||
|
// ErrNotFound indicates a requested resource does not exist.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrExampleNotFound indicates the requested example does not exist.
|
||||||
|
ErrExampleNotFound = errors.New("example not found")
|
||||||
|
|
||||||
|
// ErrDuplicateExample indicates an example with the same name already exists.
|
||||||
|
ErrDuplicateExample = errors.New("example with this name already exists")
|
||||||
|
|
||||||
|
// ErrInvalidExampleName indicates the example name is invalid.
|
||||||
|
ErrInvalidExampleName = errors.New("invalid example name")
|
||||||
|
|
||||||
|
// Auth errors
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrDuplicateEmail = errors.New("email already registered")
|
||||||
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
|
ErrSessionRevoked = errors.New("session has been revoked")
|
||||||
|
ErrInvalidAuthCode = errors.New("invalid or expired code")
|
||||||
|
ErrExpiredAuthCode = errors.New("code has expired")
|
||||||
|
ErrWeakPassword = errors.New("password does not meet requirements")
|
||||||
|
ErrUserSuspended = errors.New("account is suspended")
|
||||||
|
ErrRegistrationDisabled = errors.New("registration is disabled")
|
||||||
|
ErrNameTooLong = errors.New("name exceeds maximum length")
|
||||||
|
ErrEmailTooLong = errors.New("email exceeds maximum length")
|
||||||
|
ErrInvalidAvatarURL = errors.New("avatar URL must use http or https")
|
||||||
|
)
|
||||||
89
services/persona-api/internal/domain/example.go
Normal file
89
services/persona-api/internal/domain/example.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExampleID is a strongly-typed identifier for examples.
|
||||||
|
type ExampleID string
|
||||||
|
|
||||||
|
// String returns the string representation of the ID.
|
||||||
|
func (id ExampleID) String() string {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero returns true if the ID is empty.
|
||||||
|
func (id ExampleID) IsZero() bool {
|
||||||
|
return id == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example name constraints.
|
||||||
|
const (
|
||||||
|
MinExampleNameLen = 1
|
||||||
|
MaxExampleNameLen = 100
|
||||||
|
MaxDescriptionLen = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// Example represents an example domain entity.
|
||||||
|
// This is a pure domain model with no external dependencies.
|
||||||
|
type Example struct {
|
||||||
|
ID ExampleID
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExample creates a new Example with validation.
|
||||||
|
// Returns ErrInvalidExampleName if the name is invalid.
|
||||||
|
func NewExample(id ExampleID, name, description string) (*Example, error) {
|
||||||
|
if err := validateExampleName(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateDescription(description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return &Example{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modifies the example's mutable fields with validation.
|
||||||
|
// Returns ErrInvalidExampleName if the name is invalid.
|
||||||
|
func (e *Example) Update(name, description string) error {
|
||||||
|
if err := validateExampleName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDescription(description); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Name = name
|
||||||
|
e.Description = description
|
||||||
|
e.UpdatedAt = time.Now().UTC()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateExampleName validates an example name.
|
||||||
|
func validateExampleName(name string) error {
|
||||||
|
length := utf8.RuneCountInString(name)
|
||||||
|
if length < MinExampleNameLen || length > MaxExampleNameLen {
|
||||||
|
return ErrInvalidExampleName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDescription validates a description.
|
||||||
|
func validateDescription(desc string) error {
|
||||||
|
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
|
||||||
|
return ErrInvalidExampleName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
27
services/persona-api/internal/domain/media.go
Normal file
27
services/persona-api/internal/domain/media.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MediaObjectID is a typed media object identifier with prefix "med_".
|
||||||
|
type MediaObjectID string
|
||||||
|
|
||||||
|
// MediaObject tracks a stored media file with ownership and metadata.
|
||||||
|
// The actual file is stored in GCS (production) or MemoryStore (dev).
|
||||||
|
// This record enables querying, soft deletes, and provenance tracking.
|
||||||
|
type MediaObject struct {
|
||||||
|
ID MediaObjectID
|
||||||
|
UserID UserID
|
||||||
|
Path string // Storage path (e.g., "media/usr_123/uuid/photo.png")
|
||||||
|
Filename string // Original filename
|
||||||
|
ContentType string
|
||||||
|
Size int64
|
||||||
|
GenerationJobID string // Non-empty if created by AI generation
|
||||||
|
DeletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDeleted returns true if the media object has been soft-deleted.
|
||||||
|
func (m *MediaObject) IsDeleted() bool {
|
||||||
|
return m.DeletedAt != nil
|
||||||
|
}
|
||||||
25
services/persona-api/internal/domain/session.go
Normal file
25
services/persona-api/internal/domain/session.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SessionID is a typed session identifier with prefix "ses_".
|
||||||
|
type SessionID string
|
||||||
|
|
||||||
|
// Session tracks a user login with device and location information.
|
||||||
|
// The session ID is embedded in the JWT token for revocation support.
|
||||||
|
type Session struct {
|
||||||
|
ID SessionID
|
||||||
|
UserID UserID
|
||||||
|
IPAddress string
|
||||||
|
UserAgent string
|
||||||
|
DeviceLabel string
|
||||||
|
LastActiveAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if the session has not been revoked and has not expired.
|
||||||
|
func (s *Session) IsActive() bool {
|
||||||
|
return s.RevokedAt == nil && time.Now().Before(s.ExpiresAt)
|
||||||
|
}
|
||||||
52
services/persona-api/internal/domain/user.go
Normal file
52
services/persona-api/internal/domain/user.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserID is a typed user identifier with prefix "usr_".
|
||||||
|
type UserID string
|
||||||
|
|
||||||
|
// UserStatus represents the account state.
|
||||||
|
type UserStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserStatusActive UserStatus = "active"
|
||||||
|
UserStatusSuspended UserStatus = "suspended"
|
||||||
|
UserStatusDeactivated UserStatus = "deactivated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is the full domain model for a registered user.
|
||||||
|
// This is the database-backed identity, separate from auth.User which is the
|
||||||
|
// lightweight JWT-derived identity carried in request context.
|
||||||
|
type User struct {
|
||||||
|
ID UserID
|
||||||
|
Email string
|
||||||
|
EmailVerified bool
|
||||||
|
Name string
|
||||||
|
AvatarURL string
|
||||||
|
Status UserStatus
|
||||||
|
Roles []string
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation constants for user fields.
|
||||||
|
const (
|
||||||
|
MaxNameLen = 100
|
||||||
|
MaxEmailLen = 254 // RFC 5321
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewUser creates a new user with default values.
|
||||||
|
func NewUser(id UserID, email, name string) *User {
|
||||||
|
now := time.Now()
|
||||||
|
return &User{
|
||||||
|
ID: id,
|
||||||
|
Email: email,
|
||||||
|
EmailVerified: false,
|
||||||
|
Name: name,
|
||||||
|
Status: UserStatusActive,
|
||||||
|
Roles: []string{"user"},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
8
services/persona-api/internal/email/embed.go
Normal file
8
services/persona-api/internal/email/embed.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Package email provides embedded email templates for persona-api transactional emails.
|
||||||
|
// The TemplateFS is passed to pkg/email.NewRendererFromFS at startup.
|
||||||
|
package email
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed all:templates
|
||||||
|
var TemplateFS embed.FS
|
||||||
202
services/persona-api/internal/email/renderer_test.go
Normal file
202
services/persona-api/internal/email/renderer_test.go
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
package email_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
emailpkg "git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{{define "button"}}<div class="btn-wrapper">
|
||||||
|
<a href="{{.ActionURL}}" class="btn btn-primary">{{.ButtonText}}</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{{define "code_box"}}<div class="code-box">
|
||||||
|
<span class="code-box-value">{{.Code}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
{{define "footer"}}<div class="email-footer">
|
||||||
|
{{- if .SupportEmail}}
|
||||||
|
<p>Questions? <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
|
||||||
|
{{- end}}
|
||||||
|
<p>You received this email because you have an account with {{.AppName}}.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{{define "header"}}<div class="email-header">
|
||||||
|
{{- if .LogoURL}}
|
||||||
|
<a href="{{.AppURL}}"><img src="{{.LogoURL}}" alt="{{.AppName}}" class="app-logo"></a>
|
||||||
|
{{- else}}
|
||||||
|
<a href="{{.AppURL}}" class="app-name">{{.AppName}}</a>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
83
services/persona-api/internal/email/templates/_layout.html
Normal file
83
services/persona-api/internal/email/templates/_layout.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{{define "layout"}}<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>{{.AppName}}</title>
|
||||||
|
<style>
|
||||||
|
/* Reset */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background-color: #f9fafb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 16px; line-height: 1.6; color: #374151; }
|
||||||
|
|
||||||
|
/* Page wrapper */
|
||||||
|
.email-wrapper { width: 100%; background-color: #f9fafb; padding: 32px 16px; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.email-card { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Preheader — visually hidden but visible in inbox preview text */
|
||||||
|
.preheader { display: none; max-height: 0; overflow: hidden; mso-hide: all; font-size: 1px; line-height: 1px; color: #f9fafb; opacity: 0; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.email-header { padding: 24px 32px; border-bottom: 1px solid #e5e7eb; background-color: #ffffff; }
|
||||||
|
.app-name { font-size: 20px; font-weight: 700; color: {{.PrimaryColor}}; text-decoration: none; letter-spacing: -0.01em; display: inline-block; }
|
||||||
|
.app-logo { max-height: 36px; max-width: 160px; display: block; }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.email-body { padding: 40px 32px; }
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 { font-size: 24px; font-weight: 700; line-height: 1.3; color: #111827; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 20px; font-weight: 600; line-height: 1.4; color: #111827; margin-bottom: 12px; }
|
||||||
|
p { font-size: 16px; line-height: 1.6; color: #374151; margin-bottom: 16px; }
|
||||||
|
p:last-child { margin-bottom: 0; }
|
||||||
|
a { color: {{.PrimaryColor}}; text-decoration: underline; }
|
||||||
|
.text-muted { color: #6b7280; font-size: 14px; }
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 28px 0; }
|
||||||
|
|
||||||
|
/* OTP code box */
|
||||||
|
.code-box { background-color: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px; text-align: center; margin: 28px 0; }
|
||||||
|
.code-box-value { font-family: 'Courier New', Courier, monospace; font-size: 48px; font-weight: 700; letter-spacing: 0.15em; color: #111827; display: block; line-height: 1; }
|
||||||
|
|
||||||
|
/* CTA button */
|
||||||
|
.btn-wrapper { margin: 28px 0; text-align: left; }
|
||||||
|
.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; text-align: center; line-height: 1.4; }
|
||||||
|
.btn-primary { background-color: {{.PrimaryColor}}; color: #ffffff; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.email-footer { padding: 24px 32px; border-top: 1px solid #e5e7eb; background-color: #f9fafb; }
|
||||||
|
.email-footer p { font-size: 14px; color: #6b7280; margin-bottom: 4px; line-height: 1.5; }
|
||||||
|
.email-footer p:last-child { margin-bottom: 0; }
|
||||||
|
.email-footer a { color: #6b7280; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-wrapper { padding: 0; }
|
||||||
|
.email-card { border-radius: 0; border-left: none; border-right: none; }
|
||||||
|
.email-body { padding: 28px 20px; }
|
||||||
|
.email-header { padding: 20px; }
|
||||||
|
.email-footer { padding: 20px; }
|
||||||
|
.code-box-value { font-size: 36px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-wrapper">
|
||||||
|
<!-- Preheader: visible in inbox preview, invisible in email body -->
|
||||||
|
<span class="preheader">{{.Preheader}}‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ </span>
|
||||||
|
<div class="email-card">
|
||||||
|
{{template "header" .}}
|
||||||
|
<div class="email-body">
|
||||||
|
{{template "body" .}}
|
||||||
|
</div>
|
||||||
|
{{template "footer" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<p>Hi there,</p>
|
||||||
|
<p>Enter this code to verify your email address for <strong>{{.AppName}}</strong>:</p>
|
||||||
|
|
||||||
|
{{template "code_box" .}}
|
||||||
|
|
||||||
|
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
purpose: email_verify
|
||||||
|
category: transactional
|
||||||
|
subject: "Verify your {{.AppName}} email address"
|
||||||
|
preheader: "{{.Code}} is your email verification code — expires in {{.ExpiresIn}} minutes."
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<p>Hi there,</p>
|
||||||
|
<p>Here is your sign in code for <strong>{{.AppName}}</strong>:</p>
|
||||||
|
|
||||||
|
{{template "code_box" .}}
|
||||||
|
|
||||||
|
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.</p>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
purpose: login_otp
|
||||||
|
category: transactional
|
||||||
|
subject: "Your {{.AppName}} sign in code"
|
||||||
|
preheader: "{{.Code}} is your sign in code — expires in {{.ExpiresIn}} minutes."
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<h2>Sign in to {{.AppName}}</h2>
|
||||||
|
<p>Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.</p>
|
||||||
|
|
||||||
|
{{template "button" .}}
|
||||||
|
|
||||||
|
<p class="text-muted">Or copy this link into your browser:<br>
|
||||||
|
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<p class="text-muted">If you didn't request this sign-in link, you can safely ignore this email — your account is secure.</p>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
purpose: magic_link
|
||||||
|
category: transactional
|
||||||
|
subject: "Sign in to {{.AppName}}"
|
||||||
|
preheader: "Click to sign in to your {{.AppName}} account — link expires in {{.ExpiresIn}} minutes."
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<h2>Reset your password</h2>
|
||||||
|
<p>Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.</p>
|
||||||
|
|
||||||
|
{{template "button" .}}
|
||||||
|
|
||||||
|
<p class="text-muted">Or copy this link into your browser:<br>
|
||||||
|
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
|
||||||
|
|
||||||
|
<hr class="divider">
|
||||||
|
|
||||||
|
<p class="text-muted">If you didn't request a password reset, you can safely ignore this email — your password won't change.</p>
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
purpose: password_reset
|
||||||
|
category: transactional
|
||||||
|
subject: "Reset your {{.AppName}} password"
|
||||||
|
preheader: "You requested a password reset for {{.AppName}} — link expires in {{.ExpiresIn}} minutes."
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
{{define "body"}}
|
||||||
|
<h2>Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!</h2>
|
||||||
|
<p>Your account is ready. Start exploring everything {{.AppName}} has to offer.</p>
|
||||||
|
|
||||||
|
{{template "button" .}}
|
||||||
|
|
||||||
|
{{- if .SupportEmail}}
|
||||||
|
<p class="text-muted">Have questions? Reach us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a>.</p>
|
||||||
|
{{- end}}
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
purpose: welcome
|
||||||
|
category: transactional
|
||||||
|
subject: "Welcome to {{.AppName}}"
|
||||||
|
preheader: "Your {{.AppName}} account is ready. Get started today."
|
||||||
34
services/persona-api/internal/port/album.go
Normal file
34
services/persona-api/internal/port/album.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
24
services/persona-api/internal/port/auth_code.go
Normal file
24
services/persona-api/internal/port/auth_code.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
11
services/persona-api/internal/port/email.go
Normal file
11
services/persona-api/internal/port/email.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// EmailSender sends emails for authentication flows (OTP, magic link, password reset, etc.).
|
||||||
|
type EmailSender interface {
|
||||||
|
// SendAuthCode sends an authentication code to the given email.
|
||||||
|
// purpose identifies the flow (e.g. "login_otp", "magic_link", "password_reset", "email_verify").
|
||||||
|
// code is the token or OTP to include in the email.
|
||||||
|
SendAuthCode(ctx context.Context, email, code, purpose string) error
|
||||||
|
}
|
||||||
37
services/persona-api/internal/port/example.go
Normal file
37
services/persona-api/internal/port/example.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Package port defines interfaces (ports) for external dependencies.
|
||||||
|
// These interfaces define the contracts between the application core and
|
||||||
|
// infrastructure adapters, enabling testability and flexibility.
|
||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
38
services/persona-api/internal/port/media.go
Normal file
38
services/persona-api/internal/port/media.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
33
services/persona-api/internal/port/session.go
Normal file
33
services/persona-api/internal/port/session.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
51
services/persona-api/internal/port/user.go
Normal file
51
services/persona-api/internal/port/user.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
186
services/persona-api/internal/service/album.go
Normal file
186
services/persona-api/internal/service/album.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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:<userId> channel.
|
||||||
|
type AlbumService struct {
|
||||||
|
albums port.AlbumRepository
|
||||||
|
queue queue.Producer
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbumService creates a new AlbumService.
|
||||||
|
func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService {
|
||||||
|
return &AlbumService{
|
||||||
|
albums: albums,
|
||||||
|
queue: q,
|
||||||
|
logger: logger.WithComponent("AlbumService"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new album with the given shots and persists it.
|
||||||
|
// Shots are provided as ShotTemplate slices (Label + Direction).
|
||||||
|
func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("album name is required")
|
||||||
|
}
|
||||||
|
if subjectDesc == "" {
|
||||||
|
return nil, fmt.Errorf("subject description is required")
|
||||||
|
}
|
||||||
|
if len(shots) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one shot is required")
|
||||||
|
}
|
||||||
|
if len(shots) > 20 {
|
||||||
|
return nil, fmt.Errorf("maximum 20 shots per album")
|
||||||
|
}
|
||||||
|
|
||||||
|
shotList := make([]album.Shot, len(shots))
|
||||||
|
for i, tmpl := range shots {
|
||||||
|
shotList[i] = album.Shot{
|
||||||
|
Index: i,
|
||||||
|
Label: tmpl.Label,
|
||||||
|
Direction: tmpl.Direction,
|
||||||
|
Status: album.ShotPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &album.Album{
|
||||||
|
ID: album.AlbumID("alb_" + generateID()),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
SubjectDesc: subjectDesc,
|
||||||
|
Shots: shotList,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.albums.Create(ctx, a); err != nil {
|
||||||
|
return nil, fmt.Errorf("create album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList))
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an album by ID, enforcing user ownership.
|
||||||
|
func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all albums for a user.
|
||||||
|
func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) {
|
||||||
|
return s.albums.List(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an album. Does NOT delete stored images.
|
||||||
|
func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error {
|
||||||
|
return s.albums.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAnchor enqueues an anchor generation job for an album.
|
||||||
|
// Returns the job ID. Result arrives via album_anchor_complete SSE event.
|
||||||
|
func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{
|
||||||
|
"albumId": string(a.ID),
|
||||||
|
"userId": userID,
|
||||||
|
"subjectDesc": a.SubjectDesc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("enqueue anchor job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil {
|
||||||
|
s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||||
|
return jobID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllShots enqueues generation jobs for all pending shots.
|
||||||
|
// Returns 422 if the album has no anchor yet (shots require an anchor reference).
|
||||||
|
func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
if a.AnchorURL == "" {
|
||||||
|
return nil, 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
|
||||||
|
}
|
||||||
|
|
||||||
644
services/persona-api/internal/service/auth.go
Normal file
644
services/persona-api/internal/service/auth.go
Normal file
@ -0,0 +1,644 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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)
|
||||||
|
}
|
||||||
137
services/persona-api/internal/service/example.go
Normal file
137
services/persona-api/internal/service/example.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// Package service provides business logic / use cases for the application.
|
||||||
|
// Services orchestrate domain operations using port interfaces.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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
|
||||||
|
}
|
||||||
282
services/persona-api/internal/service/example_test.go
Normal file
282
services/persona-api/internal/service/example_test.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.threesix.ai/jordan/persona-community-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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user