From 0d8837a4f869bba9b47096b948c9b0f2c0712793 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 22 Jun 2026 18:42:11 -0600
Subject: [PATCH 01/15] fix register endpoint
---
frontend/src/features/auth/auth-types.ts | 4 +-
frontend/src/lib/http/api-client.ts | 81 ++++++++++++++++++-
frontend/src/pages/register/register-page.tsx | 3 +-
scripts/run-compose-stack.sh | 8 +-
webapi/api/endpoints/v1/auths.py | 2 +-
webapi/schemas/user_schema.py | 4 +-
webapi/tests/conftest.py | 2 +-
webapi/tests/functional/test_auth_routes.py | 25 ++++--
webapi/tests/functional/test_cd.py | 2 +-
webapi/tests/nonfunctional/test_ci.py | 2 +-
10 files changed, 112 insertions(+), 21 deletions(-)
diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts
index b0c77a7..07d7388 100644
--- a/frontend/src/features/auth/auth-types.ts
+++ b/frontend/src/features/auth/auth-types.ts
@@ -28,7 +28,9 @@ export type UserRecord = {
role: UserRole
}
-export type SignupResponse = UserRecord
+export type SignupResponse = {
+ message: string
+}
export type RecoveryGenerateInput = {
username: string
diff --git a/frontend/src/lib/http/api-client.ts b/frontend/src/lib/http/api-client.ts
index 0bc8fc0..202c598 100644
--- a/frontend/src/lib/http/api-client.ts
+++ b/frontend/src/lib/http/api-client.ts
@@ -30,10 +30,7 @@ export async function apiRequest(
const data = raw ? tryParseJson(raw) : null
if (!response.ok) {
- const detail =
- typeof data === 'object' && data && 'detail' in data
- ? String(data.detail)
- : `Request failed with status ${response.status}`
+ const detail = getErrorDetail(data, response.status)
throw new ApiError(response.status, detail)
}
@@ -48,3 +45,79 @@ function tryParseJson(value: string): unknown {
}
}
+function getErrorDetail(data: unknown, status: number): string {
+ if (isRecord(data) && 'detail' in data) {
+ return formatDetail(data.detail, status)
+ }
+
+ return fallbackMessage(status)
+}
+
+function formatDetail(detail: unknown, status: number): string {
+ if (typeof detail === 'string') {
+ return detail
+ }
+
+ if (Array.isArray(detail)) {
+ const messages = detail
+ .map(formatValidationItem)
+ .filter((message): message is string => Boolean(message))
+
+ return messages.length > 0 ? messages.join('; ') : fallbackMessage(status)
+ }
+
+ if (isRecord(detail)) {
+ const json = safeStringify(detail)
+ return json ?? fallbackMessage(status)
+ }
+
+ return fallbackMessage(status)
+}
+
+function formatValidationItem(item: unknown): string | null {
+ if (typeof item === 'string') {
+ return item
+ }
+
+ if (!isRecord(item)) {
+ return null
+ }
+
+ const message = typeof item.msg === 'string' ? item.msg : null
+ const location = formatLocation(item.loc)
+
+ if (message && location) {
+ return `${location}: ${message}`
+ }
+
+ return message ?? safeStringify(item)
+}
+
+function formatLocation(loc: unknown): string | null {
+ if (!Array.isArray(loc)) {
+ return null
+ }
+
+ const parts = loc
+ .filter((part): part is string | number => typeof part === 'string' || typeof part === 'number')
+ .filter((part) => part !== 'body')
+
+ return parts.length > 0 ? parts.join('.') : null
+}
+
+function safeStringify(value: unknown): string | null {
+ try {
+ const json = JSON.stringify(value)
+ return typeof json === 'string' ? json : null
+ } catch {
+ return null
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function fallbackMessage(status: number): string {
+ return `Request failed with status ${status}`
+}
diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx
index aff1e45..bf789c9 100644
--- a/frontend/src/pages/register/register-page.tsx
+++ b/frontend/src/pages/register/register-page.tsx
@@ -5,6 +5,7 @@ import { Card } from '../../components/ui/card'
import { InlineError } from '../../components/ui/inline-error'
import { Input } from '../../components/ui/input'
import { signup } from '../../features/auth/auth-service'
+import { ApiError } from '../../lib/http/api-error'
import { registerSchema } from '../../lib/validation/auth-schemas'
export function RegisterPage() {
@@ -32,7 +33,7 @@ export function RegisterPage() {
navigate('/login?registered=1', { replace: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to create account'
- setError(message.includes('400') ? 'Username or email is already registered' : message)
+ setError(err instanceof ApiError && err.status === 400 ? 'Username is already taken' : message)
} finally {
setLoading(false)
}
diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh
index cf1f1ec..a1bad2c 100755
--- a/scripts/run-compose-stack.sh
+++ b/scripts/run-compose-stack.sh
@@ -313,7 +313,7 @@ wait_for_http() {
}
verify_api_proxy() {
- local url="http://127.0.0.1:80/api/v1/auth/login"
+ local url="http://127.0.0.1/api/v1/auth/login"
local status
log "Checking nginx API proxy: ${url}"
@@ -442,7 +442,7 @@ start_stack
wait_for_health mariadb "${TIMEOUT_SECONDS}"
wait_for_health redis "${TIMEOUT_SECONDS}"
wait_for_health backend "${TIMEOUT_SECONDS}"
-wait_for_http "frontend" "http://127.0.0.1:80/" "${TIMEOUT_SECONDS}"
+wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}"
wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}"
verify_api_proxy
verify_database
@@ -450,9 +450,9 @@ verify_redis
log "Stack is ready."
printf '\nURLs:\n'
-printf ' Frontend: http://127.0.0.1:80\n'
+printf ' Frontend: http://127.0.0.1\n'
printf ' Backend direct: http://127.0.0.1:8000/docs\n'
-printf ' Backend through nginx: http://127.0.0.1:80/api/v1/...\n'
+printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n'
printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}"
printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}"
printf '\nUseful commands:\n'
diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py
index d42fe61..28ed757 100644
--- a/webapi/api/endpoints/v1/auths.py
+++ b/webapi/api/endpoints/v1/auths.py
@@ -42,7 +42,7 @@ def signup(payload: UserCreate, session: Session = Depends(get_session)):
name=payload.name,
last_name=payload.last_name,
email=payload.email,
- hashed_password=sha256_crypt.hash(payload.hashed_password),
+ hashed_password=sha256_crypt.hash(payload.password),
role=payload.role,
)
session.add(user)
diff --git a/webapi/schemas/user_schema.py b/webapi/schemas/user_schema.py
index 585fdb3..623c822 100644
--- a/webapi/schemas/user_schema.py
+++ b/webapi/schemas/user_schema.py
@@ -12,7 +12,7 @@ class UserCreate(BaseModel):
"name": "user",
"last_name": "testing",
"email": "user@example.com",
- "hashed_password": "usertest",
+ "password": "usertest",
"role": "user",
}
},
@@ -22,7 +22,7 @@ class UserCreate(BaseModel):
name: str = Field(max_length=100)
last_name: str = Field(max_length=100)
email: EmailStr = Field(max_length=100)
- hashed_password: str = Field(max_length=255)
+ password: str = Field(max_length=255)
role: str = "user"
diff --git a/webapi/tests/conftest.py b/webapi/tests/conftest.py
index a4263d1..ba435fe 100644
--- a/webapi/tests/conftest.py
+++ b/webapi/tests/conftest.py
@@ -77,7 +77,7 @@ def user_payload():
"name": "Py",
"last_name": "Tester",
"email": "pytest_user@example.com",
- "hashed_password": "pytest_password",
+ "password": "pytest_password",
}
diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py
index 8b98b20..ff49b7b 100644
--- a/webapi/tests/functional/test_auth_routes.py
+++ b/webapi/tests/functional/test_auth_routes.py
@@ -17,8 +17,8 @@ def test_signup_success(client, user_payload, db_session):
created = db_session.exec(select(User).where(User.username == user_payload["username"])).first()
assert created is not None
assert isinstance(created.id, int)
- assert created.hashed_password != user_payload["hashed_password"]
- assert sha256_crypt.verify(user_payload["hashed_password"], created.hashed_password)
+ assert created.hashed_password != user_payload["password"]
+ assert sha256_crypt.verify(user_payload["password"], created.hashed_password)
def test_signup_rejects_client_supplied_id(client, user_payload, db_session):
@@ -29,7 +29,16 @@ def test_signup_rejects_client_supplied_id(client, user_payload, db_session):
assert created is None
-def test_signup_openapi_schema_does_not_expose_id(client):
+def test_signup_rejects_client_supplied_hashed_password(client, user_payload, db_session):
+ payload = {**user_payload, "hashed_password": user_payload["password"]}
+ response = client.post("/api/v1/auth/signup", json=payload)
+
+ assert response.status_code == 422
+ created = db_session.exec(select(User).where(User.username == user_payload["username"])).first()
+ assert created is None
+
+
+def test_signup_openapi_schema_does_not_expose_storage_fields(client):
response = client.get("/openapi.json")
assert response.status_code == 200
@@ -41,6 +50,12 @@ def test_signup_openapi_schema_does_not_expose_id(client):
assert "id" not in user_create_schema["properties"]
assert "id" not in user_create_schema.get("required", [])
assert "id" not in example
+ assert "hashed_password" not in user_create_schema["properties"]
+ assert "hashed_password" not in user_create_schema.get("required", [])
+ assert "hashed_password" not in example
+ assert "password" in user_create_schema["properties"]
+ assert "password" in user_create_schema.get("required", [])
+ assert "password" in example
def test_signup_duplicate_username_returns_400(client, db_session):
@@ -62,7 +77,7 @@ def test_signup_duplicate_username_returns_400(client, db_session):
"name": "new",
"last_name": "user",
"email": "new_dup_user@example.com",
- "hashed_password": "password",
+ "password": "password",
},
)
@@ -85,7 +100,7 @@ def test_login_success_returns_token(client, user_payload):
response = client.post(
"/api/v1/auth/login",
- json={"username": user_payload["username"], "password": user_payload["hashed_password"]},
+ json={"username": user_payload["username"], "password": user_payload["password"]},
)
assert response.status_code == 200
diff --git a/webapi/tests/functional/test_cd.py b/webapi/tests/functional/test_cd.py
index e8587b6..551213f 100644
--- a/webapi/tests/functional/test_cd.py
+++ b/webapi/tests/functional/test_cd.py
@@ -12,7 +12,7 @@ def test_login_and_access_private(client):
"name": "Py",
"last_name": "Tester",
"email": "pytest@example.com",
- "hashed_password": "pytest",
+ "password": "pytest",
},
)
assert signup_response.status_code in (200, 400)
diff --git a/webapi/tests/nonfunctional/test_ci.py b/webapi/tests/nonfunctional/test_ci.py
index 24a6982..d236776 100644
--- a/webapi/tests/nonfunctional/test_ci.py
+++ b/webapi/tests/nonfunctional/test_ci.py
@@ -22,7 +22,7 @@ def test_register_user():
"name": "user to test",
"last_name": "my webapp",
"email": "pytestmyapp@testing.com",
- "hashed_password": TEST_PSW
+ "password": TEST_PSW
})
assert response.status_code in (200, 400)
if response.status_code == 400:
From 6a39638892dcd26cbab6be15161ef19361592543 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 22 Jun 2026 18:55:53 -0600
Subject: [PATCH 02/15] update(model): increase the lenght of prompt text
---
frontend/src/lib/validation/prompt-schemas.ts | 7 +++-
plans/dbdiagram.md | 2 +-
webapi/models/prompts.py | 13 ++++++--
webapi/schemas/prompt_schema.py | 3 +-
.../tests/functional/test_prompts_routes.py | 32 ++++++++++++++++++-
5 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/frontend/src/lib/validation/prompt-schemas.ts b/frontend/src/lib/validation/prompt-schemas.ts
index 5162ab7..e8e11cb 100644
--- a/frontend/src/lib/validation/prompt-schemas.ts
+++ b/frontend/src/lib/validation/prompt-schemas.ts
@@ -1,11 +1,16 @@
import { z } from 'zod'
+const PROMPT_TEXT_MAX_CHARS = 1_000_000
+
export const promptSchema = z.object({
model_name: z.string().min(1, 'Model name is required'),
prompt_text: z
.string()
.min(1, 'Prompt text is required')
- .max(150, 'Prompt text must be at most 150 characters'),
+ .max(
+ PROMPT_TEXT_MAX_CHARS,
+ `Prompt text must be at most ${PROMPT_TEXT_MAX_CHARS.toLocaleString()} characters`,
+ ),
category: z.string().min(1, 'Category is required'),
rate: z.coerce
.number()
diff --git a/plans/dbdiagram.md b/plans/dbdiagram.md
index 8f86753..f016b18 100644
--- a/plans/dbdiagram.md
+++ b/plans/dbdiagram.md
@@ -36,7 +36,7 @@ Table prompts {
id int [pk, increment]
user_id int [not null, ref: > user.id]
model_name varchar(30) [not null]
- prompt_text varchar(150) [not null]
+ prompt_text longtext [not null]
category varchar(30) [not null]
rate varchar(30) [not null]
diff --git a/webapi/models/prompts.py b/webapi/models/prompts.py
index 1664392..a9c3157 100644
--- a/webapi/models/prompts.py
+++ b/webapi/models/prompts.py
@@ -1,18 +1,27 @@
from typing import Optional
+from sqlalchemy import Column, Text
+from sqlalchemy.dialects.mysql import LONGTEXT
from sqlmodel import Field, Relationship, SQLModel
+PROMPT_TEXT_MAX_CHARS = 1_000_000
+PROMPT_TEXT_SQL_TYPE = (
+ Text()
+ .with_variant(LONGTEXT(), "mysql")
+ .with_variant(LONGTEXT(), "mariadb")
+)
+
class Prompts(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", nullable=False)
model_name: str = Field(max_length=30, nullable=False)
- prompt_text: str = Field(max_length=150, nullable=False)
+ prompt_text: str = Field(sa_column=Column(PROMPT_TEXT_SQL_TYPE, nullable=False))
category: str = Field(max_length=30, nullable=False)
rate: int = Field(nullable=False, ge=1, le=5)
user: Optional["User"] = Relationship(
sa_relationship_kwargs={"lazy": "selectin"},
- back_populates="prompts"
+ back_populates="prompts",
)
diff --git a/webapi/schemas/prompt_schema.py b/webapi/schemas/prompt_schema.py
index 2b7899c..7166d54 100644
--- a/webapi/schemas/prompt_schema.py
+++ b/webapi/schemas/prompt_schema.py
@@ -1,12 +1,13 @@
from typing import Optional
+from models.prompts import PROMPT_TEXT_MAX_CHARS
from pydantic import BaseModel, Field
class PromptCreate(BaseModel):
user_id: Optional[int] = None
model_name: str
- prompt_text: str = Field(max_length=150)
+ prompt_text: str = Field(min_length=1, max_length=PROMPT_TEXT_MAX_CHARS)
category: str
rate: int = Field(ge=1, le=5)
diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py
index f47cf9c..a114af3 100644
--- a/webapi/tests/functional/test_prompts_routes.py
+++ b/webapi/tests/functional/test_prompts_routes.py
@@ -1,6 +1,6 @@
import api.endpoints.v1.prompts as prompts_module
from auth.auth_service import crear_jwt
-from models.prompts import Prompts
+from models.prompts import PROMPT_TEXT_MAX_CHARS, Prompts
from models.user import User
@@ -54,6 +54,36 @@ def test_create_prompt_success(client, auth_header, created_user):
assert response.json()["model_name"] == "gpt-4.1"
+def test_create_prompt_accepts_prompt_text_longer_than_150_chars(client, auth_header, created_user):
+ prompt_text = "x" * 151
+ payload = {
+ "user_id": created_user.id,
+ "model_name": "gpt-4.1",
+ "prompt_text": prompt_text,
+ "category": "qa",
+ "rate": 5,
+ }
+
+ response = client.post("/api/v1/prompts", json=payload, headers=auth_header)
+
+ assert response.status_code == 200
+ assert response.json()["prompt_text"] == prompt_text
+
+
+def test_create_prompt_rejects_prompt_text_above_max_chars(client, auth_header, created_user):
+ payload = {
+ "user_id": created_user.id,
+ "model_name": "gpt-4.1",
+ "prompt_text": "x" * (PROMPT_TEXT_MAX_CHARS + 1),
+ "category": "qa",
+ "rate": 5,
+ }
+
+ response = client.post("/api/v1/prompts", json=payload, headers=auth_header)
+
+ assert response.status_code == 422
+
+
def test_create_prompt_unauthorized_returns_401(client, created_user):
payload = {
"user_id": created_user.id,
From c52ea20a09e864066bffd45865e7ee5dd38275b5 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 22 Jun 2026 19:44:37 -0600
Subject: [PATCH 03/15] update(deployment): hibrid way to deploy prod or local
---
.env.example | 3 +
.gitignore | 3 +
Caddyfile.example | 3 +
docker-compose.local.yml | 16 ++++
docker-compose.prod.yml | 16 ++++
docker-compose.yml | 12 +--
scripts/run-compose-stack.sh | 173 +++++++++++++++++++++++++++++------
7 files changed, 188 insertions(+), 38 deletions(-)
create mode 100644 Caddyfile.example
create mode 100644 docker-compose.local.yml
create mode 100644 docker-compose.prod.yml
diff --git a/.env.example b/.env.example
index a44a386..0c83744 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,9 @@
# and CI/CD documentation. Replace placeholder values in real environments.
# Docker Compose host ports for local inspection.
+# Valid deployment modes are local and production. Local publishes service ports
+# directly. Production publishes only Caddy ports 80/443 and requires ./Caddyfile.
+DEPLOY_ENV=local
MARIADB_HOST_PORT=3306
REDIS_HOST_PORT=6379
diff --git a/.gitignore b/.gitignore
index fa58472..7b2e6ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,9 @@ htmlcov/
# Environment variables
.env
+# Production deployment config
+Caddyfile
+
# Local runtime logs
backend.log
frontend.log
diff --git a/Caddyfile.example b/Caddyfile.example
new file mode 100644
index 0000000..7cd10bc
--- /dev/null
+++ b/Caddyfile.example
@@ -0,0 +1,3 @@
+example.com {
+ reverse_proxy frontend:80
+}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
new file mode 100644
index 0000000..361c0ad
--- /dev/null
+++ b/docker-compose.local.yml
@@ -0,0 +1,16 @@
+services:
+ mariadb:
+ ports:
+ - "${MARIADB_HOST_PORT:-3306}:3306"
+
+ redis:
+ ports:
+ - "${REDIS_HOST_PORT:-6379}:6379"
+
+ backend:
+ ports:
+ - "8000:8000"
+
+ frontend:
+ ports:
+ - "80:80"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..90ceacf
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,16 @@
+services:
+ caddy:
+ image: caddy:2
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - caddy_data:/data
+ - caddy_config:/config
+ depends_on:
+ - frontend
+
+volumes:
+ caddy_data:
+ caddy_config:
diff --git a/docker-compose.yml b/docker-compose.yml
index 37e6add..feae478 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,8 +6,6 @@ services:
MARIADB_DATABASE: ${MARIADB_DATABASE:-crud_data}
MARIADB_USER: ${MARIADB_USER:-webapi_user}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace_with_local_database_password}
- ports:
- - "${MARIADB_HOST_PORT:-3306}:3306"
volumes:
- webapi-mariadb-data:/var/lib/mysql
healthcheck:
@@ -19,8 +17,6 @@ services:
redis:
image: redis:7-alpine
- ports:
- - "${REDIS_HOST_PORT:-6379}:6379"
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
@@ -40,8 +36,8 @@ services:
ENV_MAIL_PASSWORD: ${ENV_MAIL_PASSWORD:-replace_with_local_mail_app_password}
ENV_MAIL_FROM: ${ENV_MAIL_FROM:-test@example.com}
ENV_SECRET_KEY: ${ENV_SECRET_KEY:-replace_with_local_jwt_secret}
- ports:
- - "8000:8000"
+ expose:
+ - "8000"
depends_on:
mariadb:
condition: service_healthy
@@ -57,8 +53,8 @@ services:
frontend:
build:
context: ./frontend
- ports:
- - "80:80"
+ expose:
+ - "80"
depends_on:
backend:
condition: service_healthy
diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh
index a1bad2c..e8a260f 100755
--- a/scripts/run-compose-stack.sh
+++ b/scripts/run-compose-stack.sh
@@ -6,11 +6,14 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
TIMEOUT_SECONDS=120
BUILD_FLAG="--build"
ENV_FILE="${COMPOSE_ENV_FILE:-${REPO_ROOT}/.env}"
+COMPOSE_FILE_ARGS=()
COMPOSE_ENV_ARGS=()
COMPOSE_PROJECT_ARGS=()
CLEAN_MODE="false"
RESET_DEFAULT="false"
PROJECT_NAME=""
+CADDYFILE_MISSING="false"
+CADDYFILE_FINAL_WARNING="DEPLOY_ENV=production but ./Caddyfile was not found. The Caddy container cannot run correctly without a Caddyfile. Create ./Caddyfile and rerun the production deployment."
log() {
printf '[compose-stack] %s\n' "$*"
@@ -18,17 +21,21 @@ log() {
fail() {
printf '[compose-stack] ERROR: %s\n' "$*" >&2
+ if [[ "${CADDYFILE_MISSING:-false}" == "true" ]]; then
+ printf '[compose-stack] WARNING: %s\n' "${CADDYFILE_FINAL_WARNING}" >&2
+ fi
exit 1
}
usage() {
cat <<'USAGE'
-Usage: ./scripts/run-compose-stack.sh [--clean] [--project-name NAME] [--reset-default] [--no-build] [--timeout SECONDS]
+Usage: ./scripts/run-compose-stack.sh [--env local|production] [--clean] [--project-name NAME] [--reset-default] [--no-build] [--timeout SECONDS]
-Starts the MariaDB, Redis, backend, and frontend services with Docker Compose,
-waits for readiness, and verifies minimum connectivity.
+Starts the MariaDB, Redis, backend, frontend, and production Caddy services
+with Docker Compose, waits for readiness, and verifies minimum connectivity.
Options:
+ --env VALUE Deployment mode: local or production. Defaults to DEPLOY_ENV or local.
--clean Use an isolated Compose project and clean its containers/volumes before startup.
--project-name NAME Override Compose project name. Defaults to webapi_clean_ with --clean.
--reset-default With --clean, also remove default project containers/volumes before startup.
@@ -44,6 +51,13 @@ while [[ $# -gt 0 ]]; do
CLEAN_MODE="true"
shift
;;
+ --env)
+ [[ $# -ge 2 ]] || fail "--env requires a value"
+ [[ "$2" == "local" || "$2" == "production" ]] || fail "--env must be local or production"
+ DEPLOY_ENV="$2"
+ export DEPLOY_ENV
+ shift 2
+ ;;
--project-name)
[[ $# -ge 2 ]] || fail "--project-name requires a value"
[[ -n "$2" ]] || fail "--project-name must not be empty"
@@ -89,13 +103,23 @@ detect_compose() {
}
compose() {
- "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}" "$@"
+ "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}" "$@"
}
warn() {
printf '[compose-stack] WARNING: %s\n' "$*" >&2
}
+compose_command_prefix() {
+ printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}"
+}
+
+print_final_caddyfile_warning() {
+ if [[ "${CADDYFILE_MISSING}" == "true" ]]; then
+ warn "${CADDYFILE_FINAL_WARNING}"
+ fi
+}
+
is_set() {
local name="$1"
[[ -n "${!name+x}" && -n "${!name}" ]]
@@ -136,6 +160,32 @@ load_env_file() {
done < "${ENV_FILE}"
}
+configure_deploy_env() {
+ DEPLOY_ENV="${DEPLOY_ENV:-local}"
+
+ case "${DEPLOY_ENV}" in
+ local)
+ COMPOSE_FILE_ARGS=(-f docker-compose.yml -f docker-compose.local.yml)
+ ;;
+ production)
+ COMPOSE_FILE_ARGS=(-f docker-compose.yml -f docker-compose.prod.yml)
+ ;;
+ *)
+ fail "DEPLOY_ENV must be local or production, got: ${DEPLOY_ENV}"
+ ;;
+ esac
+
+ export DEPLOY_ENV
+}
+
+check_caddyfile_for_production() {
+ CADDYFILE_MISSING="false"
+ if [[ "${DEPLOY_ENV}" == "production" && ! -f "${REPO_ROOT}/Caddyfile" ]]; then
+ CADDYFILE_MISSING="true"
+ warn "DEPLOY_ENV=production but ${REPO_ROOT}/Caddyfile was not found. Caddy will not run correctly without it."
+ fi
+}
+
configure_project_args() {
if [[ -n "${PROJECT_NAME}" ]]; then
COMPOSE_PROJECT_ARGS=(-p "${PROJECT_NAME}")
@@ -158,7 +208,7 @@ reset_default_project() {
fi
log "Removing default Compose project containers and volumes..."
- "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" down --volumes --remove-orphans || true
+ "${COMPOSE_CMD[@]}" "${COMPOSE_FILE_ARGS[@]}" "${COMPOSE_ENV_ARGS[@]}" down --volumes --remove-orphans || true
}
clean_project_state() {
@@ -173,6 +223,7 @@ clean_project_state() {
check_required_files() {
local missing=()
local required=(
+ "${REPO_ROOT}/docker-compose.yml"
"${REPO_ROOT}/webapi/db/__init__.py"
"${REPO_ROOT}/webapi/db/db_connection.py"
"${REPO_ROOT}/webapi/db/redis_connection.py"
@@ -182,6 +233,12 @@ check_required_files() {
)
local path
+ if [[ "${DEPLOY_ENV}" == "local" ]]; then
+ required+=("${REPO_ROOT}/docker-compose.local.yml")
+ else
+ required+=("${REPO_ROOT}/docker-compose.prod.yml")
+ fi
+
for path in "${required[@]}"; do
[[ -f "${path}" ]] || missing+=("${path}")
done
@@ -352,6 +409,20 @@ docker_published_port_in_use() {
configure_ports() {
local candidate
+ if [[ "${DEPLOY_ENV}" == "production" ]]; then
+ if [[ "${CLEAN_MODE}" == "true" ]]; then
+ if docker_published_port_in_use 80; then
+ fail "Host port 80 is already used. Production Caddy publishes port 80, so stop the conflicting container before clean startup."
+ fi
+
+ if docker_published_port_in_use 443; then
+ fail "Host port 443 is already used. Production Caddy publishes port 443, so stop the conflicting container before clean startup."
+ fi
+ fi
+
+ return
+ fi
+
if [[ "${CLEAN_MODE}" == "true" ]]; then
if [[ -z "${MARIADB_HOST_PORT:-}" ]] && docker_published_port_in_use 3306; then
fail "Host port 3306 is already used. Stop the conflicting container or rerun with MARIADB_HOST_PORT=."
@@ -362,11 +433,11 @@ configure_ports() {
fi
if docker_published_port_in_use 8000; then
- fail "Host port 8000 is already used. docker-compose.yml hardcodes backend port 8000, so stop the conflicting container before clean startup."
+ fail "Host port 8000 is already used. docker-compose.local.yml publishes backend port 8000, so stop the conflicting container before clean startup."
fi
if docker_published_port_in_use 80; then
- fail "Host port 80 is already used. docker-compose.yml hardcodes frontend port 80, so stop the conflicting container before clean startup."
+ fail "Host port 80 is already used. docker-compose.local.yml publishes frontend port 80, so stop the conflicting container before clean startup."
fi
return
@@ -386,6 +457,10 @@ configure_ports() {
}
start_stack() {
+ local compose_prefix
+
+ compose_prefix="$(compose_command_prefix)"
+
if [[ -n "${BUILD_FLAG}" ]]; then
log "Starting stack with image builds..."
if compose up "${BUILD_FLAG}" -d; then
@@ -404,36 +479,44 @@ start_stack() {
printf '\nIf legacy docker-compose hit the ContainerConfig recreate bug, remove only containers for this project and retry:\n' >&2
printf " docker ps -a --format '{{.Names}}' | grep '%s' | xargs -r docker rm -f\n" "${PROJECT_NAME}" >&2
if [[ -n "${BUILD_FLAG}" ]]; then
- printf " %s -p '%s' up %s -d\n" "${COMPOSE_CMD[*]}" "${PROJECT_NAME}" "${BUILD_FLAG}" >&2
+ printf " %s up %s -d\n" "${compose_prefix}" "${BUILD_FLAG}" >&2
else
- printf " %s -p '%s' up -d\n" "${COMPOSE_CMD[*]}" "${PROJECT_NAME}" >&2
+ printf " %s up -d\n" "${compose_prefix}" >&2
fi
- printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" >&2
+ printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "${compose_prefix}" >&2
else
- printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2
+ if [[ "${DEPLOY_ENV}" == "production" ]]; then
+ printf '\nIf port 80 or 443 is already used by an old webapi container, stop it first.\n' >&2
+ else
+ printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2
+ fi
printf 'Manual backend workflow cleanup example: docker rm -f webapi-mariadb webapi-redis webapi-backend\n' >&2
fi
if [[ -n "${BUILD_FLAG}" ]]; then
- printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up %s -d\n' "${COMPOSE_CMD[*]}" "${BUILD_FLAG}" >&2
+ printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up %s -d\n' "${compose_prefix}" "${BUILD_FLAG}" >&2
else
- printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up -d\n' "${COMPOSE_CMD[*]}" >&2
+ printf 'Redis port override example: REDIS_HOST_PORT=6380 %s up -d\n' "${compose_prefix}" >&2
fi
- printf 'Compose cleanup example: %s down\n' "${COMPOSE_CMD[*]}" >&2
- exit 1
+ printf 'Compose cleanup example: %s down\n' "${compose_prefix}" >&2
+ fail "Docker Compose failed to start the stack."
}
cd "${REPO_ROOT}"
detect_compose
load_env_file
+configure_deploy_env
configure_project_args
check_required_files
check_environment
+check_caddyfile_for_production
reset_default_project
clean_project_state
configure_ports
log "Using Compose command: ${COMPOSE_CMD[*]}"
+log "Using deployment environment: ${DEPLOY_ENV}"
+log "Using Compose files: ${COMPOSE_FILE_ARGS[*]}"
if [[ "${CLEAN_MODE}" == "true" ]]; then
log "Using isolated Compose project: ${PROJECT_NAME}"
fi
@@ -442,23 +525,53 @@ start_stack
wait_for_health mariadb "${TIMEOUT_SECONDS}"
wait_for_health redis "${TIMEOUT_SECONDS}"
wait_for_health backend "${TIMEOUT_SECONDS}"
-wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}"
-wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}"
-verify_api_proxy
+
+if [[ "${DEPLOY_ENV}" == "local" ]]; then
+ wait_for_http "frontend" "http://127.0.0.1/" "${TIMEOUT_SECONDS}"
+ wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}"
+ verify_api_proxy
+elif [[ "${CADDYFILE_MISSING}" == "true" ]]; then
+ log "Skipping Caddy HTTP verification because ./Caddyfile is missing."
+else
+ wait_for_health caddy "${TIMEOUT_SECONDS}"
+ wait_for_http "frontend through Caddy" "http://127.0.0.1/" "${TIMEOUT_SECONDS}"
+ verify_api_proxy
+fi
+
verify_database
verify_redis
-log "Stack is ready."
+if [[ "${CADDYFILE_MISSING}" == "true" ]]; then
+ log "Core services are ready; Caddy needs ./Caddyfile before production traffic can work."
+else
+ log "Stack is ready."
+fi
+
+COMPOSE_PREFIX="$(compose_command_prefix)"
+
printf '\nURLs:\n'
-printf ' Frontend: http://127.0.0.1\n'
-printf ' Backend direct: http://127.0.0.1:8000/docs\n'
-printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n'
-printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}"
-printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}"
+if [[ "${DEPLOY_ENV}" == "local" ]]; then
+ printf ' Frontend: http://127.0.0.1\n'
+ printf ' Backend direct: http://127.0.0.1:8000/docs\n'
+ printf ' Backend through nginx: http://127.0.0.1/api/v1/...\n'
+ printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}"
+ printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}"
+else
+ printf ' Frontend through Caddy: http://127.0.0.1\n'
+ printf ' Backend through Caddy: http://127.0.0.1/api/v1/...\n'
+ printf ' HTTPS through Caddy: https://\n'
+ printf ' MariaDB/Redis: internal Compose network only\n'
+fi
printf '\nUseful commands:\n'
-printf ' View services: %s ps\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
-printf ' View logs: %s logs backend frontend mariadb redis\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
-printf ' Check Redis: %s exec redis redis-cli ping\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
-printf ' Stop stack: %s down\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
-printf ' Reset DB: %s down -v\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
-printf ' Get into DB container: %s exec mariadb sh -c '\''MYSQL_PWD="$MARIADB_PASSWORD" mariadb "$MARIADB_DATABASE" -u "$MARIADB_USER"'\''\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")"
+if [[ "${DEPLOY_ENV}" == "production" ]]; then
+ printf ' View logs: %s logs caddy backend frontend mariadb redis\n' "${COMPOSE_PREFIX}"
+else
+ printf ' View logs: %s logs backend frontend mariadb redis\n' "${COMPOSE_PREFIX}"
+fi
+printf ' View services: %s ps\n' "${COMPOSE_PREFIX}"
+printf ' Check Redis: %s exec redis redis-cli ping\n' "${COMPOSE_PREFIX}"
+printf ' Stop stack: %s down\n' "${COMPOSE_PREFIX}"
+printf ' Reset DB: %s down -v\n' "${COMPOSE_PREFIX}"
+printf ' Get into DB container: %s exec mariadb sh -c '\''MYSQL_PWD="$MARIADB_PASSWORD" mariadb "$MARIADB_DATABASE" -u "$MARIADB_USER"'\''\n' "${COMPOSE_PREFIX}"
+
+print_final_caddyfile_warning
From 37070d403090238f71fcbcb997c6c8b9c15ac133 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 13 Jul 2026 11:01:29 -0600
Subject: [PATCH 04/15] feat: validate prompt model names
---
webapi/api/endpoints/v1/options.py | 16 +--------
webapi/core/prompt_options.py | 22 ++++++++++++
webapi/schemas/prompt_schema.py | 19 +++++++++--
.../tests/functional/test_options_routes.py | 2 ++
.../tests/functional/test_prompts_routes.py | 34 +++++++++++++++++++
5 files changed, 76 insertions(+), 17 deletions(-)
create mode 100644 webapi/core/prompt_options.py
diff --git a/webapi/api/endpoints/v1/options.py b/webapi/api/endpoints/v1/options.py
index 54edd70..ac5234a 100644
--- a/webapi/api/endpoints/v1/options.py
+++ b/webapi/api/endpoints/v1/options.py
@@ -1,26 +1,12 @@
from fastapi import APIRouter, Depends
from auth.auth_service import get_current_db_user
+from core.prompt_options import CATEGORY_OPTIONS, MODEL_OPTIONS
from models.user import User
router = APIRouter()
-CATEGORY_OPTIONS = [
- {"value": "qa", "label": "QA"},
- {"value": "dev", "label": "Development"},
- {"value": "ops", "label": "Operations"},
- {"value": "writing", "label": "Writing"},
- {"value": "research", "label": "Research"},
-]
-
-MODEL_OPTIONS = [
- {"value": "gpt-4.1", "label": "GPT-4.1"},
- {"value": "gpt-4o-mini", "label": "GPT-4o mini"},
- {"value": "gpt-5", "label": "GPT-5"},
- {"value": "gpt-5-mini", "label": "GPT-5 mini"},
-]
-
@router.get("/categories")
def read_categories(_current_user: User = Depends(get_current_db_user)):
diff --git a/webapi/core/prompt_options.py b/webapi/core/prompt_options.py
new file mode 100644
index 0000000..897cc3b
--- /dev/null
+++ b/webapi/core/prompt_options.py
@@ -0,0 +1,22 @@
+MODEL_NAME_MAX_CHARS = 30
+
+CATEGORY_OPTIONS = [
+ {"value": "qa", "label": "QA"},
+ {"value": "dev", "label": "Development"},
+ {"value": "ops", "label": "Operations"},
+ {"value": "writing", "label": "Writing"},
+ {"value": "research", "label": "Research"},
+]
+
+MODEL_OPTIONS = [
+ {"value": "gpt-4.1", "label": "GPT-4.1"},
+ {"value": "gpt-4o-mini", "label": "GPT-4o mini"},
+ {"value": "gpt-5", "label": "GPT-5"},
+ {"value": "gpt-5-mini", "label": "GPT-5 mini"},
+]
+
+MODEL_OPTION_VALUES = frozenset(option["value"] for option in MODEL_OPTIONS)
+
+
+def is_valid_model_name(value: str) -> bool:
+ return value in MODEL_OPTION_VALUES
diff --git a/webapi/schemas/prompt_schema.py b/webapi/schemas/prompt_schema.py
index 7166d54..84f8fe6 100644
--- a/webapi/schemas/prompt_schema.py
+++ b/webapi/schemas/prompt_schema.py
@@ -1,16 +1,31 @@
from typing import Optional
+from core.prompt_options import MODEL_NAME_MAX_CHARS, is_valid_model_name
from models.prompts import PROMPT_TEXT_MAX_CHARS
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
class PromptCreate(BaseModel):
user_id: Optional[int] = None
- model_name: str
+ model_name: str = Field(min_length=1, max_length=MODEL_NAME_MAX_CHARS)
prompt_text: str = Field(min_length=1, max_length=PROMPT_TEXT_MAX_CHARS)
category: str
rate: int = Field(ge=1, le=5)
+ @field_validator("model_name", mode="before")
+ @classmethod
+ def strip_model_name(cls, value: str) -> str:
+ if isinstance(value, str):
+ return value.strip()
+ return value
+
+ @field_validator("model_name")
+ @classmethod
+ def validate_model_name(cls, value: str) -> str:
+ if not is_valid_model_name(value):
+ raise ValueError("Unknown model_name")
+ return value
+
class PromptRead(BaseModel):
id: int
diff --git a/webapi/tests/functional/test_options_routes.py b/webapi/tests/functional/test_options_routes.py
index d6f5e58..f8137f8 100644
--- a/webapi/tests/functional/test_options_routes.py
+++ b/webapi/tests/functional/test_options_routes.py
@@ -1,4 +1,5 @@
from api.endpoints.v1.options import CATEGORY_OPTIONS, MODEL_OPTIONS
+from core.prompt_options import MODEL_OPTION_VALUES
def test_read_category_options(client, auth_header):
@@ -53,3 +54,4 @@ def test_prompt_options_align_with_known_backend_values(client, auth_header):
assert {item["value"] for item in model_response.json()["items"]} == {
item["value"] for item in MODEL_OPTIONS
}
+ assert {item["value"] for item in model_response.json()["items"]} == MODEL_OPTION_VALUES
diff --git a/webapi/tests/functional/test_prompts_routes.py b/webapi/tests/functional/test_prompts_routes.py
index a114af3..2acd15a 100644
--- a/webapi/tests/functional/test_prompts_routes.py
+++ b/webapi/tests/functional/test_prompts_routes.py
@@ -2,6 +2,7 @@
from auth.auth_service import crear_jwt
from models.prompts import PROMPT_TEXT_MAX_CHARS, Prompts
from models.user import User
+from sqlmodel import select
def auth_headers_for(user: User) -> dict[str, str]:
@@ -84,6 +85,22 @@ def test_create_prompt_rejects_prompt_text_above_max_chars(client, auth_header,
assert response.status_code == 422
+def test_create_prompt_rejects_unknown_model_name(client, auth_header, created_user, db_session):
+ payload = {
+ "user_id": created_user.id,
+ "model_name": "unknown-model",
+ "prompt_text": "Unknown model should not persist",
+ "category": "qa",
+ "rate": 5,
+ }
+
+ response = client.post("/api/v1/prompts", json=payload, headers=auth_header)
+
+ assert response.status_code == 422
+ prompts = db_session.exec(select(Prompts)).all()
+ assert prompts == []
+
+
def test_create_prompt_unauthorized_returns_401(client, created_user):
payload = {
"user_id": created_user.id,
@@ -265,6 +282,23 @@ def test_update_prompt_success(client, auth_header, created_prompt, created_user
assert response.json()["prompt_text"] == "Updated prompt"
+def test_update_prompt_rejects_unknown_model_name(client, auth_header, created_prompt, created_user, db_session):
+ payload = {
+ "user_id": created_user.id,
+ "model_name": "unknown-model",
+ "prompt_text": "Unknown model should not update",
+ "category": "dev",
+ "rate": 3,
+ }
+
+ response = client.put(f"/api/v1/prompts/{created_prompt.id}", json=payload, headers=auth_header)
+
+ assert response.status_code == 422
+ db_session.refresh(created_prompt)
+ assert created_prompt.model_name == "gpt-4.1"
+ assert created_prompt.prompt_text == "existing prompt"
+
+
def test_update_prompt_missing_returns_404(client, auth_header, created_user):
payload = {
"user_id": created_user.id,
From 67b4ac0eb9720fec10a1ca7c599174df58284c60 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 13 Jul 2026 11:01:34 -0600
Subject: [PATCH 05/15] feat: add prompt model combobox
---
.../src/components/prompts/prompt-form.tsx | 15 +-
frontend/src/components/ui/combobox-input.tsx | 149 ++++++++++++++++++
.../src/lib/validation/prompt-schemas.test.ts | 29 ++++
frontend/src/lib/validation/prompt-schemas.ts | 2 +-
frontend/src/styles/base.css | 41 +++++
5 files changed, 232 insertions(+), 4 deletions(-)
create mode 100644 frontend/src/components/ui/combobox-input.tsx
create mode 100644 frontend/src/lib/validation/prompt-schemas.test.ts
diff --git a/frontend/src/components/prompts/prompt-form.tsx b/frontend/src/components/prompts/prompt-form.tsx
index 79aeab1..e37b666 100644
--- a/frontend/src/components/prompts/prompt-form.tsx
+++ b/frontend/src/components/prompts/prompt-form.tsx
@@ -3,6 +3,7 @@ import type { SelectOption } from '../../features/options/options-types'
import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-types'
import { promptSchema } from '../../lib/validation/prompt-schemas'
import { Button } from '../ui/button'
+import { ComboboxInput } from '../ui/combobox-input'
import { Input } from '../ui/input'
import { RatingInput } from '../ui/rating-input'
import { Select } from '../ui/select'
@@ -59,6 +60,14 @@ export function PromptForm({
return
}
+ const selectedModel = modelOptions.find((option) => option.value === parsed.data.model_name)
+ if (!selectedModel) {
+ const message = 'Select a known model from the list.'
+ setFieldErrors({ model_name: message })
+ setError(message)
+ return
+ }
+
setError(null)
setFieldErrors({})
await onSubmit(parsed.data)
@@ -69,12 +78,12 @@ export function PromptForm({
return (
: null}
-
+ <>
+
+ {prompts.map((prompt) => {
+ const summary = summarizePromptText(prompt.prompt_text)
+
+ return (
+ setSelectedPrompt(prompt)}
+ >
+ {summary}
+
+ {prompt.category}
+ Rating {prompt.rate}/5
+
+ View prompt details
+
+ )
+ })}
+
+ setSelectedPrompt(null)}
+ onEdit={onEdit}
+ onDelete={onDelete}
+ />
+ >
)
}
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index 751c8a6..4b6b452 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -313,6 +313,89 @@ h2 {
gap: var(--space-4);
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.prompt-list {
+ gap: var(--space-2);
+}
+
+.prompt-list-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: var(--space-3);
+ color: inherit;
+ background: var(--surface);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color var(--transition-fast),
+ background-color var(--transition-fast),
+ box-shadow var(--transition-fast);
+}
+
+.prompt-list-item:hover,
+.prompt-list-item:focus-visible {
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
+ background: color-mix(in srgb, var(--accent) 6%, var(--surface));
+}
+
+.prompt-list-item:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
+ outline-offset: 2px;
+}
+
+.prompt-list-summary {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.prompt-list-meta,
+.prompt-detail-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.prompt-detail-dialog {
+ width: min(640px, 100%);
+}
+
+.prompt-detail-body {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.prompt-detail-field {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.prompt-detail-text {
+ max-height: min(46vh, 28rem);
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
.section-heading {
display: flex;
justify-content: space-between;
@@ -502,6 +585,15 @@ h2 {
flex-direction: column;
}
+ .prompt-list-item {
+ grid-template-columns: 1fr;
+ align-items: flex-start;
+ }
+
+ .prompt-list-summary {
+ white-space: normal;
+ }
+
.topbar {
flex-direction: column;
align-items: flex-start;
From 65cf55204788e79b1612876ce4af9779f207b16e Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 13 Jul 2026 13:00:19 -0600
Subject: [PATCH 09/15] fix(auth): force public signup to create default users
---
webapi/api/endpoints/v1/auths.py | 4 +---
webapi/models/user.py | 7 ++++++-
webapi/schemas/user_schema.py | 2 --
webapi/tests/functional/test_auth_routes.py | 13 +++++++++----
4 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/webapi/api/endpoints/v1/auths.py b/webapi/api/endpoints/v1/auths.py
index 28ed757..99b41d2 100644
--- a/webapi/api/endpoints/v1/auths.py
+++ b/webapi/api/endpoints/v1/auths.py
@@ -35,15 +35,13 @@ def signup(payload: UserCreate, session: Session = Depends(get_session)):
user_exists = result.one_or_none()
if user_exists:
raise HTTPException(status_code=400, detail="username already taken")
- if payload.role not in {"user", "admin", "god"}:
- raise HTTPException(status_code=400, detail="invalid role")
user = User(
username=payload.username,
name=payload.name,
last_name=payload.last_name,
email=payload.email,
hashed_password=sha256_crypt.hash(payload.password),
- role=payload.role,
+ role="user",
)
session.add(user)
session.commit()
diff --git a/webapi/models/user.py b/webapi/models/user.py
index 1a3eb5d..52974b3 100644
--- a/webapi/models/user.py
+++ b/webapi/models/user.py
@@ -11,7 +11,12 @@ class User(SQLModel, table=True):
last_name: str = Field(max_length=100, index=True, nullable=False)
email: EmailStr = Field(max_length=100, nullable=False)
hashed_password: str = Field(max_length=255) # Longer for bcrypt hashes
- role: str = Field(default="user", max_length=20, nullable=False)
+ role: str = Field(
+ default="user",
+ max_length=20,
+ nullable=False,
+ sa_column_kwargs={"server_default": "user"},
+ )
prompts: List["Prompts"] = Relationship(
sa_relationship_kwargs={"lazy": "selectin"},
diff --git a/webapi/schemas/user_schema.py b/webapi/schemas/user_schema.py
index 623c822..a940b3f 100644
--- a/webapi/schemas/user_schema.py
+++ b/webapi/schemas/user_schema.py
@@ -13,7 +13,6 @@ class UserCreate(BaseModel):
"last_name": "testing",
"email": "user@example.com",
"password": "usertest",
- "role": "user",
}
},
)
@@ -23,7 +22,6 @@ class UserCreate(BaseModel):
last_name: str = Field(max_length=100)
email: EmailStr = Field(max_length=100)
password: str = Field(max_length=255)
- role: str = "user"
class UserRead(BaseModel):
diff --git a/webapi/tests/functional/test_auth_routes.py b/webapi/tests/functional/test_auth_routes.py
index ff49b7b..8e89f76 100644
--- a/webapi/tests/functional/test_auth_routes.py
+++ b/webapi/tests/functional/test_auth_routes.py
@@ -17,6 +17,7 @@ def test_signup_success(client, user_payload, db_session):
created = db_session.exec(select(User).where(User.username == user_payload["username"])).first()
assert created is not None
assert isinstance(created.id, int)
+ assert created.role == "user"
assert created.hashed_password != user_payload["password"]
assert sha256_crypt.verify(user_payload["password"], created.hashed_password)
@@ -56,6 +57,9 @@ def test_signup_openapi_schema_does_not_expose_storage_fields(client):
assert "password" in user_create_schema["properties"]
assert "password" in user_create_schema.get("required", [])
assert "password" in example
+ assert "role" not in user_create_schema["properties"]
+ assert "role" not in user_create_schema.get("required", [])
+ assert "role" not in example
def test_signup_duplicate_username_returns_400(client, db_session):
@@ -85,14 +89,15 @@ def test_signup_duplicate_username_returns_400(client, db_session):
assert response.json()["detail"] == "username already taken"
-def test_signup_invalid_role_returns_400(client, user_payload):
+def test_signup_rejects_client_supplied_role(client, user_payload, db_session):
response = client.post(
"/api/v1/auth/signup",
- json={**user_payload, "role": "superuser"},
+ json={**user_payload, "role": "god"},
)
- assert response.status_code == 400
- assert response.json()["detail"] == "invalid role"
+ assert response.status_code == 422
+ created = db_session.exec(select(User).where(User.username == user_payload["username"])).first()
+ assert created is None
def test_login_success_returns_token(client, user_payload):
From 5f78f57d98806ab7b0564111bfea270559ccc794 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 13 Jul 2026 13:00:31 -0600
Subject: [PATCH 10/15] fix(auth): omit role from frontend registration
---
frontend/src/features/auth/auth-types.ts | 1 -
frontend/src/pages/register/register-page.tsx | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts
index 07d7388..a64fd2d 100644
--- a/frontend/src/features/auth/auth-types.ts
+++ b/frontend/src/features/auth/auth-types.ts
@@ -14,7 +14,6 @@ export type RegisterInput = {
name: string
last_name: string
email: string
- role: 'user'
}
export type UserRole = 'user' | 'admin' | 'god'
diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx
index bf789c9..46ae9ad 100644
--- a/frontend/src/pages/register/register-page.tsx
+++ b/frontend/src/pages/register/register-page.tsx
@@ -29,7 +29,7 @@ export function RegisterPage() {
try {
setLoading(true)
setError(null)
- await signup({ ...parsed.data, role: 'user' })
+ await signup(parsed.data)
navigate('/login?registered=1', { replace: true })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to create account'
From 8726ff735d5bfff5ab8b03b077c5089294b03e67 Mon Sep 17 00:00:00 2001
From: luisfponce
Date: Mon, 13 Jul 2026 13:00:37 -0600
Subject: [PATCH 11/15] feat(auth): notify registered users of default role
---
frontend/src/pages/login/login-page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/pages/login/login-page.tsx b/frontend/src/pages/login/login-page.tsx
index 190bbf2..aaf982f 100644
--- a/frontend/src/pages/login/login-page.tsx
+++ b/frontend/src/pages/login/login-page.tsx
@@ -44,7 +44,7 @@ export function LoginPage() {
Use your API account credentials.