feat: Added MinIO Docker service, Post/PostAttachment models with migra…
- "docker-compose.yml" - "backend/config.py" - "backend/minio_client.py" - "backend/models.py" - "backend/schemas.py" - "backend/requirements.txt" - "docker/nginx.conf" - "alembic/versions/024_add_posts_and_attachments.py" GSD-Task: S01/T01
This commit is contained in:
parent
758bf7ecea
commit
f0f36a3f76
20 changed files with 16631 additions and 23 deletions
|
|
@ -47,3 +47,4 @@
|
|||
| D039 | | architecture | LightRAG vs Qdrant search execution strategy | Sequential with fallback — LightRAG first, Qdrant only on LightRAG failure/empty, not parallel | Running both in parallel would double latency overhead. LightRAG is the primary engine; Qdrant is a safety net. Sequential approach reduces load and simplifies result merging. | Yes | agent |
|
||||
| D040 | M021/S02 | architecture | Creator-scoped retrieval cascade strategy | Sequential 4-tier cascade (creator → domain → global → none) with ll_keywords scoping and post-filtering | Sequential cascade is simpler than parallel-with-priority and avoids wasted LightRAG calls when early tiers succeed. ll_keywords hints LightRAG's retrieval without hard constraints. Post-filtering on tier 1 ensures strict creator scoping while 3x oversampling compensates for filtering losses. Domain tier uses ≥2 page threshold to avoid noise from sparse creators. | Yes | agent |
|
||||
| D041 | M022/S05 | architecture | Highlight scorer weight distribution for 10-dimension model | Original 7 dimensions reduced proportionally, new 3 audio proxy dimensions (speech_rate_variance, pause_density, speaking_pace) allocated 0.22 total weight. Audio dims default to 0.5 (neutral) when word_timings unavailable for backward compatibility. | Audio proxy signals derived from word-level timing data provide meaningful highlight quality indicators without requiring raw audio analysis (librosa). Neutral fallback ensures existing scoring paths are unaffected. | Yes | agent |
|
||||
| D042 | M023/S01 | architecture | Rich text editor for creator posts | Tiptap (headless, React) with StarterKit + Link + Placeholder extensions. Store Tiptap JSON as canonical format in JSONB column, render client-side via @tiptap/html. | Headless architecture fits dark theme customization. Large ecosystem, well-maintained. JSON storage is lossless and enables future server-side rendering. No HTML sanitization needed since canonical format is structured JSON. | Yes | agent |
|
||||
|
|
|
|||
1
.gsd/completed-units-M022.json
Normal file
1
.gsd/completed-units-M022.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -1,6 +1,264 @@
|
|||
# S01: [A] Post Editor + File Sharing
|
||||
|
||||
**Goal:** Build post editor with MinIO file storage, drag-drop upload, visibility toggles, and follower feed
|
||||
**Goal:** Creator writes rich text posts with file attachments (presets, sample packs). Followers see posts in feed on creator profile. Files downloadable via signed URLs from MinIO.
|
||||
**Demo:** After this: Creator writes rich text posts with file attachments (presets, sample packs). Followers see posts in feed. Files downloadable via signed URLs.
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added MinIO Docker service, Post/PostAttachment models with migration 024, Pydantic schemas, and MinIO client helper module** — ## Description
|
||||
|
||||
Stand up the MinIO Docker service and build the data layer for posts and file attachments. This is the foundation everything else depends on.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add MinIO service to `docker-compose.yml`:
|
||||
- Image: `minio/minio`
|
||||
- Command: `server /data --console-address ":9001"`
|
||||
- Container name: `chrysopedia-minio`
|
||||
- Volume: `/vmPool/r/services/chrysopedia_minio:/data`
|
||||
- Environment: `MINIO_ROOT_USER=${MINIO_ROOT_USER:-chrysopedia}`, `MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-changeme-minio}`
|
||||
- Network: `chrysopedia`
|
||||
- Healthcheck: `curl -sf http://localhost:9000/minio/health/live`
|
||||
- NO public port exposure (internal only on chrysopedia network)
|
||||
|
||||
2. Add MinIO settings to `backend/config.py`:
|
||||
- `minio_url: str = "chrysopedia-minio:9000"`
|
||||
- `minio_access_key: str = "chrysopedia"`
|
||||
- `minio_secret_key: str = "changeme-minio"`
|
||||
- `minio_bucket: str = "chrysopedia"`
|
||||
- `minio_secure: bool = False`
|
||||
|
||||
3. Create `backend/minio_client.py`:
|
||||
- Lazy-init singleton `Minio` client from settings
|
||||
- `get_minio_client()` function
|
||||
- `ensure_bucket()` function that creates the bucket if it doesn't exist (call on first use)
|
||||
- `generate_download_url(object_key, expires=3600)` → presigned GET URL
|
||||
- `upload_file(object_key, data, length, content_type)` → wraps `put_object`
|
||||
|
||||
4. Add `minio` to `backend/requirements.txt`
|
||||
|
||||
5. Add Post and PostAttachment models to `backend/models.py`:
|
||||
- `Post`: id (UUID PK), creator_id (FK to creators.id), title (String), body_json (JSONB), is_published (Boolean default False), created_at, updated_at
|
||||
- `PostAttachment`: id (UUID PK), post_id (FK to posts.id, cascade delete), filename (String, original name), object_key (String, MinIO path), content_type (String), size_bytes (BigInteger), created_at
|
||||
- Add `posts` relationship on Creator model
|
||||
|
||||
6. Create Alembic migration `024_add_posts_and_attachments.py`:
|
||||
- Create `posts` table
|
||||
- Create `post_attachments` table
|
||||
- Foreign keys with ON DELETE CASCADE
|
||||
|
||||
7. Add Pydantic schemas to `backend/schemas.py`:
|
||||
- `PostAttachmentRead`: id, filename, content_type, size_bytes, download_url (optional str), created_at
|
||||
- `PostCreate`: title, body_json (dict), is_published (bool)
|
||||
- `PostUpdate`: title (optional), body_json (optional dict), is_published (optional bool)
|
||||
- `PostRead`: id, creator_id, title, body_json, is_published, created_at, updated_at, attachments (list[PostAttachmentRead])
|
||||
- `PostListResponse`: items (list[PostRead]), total (int)
|
||||
|
||||
8. Bump `client_max_body_size` in `docker/nginx.conf` from 50m to 100m
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] MinIO service in docker-compose.yml with healthcheck, volume, no public port
|
||||
- [ ] MinIO config fields in Settings class
|
||||
- [ ] minio_client.py with lazy init, ensure_bucket, upload, presigned URL generation
|
||||
- [ ] Post and PostAttachment models with correct FKs and cascade
|
||||
- [ ] Alembic migration 024 creates both tables
|
||||
- [ ] Pydantic schemas for post CRUD
|
||||
- [ ] nginx client_max_body_size bumped to 100m
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| MinIO container | Log connection error, raise 503 from endpoints | Same — 503 with timeout detail | N/A (SDK handles protocol) |
|
||||
| PostgreSQL (migration) | Alembic raises, migration rolls back | Connection timeout → retry | N/A |
|
||||
|
||||
## Verification
|
||||
|
||||
- `docker compose config` validates compose file
|
||||
- MinIO container starts and passes healthcheck
|
||||
- `python -c "from models import Post, PostAttachment; print('ok')"` imports cleanly
|
||||
- `alembic upgrade head` applies migration 024 without error
|
||||
- `python -c "from schemas import PostCreate, PostRead, PostAttachmentRead; print('ok')"` imports cleanly
|
||||
- `python -c "from minio_client import get_minio_client; print('ok')"` imports cleanly
|
||||
- Estimate: 45m
|
||||
- Files: docker-compose.yml, backend/config.py, backend/minio_client.py, backend/models.py, backend/schemas.py, backend/requirements.txt, docker/nginx.conf, alembic/versions/024_add_posts_and_attachments.py
|
||||
- Verify: docker compose config --quiet && python -c 'from models import Post, PostAttachment; from schemas import PostCreate, PostRead; from minio_client import get_minio_client; print("all imports ok")'
|
||||
- [ ] **T02: Post CRUD router and file upload/download router with API registration** — ## Description
|
||||
|
||||
Build both API routers: posts CRUD (create, list, get, update, delete) and file upload/download (proxy upload to MinIO, signed URL generation). Register both in main.py.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `backend/routers/posts.py`:
|
||||
- `POST /posts` — create post. Requires auth (`get_current_user`). Creator resolved from `current_user.creator_id`. Body: `PostCreate` schema. Returns `PostRead`. Set creator_id from auth user's linked creator.
|
||||
- `GET /posts` — list posts. Public. Query params: `creator_id` (required int), `page` (default 1), `limit` (default 20). Filter `is_published=True` for non-owner requests (if no auth or different creator). Returns `PostListResponse`. Use `selectinload(Post.attachments)` for eager loading.
|
||||
- `GET /posts/{post_id}` — get single post. Public for published posts. Returns `PostRead` with attachments. Generate download URLs for each attachment using `generate_download_url()`.
|
||||
- `PUT /posts/{post_id}` — update post. Auth required. Ownership check: post.creator_id must match user's creator_id. Body: `PostUpdate`. Returns `PostRead`.
|
||||
- `DELETE /posts/{post_id}` — delete post. Auth required. Ownership check. Also delete attachment objects from MinIO. Returns 204.
|
||||
|
||||
2. Create `backend/routers/files.py`:
|
||||
- `POST /files/upload` — upload file. Auth required. Accept `UploadFile` + `post_id` (form field). Validate post exists and user owns it. Generate object_key: `posts/{creator_slug}/{post_id}/{sanitized_filename}`. Use `run_in_executor` for MinIO `put_object` (sync I/O in async handler). Create PostAttachment record. Return `PostAttachmentRead`.
|
||||
- `GET /files/{attachment_id}/download` — generate signed URL. Public. Look up PostAttachment, generate presigned GET URL (1 hour), return JSON with `url` field (not redirect, so frontend controls UX).
|
||||
- Filename sanitization: strip path separators, limit length, preserve extension.
|
||||
|
||||
3. Register both routers in `backend/main.py`:
|
||||
- `from routers import posts, files`
|
||||
- `app.include_router(posts.router, prefix="/api/v1")`
|
||||
- `app.include_router(files.router, prefix="/api/v1")`
|
||||
|
||||
4. Wire MinIO bucket initialization into app startup in `backend/main.py`:
|
||||
- In the existing `lifespan` or startup event, call `ensure_bucket()` from minio_client.py (wrapped in try/except so API still starts if MinIO is temporarily down)
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Post CRUD endpoints with auth and ownership enforcement
|
||||
- [ ] File upload proxied through API to MinIO with PostAttachment record creation
|
||||
- [ ] Signed download URL generation (1-hour expiry)
|
||||
- [ ] Both routers registered in main.py
|
||||
- [ ] MinIO bucket auto-creation on startup
|
||||
- [ ] `selectinload` for attachments on post queries
|
||||
- [ ] `run_in_executor` for sync MinIO I/O in async handlers
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| MinIO (upload) | 503 with "File storage unavailable" | 504 gateway timeout | N/A |
|
||||
| MinIO (download URL) | 503 | N/A (presigned URL is local computation) | N/A |
|
||||
| DB (post lookup) | 500 | 500 | N/A |
|
||||
| Auth (missing token) | 401 Unauthorized | N/A | 401 |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: Upload with no file, create post with empty title, update post that doesn't exist
|
||||
- **Error paths**: Upload to non-owned post returns 403, delete non-owned post returns 403, get non-existent post returns 404
|
||||
- **Boundary conditions**: List posts with no results returns empty list, upload file with path traversal in filename gets sanitized
|
||||
|
||||
## Verification
|
||||
|
||||
- API starts without errors: `docker logs chrysopedia-api 2>&1 | tail -5` shows no import/startup errors
|
||||
- Post CRUD works: create post via curl with auth token, list posts, get single post, update, delete
|
||||
- File upload works: upload a test file via curl multipart, verify attachment in response
|
||||
- Download URL works: GET download endpoint returns signed URL
|
||||
|
||||
## Observability Impact
|
||||
|
||||
- Signals added: File upload errors logged with MinIO error message and object_key. Post ownership violations logged at WARNING.
|
||||
- How a future agent inspects: `docker logs chrysopedia-api | grep -i minio` for storage issues. DB query on posts/post_attachments tables.
|
||||
- Failure state exposed: 503 status with detail message for MinIO failures, 403 for ownership violations, 404 for missing resources.
|
||||
- Estimate: 1h
|
||||
- Files: backend/routers/posts.py, backend/routers/files.py, backend/main.py
|
||||
- Verify: docker exec chrysopedia-api python -c 'from routers.posts import router; from routers.files import router; print("routers ok")' && curl -sf http://ub01:8096/api/v1/posts?creator_id=1 | python3 -m json.tool
|
||||
- [ ] **T03: Tiptap post editor page with file attachment upload** — ## Description
|
||||
|
||||
Build the creator-facing post editor with Tiptap rich text editing and file attachment upload. This is the write path — creators use this to compose and publish posts with downloadable files.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install Tiptap dependencies:
|
||||
- `cd frontend && npm install @tiptap/react @tiptap/starter-kit @tiptap/pm @tiptap/extension-link @tiptap/extension-placeholder`
|
||||
|
||||
2. Add multipart upload helper to `frontend/src/api/client.ts`:
|
||||
- `requestMultipart<T>(url, formData, init?)` — same as `request()` but does NOT set Content-Type header (browser sets multipart boundary). Still attaches auth token.
|
||||
|
||||
3. Create `frontend/src/api/posts.ts`:
|
||||
- `createPost(data: PostCreate): Promise<PostRead>` — POST /api/v1/posts
|
||||
- `updatePost(id: string, data: PostUpdate): Promise<PostRead>` — PUT /api/v1/posts/{id}
|
||||
- `getPost(id: string): Promise<PostRead>` — GET /api/v1/posts/{id}
|
||||
- `listPosts(creatorId: number, page?: number): Promise<PostListResponse>` — GET /api/v1/posts?creator_id=X
|
||||
- `uploadFile(postId: string, file: File): Promise<PostAttachmentRead>` — POST /api/v1/files/upload (multipart)
|
||||
- `getDownloadUrl(attachmentId: string): Promise<{url: string}>` — GET /api/v1/files/{id}/download
|
||||
- `deletePost(id: string): Promise<void>` — DELETE /api/v1/posts/{id}
|
||||
- TypeScript types: `PostCreate`, `PostUpdate`, `PostRead`, `PostAttachmentRead`, `PostListResponse`
|
||||
|
||||
4. Create `frontend/src/pages/PostEditor.tsx` + `PostEditor.module.css`:
|
||||
- Tiptap editor with StarterKit (headings, bold, italic, lists, code blocks) + Link extension + Placeholder
|
||||
- Title input field above the editor
|
||||
- Toolbar: bold, italic, heading (H2, H3), bullet list, ordered list, link, code block
|
||||
- File attachment zone below editor: drag-and-drop area or file picker button
|
||||
- File list showing attached files with remove button
|
||||
- Publish toggle (draft/published)
|
||||
- Save button that: creates post via API → uploads attached files → navigates to creator dashboard
|
||||
- If editing (post_id in URL), load existing post and populate editor
|
||||
- Styling: dark theme consistent with existing CSS variables, editor min-height ~300px
|
||||
|
||||
5. Add routes in `frontend/src/App.tsx`:
|
||||
- `/creator/posts/new` → PostEditor (protected)
|
||||
- `/creator/posts/:postId/edit` → PostEditor (protected, edit mode)
|
||||
|
||||
6. Add "Posts" link to `SidebarNav` in `frontend/src/pages/CreatorDashboard.tsx`:
|
||||
- Add NavLink to `/creator/posts` between existing nav items
|
||||
- Add a "New Post" button/link on the dashboard or posts list that goes to `/creator/posts/new`
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Tiptap editor renders with toolbar (bold, italic, headings, lists, link, code)
|
||||
- [ ] Title field + body editor + file attachment area
|
||||
- [ ] Files upload via multipart to /api/v1/files/upload
|
||||
- [ ] Save creates post then uploads files
|
||||
- [ ] Route registered and accessible from creator sidebar
|
||||
- [ ] Dark theme styling consistent with app
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` succeeds with no errors
|
||||
- Navigate to `/creator/posts/new` while logged in — editor renders with toolbar
|
||||
- Type text, attach a file, save — post created in API, file uploaded to MinIO
|
||||
- Estimate: 1h30m
|
||||
- Files: frontend/package.json, frontend/src/api/client.ts, frontend/src/api/posts.ts, frontend/src/pages/PostEditor.tsx, frontend/src/pages/PostEditor.module.css, frontend/src/App.tsx, frontend/src/pages/CreatorDashboard.tsx
|
||||
- Verify: cd frontend && npm run build 2>&1 | tail -5
|
||||
- [ ] **T04: Posts feed on creator profile with file download buttons** — ## Description
|
||||
|
||||
Build the public-facing read path: a PostsFeed component on the creator detail page showing published posts reverse-chronologically, with file download buttons using signed URLs. Also add a posts list page at `/creator/posts` for the creator to manage their posts.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/components/PostsFeed.tsx` + `PostsFeed.module.css`:
|
||||
- Props: `creatorId: number`
|
||||
- Fetches posts via `listPosts(creatorId)` on mount
|
||||
- Renders each post as a card: title, rich text body (render Tiptap JSON using `generateHTML` from `@tiptap/html` or render raw HTML), created_at timestamp, attachment list
|
||||
- Each attachment shows: filename, size, download button
|
||||
- Download button calls `getDownloadUrl(attachmentId)` then opens the returned URL
|
||||
- Empty state: "No posts yet" message
|
||||
- Loading state with skeleton/spinner
|
||||
- Styling: dark theme cards consistent with existing technique cards
|
||||
|
||||
2. Integrate PostsFeed into `frontend/src/pages/CreatorDetail.tsx`:
|
||||
- Add a "Posts" section below the existing technique grid
|
||||
- Section heading: "Posts" with post count
|
||||
- Render `<PostsFeed creatorId={creator.id} />`
|
||||
- Only show section if creator has posts (or always show with empty state)
|
||||
|
||||
3. Create `frontend/src/pages/PostsList.tsx` + `PostsList.module.css`:
|
||||
- Protected route at `/creator/posts`
|
||||
- Lists the current creator's posts (draft and published)
|
||||
- Each row: title, status badge (draft/published), created date, edit button, delete button
|
||||
- "New Post" button linking to `/creator/posts/new`
|
||||
- Delete with confirmation dialog
|
||||
- Uses the same `CreatorDashboard` layout shell (SidebarNav)
|
||||
|
||||
4. Add route in `frontend/src/App.tsx`:
|
||||
- `/creator/posts` → PostsList (protected)
|
||||
|
||||
5. Install `@tiptap/html` for server-side HTML generation from Tiptap JSON:
|
||||
- `npm install @tiptap/html`
|
||||
- Use `generateHTML(doc, extensions)` to render post body in feed
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] PostsFeed component renders posts with rich text body and file attachments
|
||||
- [ ] File download buttons generate and open signed URLs
|
||||
- [ ] PostsFeed integrated into CreatorDetail page
|
||||
- [ ] PostsList page for creator to manage their posts (draft/published)
|
||||
- [ ] Routes registered in App.tsx
|
||||
- [ ] Dark theme styling consistent with app
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` succeeds
|
||||
- Navigate to a creator profile that has posts — posts section visible with cards
|
||||
- Click download button — file downloads via signed URL
|
||||
- Navigate to `/creator/posts` while logged in — post management list visible
|
||||
- Estimate: 1h
|
||||
- Files: frontend/src/components/PostsFeed.tsx, frontend/src/components/PostsFeed.module.css, frontend/src/pages/CreatorDetail.tsx, frontend/src/pages/PostsList.tsx, frontend/src/pages/PostsList.module.css, frontend/src/App.tsx, frontend/package.json
|
||||
- Verify: cd frontend && npm run build 2>&1 | tail -5
|
||||
|
|
|
|||
105
.gsd/milestones/M023/slices/S01/S01-RESEARCH.md
Normal file
105
.gsd/milestones/M023/slices/S01/S01-RESEARCH.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# S01 Research: Post Editor + File Sharing
|
||||
|
||||
## Summary
|
||||
|
||||
This slice adds creator-authored rich text posts with file attachments (presets, sample packs). Followers see posts in a feed on the creator profile. Files are downloadable via signed URLs from MinIO. This is **new functionality** — no Post model, no MinIO service, and no rich text editor exist in the codebase today. The slice spans the full stack: Docker infrastructure (MinIO), backend (models + migration + 2 routers), and frontend (editor page + feed component).
|
||||
|
||||
No explicit REQUIREMENTS.md entry exists for this feature yet. It's driven by the M023 roadmap: "Creator writes rich text posts with file attachments. Followers see posts in feed. Files downloadable via signed URLs."
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Tiptap** for the rich text editor (headless, React-native, large ecosystem, 485-install skill available). **MinIO** Python SDK (`minio` package) for presigned URL generation. Proxy-upload through the API for files (simpler than client-side presigned PUT — avoids CORS on MinIO, keeps auth in one place). Store Tiptap JSON as the canonical content format; render HTML server-side for feed display or let Tiptap render client-side.
|
||||
|
||||
Build order: infrastructure (MinIO container) → data model (Post + PostAttachment + migration) → file upload API → post CRUD API → frontend editor → frontend feed on creator profile.
|
||||
|
||||
## Implementation Landscape
|
||||
|
||||
### What exists
|
||||
|
||||
| Component | Location | Relevance |
|
||||
|---|---|---|
|
||||
| User model with `creator_id` FK | `backend/models.py` (`class User`) | Posts are creator-scoped; auth user resolves to creator |
|
||||
| Auth system (JWT, ProtectedRoute) | `backend/auth.py`, `frontend/src/context/AuthContext.tsx`, `frontend/src/components/ProtectedRoute.tsx` | Post creation/editing requires auth; feed is public |
|
||||
| Follow system | `backend/routers/follows.py`, `backend/models.py` (`CreatorFollow`) | "Followers see posts in feed" — but MVP: show posts on creator profile to everyone, not a personalized feed |
|
||||
| Creator detail page | `frontend/src/pages/CreatorDetail.tsx` | Posts feed renders here (new section below existing technique grid) |
|
||||
| Creator dashboard sidebar nav | `frontend/src/pages/CreatorDashboard.tsx` (`SidebarNav`) | Add "Posts" nav link to existing sidebar |
|
||||
| API client with auth token | `frontend/src/api/client.ts` | `request<T>()` sets `Content-Type: application/json` and auto-attaches JWT. File upload needs a `requestMultipart()` variant that omits Content-Type (browser sets multipart boundary) |
|
||||
| `python-multipart` in requirements | `backend/requirements.txt` | FastAPI `UploadFile` already usable |
|
||||
| Docker Compose | `docker-compose.yml` | Add MinIO service here |
|
||||
| Alembic migrations | `alembic/versions/` | Latest: `023_add_personality_profile.py` → next is `024` |
|
||||
| Existing route patterns | `backend/main.py` | Router registration pattern: import + `app.include_router(router, prefix="/api/v1")` |
|
||||
| App.tsx route definitions | `frontend/src/App.tsx` | Pattern: `<Route path="/creator/posts" element={<ProtectedRoute><Suspense><PostEditor /></Suspense></ProtectedRoute>} />` |
|
||||
|
||||
### What's missing (must build)
|
||||
|
||||
1. **MinIO Docker service** — new container in `docker-compose.yml` with volume at `/vmPool/r/services/chrysopedia_minio/`
|
||||
2. **MinIO config in Settings** — `minio_url`, `minio_access_key`, `minio_secret_key`, `minio_bucket` in `backend/config.py`
|
||||
3. **`minio` Python package** — add to `backend/requirements.txt`
|
||||
4. **MinIO client singleton** — `backend/minio_client.py` — lazy-init Minio client, bucket auto-creation on startup
|
||||
5. **Post model** — `backend/models.py`: `class Post(Base)` with id, creator_id (FK), title, body_json (JSONB for Tiptap), created_at, updated_at, is_published
|
||||
6. **PostAttachment model** — `backend/models.py`: `class PostAttachment(Base)` with id, post_id (FK), filename (original name), object_key (MinIO path), content_type, size_bytes, created_at
|
||||
7. **Alembic migration 024** — create `posts` and `post_attachments` tables
|
||||
8. **Posts router** — `backend/routers/posts.py`:
|
||||
- `POST /posts` — create post (auth, creator-scoped)
|
||||
- `GET /posts?creator_id=X` — list posts (public, paginated, reverse-chronological)
|
||||
- `GET /posts/{id}` — get single post (public)
|
||||
- `PUT /posts/{id}` — update post (auth, ownership check)
|
||||
- `DELETE /posts/{id}` — delete post + cascade attachments (auth, ownership)
|
||||
9. **Files router** — `backend/routers/files.py`:
|
||||
- `POST /files/upload` — upload file to MinIO via API proxy, returns attachment metadata (auth)
|
||||
- `GET /files/{attachment_id}/download` — generate signed GET URL, redirect or return URL (public but time-limited)
|
||||
10. **Pydantic schemas** — `PostCreate`, `PostUpdate`, `PostRead`, `PostAttachmentRead`, `PostListResponse` in `backend/schemas.py`
|
||||
11. **Frontend API module** — `frontend/src/api/posts.ts` with multipart upload helper
|
||||
12. **Tiptap dependencies** — `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-image` (possibly `@tiptap/extension-link`)
|
||||
13. **PostEditor page** — `frontend/src/pages/PostEditor.tsx` + CSS module. Rich text editor with file attachment zone.
|
||||
14. **PostsFeed component** — `frontend/src/components/PostsFeed.tsx` — renders posts on CreatorDetail page
|
||||
15. **PostDetail page or modal** — view single post with file download buttons
|
||||
|
||||
### Architecture decisions needed
|
||||
|
||||
| Decision | Recommended choice | Rationale |
|
||||
|---|---|---|
|
||||
| File upload flow | Proxy through API (not client-side presigned PUT) | Avoids MinIO CORS config, keeps auth verification server-side, simpler frontend code. Max file size ~100MB via nginx/uvicorn limit. |
|
||||
| Content storage format | Tiptap JSON in `body_json` JSONB column | JSON is the canonical format. HTML can be generated on-read or stored alongside. Tiptap can render from JSON client-side, so no server-side HTML generation needed initially. |
|
||||
| Feed scope | Show all published posts on creator profile (public) | A personalized "my followed creators" feed is a separate feature. MVP: posts appear on the creator's public profile page. |
|
||||
| File download auth | Signed URLs with 1-hour expiry, no login required | Follows D035 (MinIO for signed URLs). Public downloads with time-limited URLs prevent hotlinking but don't gate behind auth. |
|
||||
| MinIO bucket structure | Single bucket `chrysopedia`, key prefix `posts/{creator_slug}/{post_id}/{filename}` | Flat bucket with prefix hierarchy. Creator slug in path for human readability. |
|
||||
|
||||
### Key constraints
|
||||
|
||||
- **MinIO must be internal-only** — no public port exposure. All access goes through the API's signed URL generation.
|
||||
- **File size limit** — need to configure both uvicorn and nginx for larger uploads. Current nginx likely has default 1MB limit. Set to 100MB for presets/sample packs.
|
||||
- **Docker network** — MinIO container joins the existing `chrysopedia` network. API container accesses it at `http://chrysopedia-minio:9000`.
|
||||
- **`request()` in client.ts hardcodes Content-Type: application/json** — file upload needs a separate helper that uses FormData and lets the browser set the Content-Type header with multipart boundary.
|
||||
|
||||
### Natural task seams
|
||||
|
||||
1. **Infrastructure + Data Model** (backend-only, no frontend): MinIO in docker-compose, config, minio_client.py, Post/PostAttachment models, migration, schemas. Verify: migration runs, MinIO bucket accessible.
|
||||
2. **Post CRUD API** (backend-only): Posts router with create/read/update/delete. Verify: curl tests against running API.
|
||||
3. **File Upload/Download API** (backend-only): Files router with upload proxy and signed download URL. Verify: upload file via curl, download via signed URL.
|
||||
4. **Frontend: Tiptap Editor + Post Creation** (frontend-heavy): Install tiptap, build PostEditor page, multipart upload helper, wire to API. Verify: create post with attachment in browser.
|
||||
5. **Frontend: Feed + Download** (frontend): PostsFeed on CreatorDetail, file download buttons, PostEditor link in sidebar nav. Verify: posts appear on creator profile, files downloadable.
|
||||
|
||||
### Don't hand-roll
|
||||
|
||||
- **Rich text editor** — use Tiptap, not a custom contentEditable implementation
|
||||
- **S3 client** — use `minio` Python SDK, not raw HTTP to S3 API
|
||||
- **File type detection** — use `content_type` from the upload, don't parse magic bytes
|
||||
|
||||
### Potential pitfalls
|
||||
|
||||
1. **MinIO Docker image size** — `minio/minio` is ~200MB. Fine for ub01 but worth noting.
|
||||
2. **Tiptap bundle size** — StarterKit + React adds ~150KB gzipped. Acceptable for a code-split lazy page.
|
||||
3. **nginx upload limit** — the `chrysopedia-web-8096` container proxies to the API. Its nginx config needs `client_max_body_size 100m` for file uploads to pass through.
|
||||
4. **asyncpg + MinIO** — MinIO SDK is sync. For the FastAPI async handlers, either use `run_in_executor` for MinIO calls or keep them in sync endpoints. Presigned URL generation is pure computation (no I/O) so it's fine sync. Actual upload uses `put_object` which does I/O — run in executor.
|
||||
|
||||
## Suggested skills
|
||||
|
||||
- `npx skills add jezweb/claude-skills@tiptap` (485 installs) — Tiptap editor patterns
|
||||
- `npx skills add vm0-ai/vm0-skills@minio` (99 installs) — MinIO integration patterns
|
||||
|
||||
## Sources
|
||||
|
||||
- MinIO Python SDK docs: `resolve_library /minio/minio-py` — presigned URLs, put_object
|
||||
- Tiptap docs: `resolve_library /ueberdosis/tiptap-docs` — React setup, image upload node
|
||||
- Existing codebase patterns: follows router, auth system, creator dashboard
|
||||
109
.gsd/milestones/M023/slices/S01/tasks/T01-PLAN.md
Normal file
109
.gsd/milestones/M023/slices/S01/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
estimated_steps: 60
|
||||
estimated_files: 8
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T01: MinIO infrastructure, Post/PostAttachment models, migration, and schemas
|
||||
|
||||
## Description
|
||||
|
||||
Stand up the MinIO Docker service and build the data layer for posts and file attachments. This is the foundation everything else depends on.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add MinIO service to `docker-compose.yml`:
|
||||
- Image: `minio/minio`
|
||||
- Command: `server /data --console-address ":9001"`
|
||||
- Container name: `chrysopedia-minio`
|
||||
- Volume: `/vmPool/r/services/chrysopedia_minio:/data`
|
||||
- Environment: `MINIO_ROOT_USER=${MINIO_ROOT_USER:-chrysopedia}`, `MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-changeme-minio}`
|
||||
- Network: `chrysopedia`
|
||||
- Healthcheck: `curl -sf http://localhost:9000/minio/health/live`
|
||||
- NO public port exposure (internal only on chrysopedia network)
|
||||
|
||||
2. Add MinIO settings to `backend/config.py`:
|
||||
- `minio_url: str = "chrysopedia-minio:9000"`
|
||||
- `minio_access_key: str = "chrysopedia"`
|
||||
- `minio_secret_key: str = "changeme-minio"`
|
||||
- `minio_bucket: str = "chrysopedia"`
|
||||
- `minio_secure: bool = False`
|
||||
|
||||
3. Create `backend/minio_client.py`:
|
||||
- Lazy-init singleton `Minio` client from settings
|
||||
- `get_minio_client()` function
|
||||
- `ensure_bucket()` function that creates the bucket if it doesn't exist (call on first use)
|
||||
- `generate_download_url(object_key, expires=3600)` → presigned GET URL
|
||||
- `upload_file(object_key, data, length, content_type)` → wraps `put_object`
|
||||
|
||||
4. Add `minio` to `backend/requirements.txt`
|
||||
|
||||
5. Add Post and PostAttachment models to `backend/models.py`:
|
||||
- `Post`: id (UUID PK), creator_id (FK to creators.id), title (String), body_json (JSONB), is_published (Boolean default False), created_at, updated_at
|
||||
- `PostAttachment`: id (UUID PK), post_id (FK to posts.id, cascade delete), filename (String, original name), object_key (String, MinIO path), content_type (String), size_bytes (BigInteger), created_at
|
||||
- Add `posts` relationship on Creator model
|
||||
|
||||
6. Create Alembic migration `024_add_posts_and_attachments.py`:
|
||||
- Create `posts` table
|
||||
- Create `post_attachments` table
|
||||
- Foreign keys with ON DELETE CASCADE
|
||||
|
||||
7. Add Pydantic schemas to `backend/schemas.py`:
|
||||
- `PostAttachmentRead`: id, filename, content_type, size_bytes, download_url (optional str), created_at
|
||||
- `PostCreate`: title, body_json (dict), is_published (bool)
|
||||
- `PostUpdate`: title (optional), body_json (optional dict), is_published (optional bool)
|
||||
- `PostRead`: id, creator_id, title, body_json, is_published, created_at, updated_at, attachments (list[PostAttachmentRead])
|
||||
- `PostListResponse`: items (list[PostRead]), total (int)
|
||||
|
||||
8. Bump `client_max_body_size` in `docker/nginx.conf` from 50m to 100m
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] MinIO service in docker-compose.yml with healthcheck, volume, no public port
|
||||
- [ ] MinIO config fields in Settings class
|
||||
- [ ] minio_client.py with lazy init, ensure_bucket, upload, presigned URL generation
|
||||
- [ ] Post and PostAttachment models with correct FKs and cascade
|
||||
- [ ] Alembic migration 024 creates both tables
|
||||
- [ ] Pydantic schemas for post CRUD
|
||||
- [ ] nginx client_max_body_size bumped to 100m
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| MinIO container | Log connection error, raise 503 from endpoints | Same — 503 with timeout detail | N/A (SDK handles protocol) |
|
||||
| PostgreSQL (migration) | Alembic raises, migration rolls back | Connection timeout → retry | N/A |
|
||||
|
||||
## Verification
|
||||
|
||||
- `docker compose config` validates compose file
|
||||
- MinIO container starts and passes healthcheck
|
||||
- `python -c "from models import Post, PostAttachment; print('ok')"` imports cleanly
|
||||
- `alembic upgrade head` applies migration 024 without error
|
||||
- `python -c "from schemas import PostCreate, PostRead, PostAttachmentRead; print('ok')"` imports cleanly
|
||||
- `python -c "from minio_client import get_minio_client; print('ok')"` imports cleanly
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``docker-compose.yml` — existing compose file to add MinIO service`
|
||||
- ``backend/config.py` — Settings class to extend with MinIO fields`
|
||||
- ``backend/models.py` — existing models to add Post/PostAttachment`
|
||||
- ``backend/schemas.py` — existing schemas to add post schemas`
|
||||
- ``backend/requirements.txt` — add minio package`
|
||||
- ``docker/nginx.conf` — bump client_max_body_size`
|
||||
- ``alembic/versions/023_add_personality_profile.py` — latest migration for revision chain`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``docker-compose.yml` — MinIO service added`
|
||||
- ``backend/config.py` — MinIO settings added`
|
||||
- ``backend/minio_client.py` — new file with MinIO client singleton and helpers`
|
||||
- ``backend/models.py` — Post and PostAttachment models added`
|
||||
- ``backend/schemas.py` — Post CRUD schemas added`
|
||||
- ``backend/requirements.txt` — minio package added`
|
||||
- ``docker/nginx.conf` — client_max_body_size 100m`
|
||||
- ``alembic/versions/024_add_posts_and_attachments.py` — new migration`
|
||||
|
||||
## Verification
|
||||
|
||||
docker compose config --quiet && python -c 'from models import Post, PostAttachment; from schemas import PostCreate, PostRead; from minio_client import get_minio_client; print("all imports ok")'
|
||||
93
.gsd/milestones/M023/slices/S01/tasks/T01-SUMMARY.md
Normal file
93
.gsd/milestones/M023/slices/S01/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M023
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["docker-compose.yml", "backend/config.py", "backend/minio_client.py", "backend/models.py", "backend/schemas.py", "backend/requirements.txt", "docker/nginx.conf", "alembic/versions/024_add_posts_and_attachments.py"]
|
||||
key_decisions: ["Used BigInteger for size_bytes to support files >2GB", "MinIO has no public port exposure — internal only on chrysopedia network", "Lazy-init MinIO singleton with ensure_bucket on first write"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "docker compose config --quiet passed (exit 0). All Python imports verified: models (Post, PostAttachment), schemas (PostCreate, PostRead, PostAttachmentRead), and minio_client (get_minio_client) import cleanly."
|
||||
completed_at: 2026-04-04T09:02:37.469Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added MinIO Docker service, Post/PostAttachment models with migration 024, Pydantic schemas, and MinIO client helper module
|
||||
|
||||
> Added MinIO Docker service, Post/PostAttachment models with migration 024, Pydantic schemas, and MinIO client helper module
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S01
|
||||
milestone: M023
|
||||
key_files:
|
||||
- docker-compose.yml
|
||||
- backend/config.py
|
||||
- backend/minio_client.py
|
||||
- backend/models.py
|
||||
- backend/schemas.py
|
||||
- backend/requirements.txt
|
||||
- docker/nginx.conf
|
||||
- alembic/versions/024_add_posts_and_attachments.py
|
||||
key_decisions:
|
||||
- Used BigInteger for size_bytes to support files >2GB
|
||||
- MinIO has no public port exposure — internal only on chrysopedia network
|
||||
- Lazy-init MinIO singleton with ensure_bucket on first write
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T09:02:37.470Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added MinIO Docker service, Post/PostAttachment models with migration 024, Pydantic schemas, and MinIO client helper module
|
||||
|
||||
**Added MinIO Docker service, Post/PostAttachment models with migration 024, Pydantic schemas, and MinIO client helper module**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added the MinIO object storage service to docker-compose.yml with healthcheck, volume mount, and no public port exposure. Extended Settings with MinIO connection fields. Created minio_client.py with lazy-init singleton, ensure_bucket, upload_file, and presigned URL generation. Added Post and PostAttachment ORM models with correct FKs and cascade delete. Created Alembic migration 024. Added post CRUD Pydantic schemas. Added minio package to requirements.txt. Bumped nginx client_max_body_size to 100m.
|
||||
|
||||
## Verification
|
||||
|
||||
docker compose config --quiet passed (exit 0). All Python imports verified: models (Post, PostAttachment), schemas (PostCreate, PostRead, PostAttachmentRead), and minio_client (get_minio_client) import cleanly.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `docker compose config --quiet` | 0 | ✅ pass | 1000ms |
|
||||
| 2 | `python -c 'from models import Post, PostAttachment; print("ok")'` | 0 | ✅ pass | 500ms |
|
||||
| 3 | `python -c 'from schemas import PostCreate, PostRead, PostAttachmentRead; print("ok")'` | 0 | ✅ pass | 500ms |
|
||||
| 4 | `python -c 'from minio_client import get_minio_client; print("ok")'` | 0 | ✅ pass | 500ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
Alembic migration 024 not yet applied to a live database — application happens at deploy time on ub01.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docker-compose.yml`
|
||||
- `backend/config.py`
|
||||
- `backend/minio_client.py`
|
||||
- `backend/models.py`
|
||||
- `backend/schemas.py`
|
||||
- `backend/requirements.txt`
|
||||
- `docker/nginx.conf`
|
||||
- `alembic/versions/024_add_posts_and_attachments.py`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
Alembic migration 024 not yet applied to a live database — application happens at deploy time on ub01.
|
||||
91
.gsd/milestones/M023/slices/S01/tasks/T02-PLAN.md
Normal file
91
.gsd/milestones/M023/slices/S01/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
estimated_steps: 47
|
||||
estimated_files: 3
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T02: Post CRUD router and file upload/download router with API registration
|
||||
|
||||
## Description
|
||||
|
||||
Build both API routers: posts CRUD (create, list, get, update, delete) and file upload/download (proxy upload to MinIO, signed URL generation). Register both in main.py.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `backend/routers/posts.py`:
|
||||
- `POST /posts` — create post. Requires auth (`get_current_user`). Creator resolved from `current_user.creator_id`. Body: `PostCreate` schema. Returns `PostRead`. Set creator_id from auth user's linked creator.
|
||||
- `GET /posts` — list posts. Public. Query params: `creator_id` (required int), `page` (default 1), `limit` (default 20). Filter `is_published=True` for non-owner requests (if no auth or different creator). Returns `PostListResponse`. Use `selectinload(Post.attachments)` for eager loading.
|
||||
- `GET /posts/{post_id}` — get single post. Public for published posts. Returns `PostRead` with attachments. Generate download URLs for each attachment using `generate_download_url()`.
|
||||
- `PUT /posts/{post_id}` — update post. Auth required. Ownership check: post.creator_id must match user's creator_id. Body: `PostUpdate`. Returns `PostRead`.
|
||||
- `DELETE /posts/{post_id}` — delete post. Auth required. Ownership check. Also delete attachment objects from MinIO. Returns 204.
|
||||
|
||||
2. Create `backend/routers/files.py`:
|
||||
- `POST /files/upload` — upload file. Auth required. Accept `UploadFile` + `post_id` (form field). Validate post exists and user owns it. Generate object_key: `posts/{creator_slug}/{post_id}/{sanitized_filename}`. Use `run_in_executor` for MinIO `put_object` (sync I/O in async handler). Create PostAttachment record. Return `PostAttachmentRead`.
|
||||
- `GET /files/{attachment_id}/download` — generate signed URL. Public. Look up PostAttachment, generate presigned GET URL (1 hour), return JSON with `url` field (not redirect, so frontend controls UX).
|
||||
- Filename sanitization: strip path separators, limit length, preserve extension.
|
||||
|
||||
3. Register both routers in `backend/main.py`:
|
||||
- `from routers import posts, files`
|
||||
- `app.include_router(posts.router, prefix="/api/v1")`
|
||||
- `app.include_router(files.router, prefix="/api/v1")`
|
||||
|
||||
4. Wire MinIO bucket initialization into app startup in `backend/main.py`:
|
||||
- In the existing `lifespan` or startup event, call `ensure_bucket()` from minio_client.py (wrapped in try/except so API still starts if MinIO is temporarily down)
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Post CRUD endpoints with auth and ownership enforcement
|
||||
- [ ] File upload proxied through API to MinIO with PostAttachment record creation
|
||||
- [ ] Signed download URL generation (1-hour expiry)
|
||||
- [ ] Both routers registered in main.py
|
||||
- [ ] MinIO bucket auto-creation on startup
|
||||
- [ ] `selectinload` for attachments on post queries
|
||||
- [ ] `run_in_executor` for sync MinIO I/O in async handlers
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Dependency | On error | On timeout | On malformed response |
|
||||
|------------|----------|-----------|----------------------|
|
||||
| MinIO (upload) | 503 with "File storage unavailable" | 504 gateway timeout | N/A |
|
||||
| MinIO (download URL) | 503 | N/A (presigned URL is local computation) | N/A |
|
||||
| DB (post lookup) | 500 | 500 | N/A |
|
||||
| Auth (missing token) | 401 Unauthorized | N/A | 401 |
|
||||
|
||||
## Negative Tests
|
||||
|
||||
- **Malformed inputs**: Upload with no file, create post with empty title, update post that doesn't exist
|
||||
- **Error paths**: Upload to non-owned post returns 403, delete non-owned post returns 403, get non-existent post returns 404
|
||||
- **Boundary conditions**: List posts with no results returns empty list, upload file with path traversal in filename gets sanitized
|
||||
|
||||
## Verification
|
||||
|
||||
- API starts without errors: `docker logs chrysopedia-api 2>&1 | tail -5` shows no import/startup errors
|
||||
- Post CRUD works: create post via curl with auth token, list posts, get single post, update, delete
|
||||
- File upload works: upload a test file via curl multipart, verify attachment in response
|
||||
- Download URL works: GET download endpoint returns signed URL
|
||||
|
||||
## Observability Impact
|
||||
|
||||
- Signals added: File upload errors logged with MinIO error message and object_key. Post ownership violations logged at WARNING.
|
||||
- How a future agent inspects: `docker logs chrysopedia-api | grep -i minio` for storage issues. DB query on posts/post_attachments tables.
|
||||
- Failure state exposed: 503 status with detail message for MinIO failures, 403 for ownership violations, 404 for missing resources.
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``backend/models.py` — Post and PostAttachment models from T01`
|
||||
- ``backend/schemas.py` — Post schemas from T01`
|
||||
- ``backend/minio_client.py` — MinIO client helpers from T01`
|
||||
- ``backend/config.py` — MinIO settings from T01`
|
||||
- ``backend/auth.py` — get_current_user dependency`
|
||||
- ``backend/database.py` — get_session dependency`
|
||||
- ``backend/main.py` — router registration pattern`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``backend/routers/posts.py` — new file with post CRUD endpoints`
|
||||
- ``backend/routers/files.py` — new file with file upload/download endpoints`
|
||||
- ``backend/main.py` — updated with posts and files router registration + MinIO bucket init`
|
||||
|
||||
## Verification
|
||||
|
||||
docker exec chrysopedia-api python -c 'from routers.posts import router; from routers.files import router; print("routers ok")' && curl -sf http://ub01:8096/api/v1/posts?creator_id=1 | python3 -m json.tool
|
||||
86
.gsd/milestones/M023/slices/S01/tasks/T03-PLAN.md
Normal file
86
.gsd/milestones/M023/slices/S01/tasks/T03-PLAN.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
estimated_steps: 43
|
||||
estimated_files: 7
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T03: Tiptap post editor page with file attachment upload
|
||||
|
||||
## Description
|
||||
|
||||
Build the creator-facing post editor with Tiptap rich text editing and file attachment upload. This is the write path — creators use this to compose and publish posts with downloadable files.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Install Tiptap dependencies:
|
||||
- `cd frontend && npm install @tiptap/react @tiptap/starter-kit @tiptap/pm @tiptap/extension-link @tiptap/extension-placeholder`
|
||||
|
||||
2. Add multipart upload helper to `frontend/src/api/client.ts`:
|
||||
- `requestMultipart<T>(url, formData, init?)` — same as `request()` but does NOT set Content-Type header (browser sets multipart boundary). Still attaches auth token.
|
||||
|
||||
3. Create `frontend/src/api/posts.ts`:
|
||||
- `createPost(data: PostCreate): Promise<PostRead>` — POST /api/v1/posts
|
||||
- `updatePost(id: string, data: PostUpdate): Promise<PostRead>` — PUT /api/v1/posts/{id}
|
||||
- `getPost(id: string): Promise<PostRead>` — GET /api/v1/posts/{id}
|
||||
- `listPosts(creatorId: number, page?: number): Promise<PostListResponse>` — GET /api/v1/posts?creator_id=X
|
||||
- `uploadFile(postId: string, file: File): Promise<PostAttachmentRead>` — POST /api/v1/files/upload (multipart)
|
||||
- `getDownloadUrl(attachmentId: string): Promise<{url: string}>` — GET /api/v1/files/{id}/download
|
||||
- `deletePost(id: string): Promise<void>` — DELETE /api/v1/posts/{id}
|
||||
- TypeScript types: `PostCreate`, `PostUpdate`, `PostRead`, `PostAttachmentRead`, `PostListResponse`
|
||||
|
||||
4. Create `frontend/src/pages/PostEditor.tsx` + `PostEditor.module.css`:
|
||||
- Tiptap editor with StarterKit (headings, bold, italic, lists, code blocks) + Link extension + Placeholder
|
||||
- Title input field above the editor
|
||||
- Toolbar: bold, italic, heading (H2, H3), bullet list, ordered list, link, code block
|
||||
- File attachment zone below editor: drag-and-drop area or file picker button
|
||||
- File list showing attached files with remove button
|
||||
- Publish toggle (draft/published)
|
||||
- Save button that: creates post via API → uploads attached files → navigates to creator dashboard
|
||||
- If editing (post_id in URL), load existing post and populate editor
|
||||
- Styling: dark theme consistent with existing CSS variables, editor min-height ~300px
|
||||
|
||||
5. Add routes in `frontend/src/App.tsx`:
|
||||
- `/creator/posts/new` → PostEditor (protected)
|
||||
- `/creator/posts/:postId/edit` → PostEditor (protected, edit mode)
|
||||
|
||||
6. Add "Posts" link to `SidebarNav` in `frontend/src/pages/CreatorDashboard.tsx`:
|
||||
- Add NavLink to `/creator/posts` between existing nav items
|
||||
- Add a "New Post" button/link on the dashboard or posts list that goes to `/creator/posts/new`
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Tiptap editor renders with toolbar (bold, italic, headings, lists, link, code)
|
||||
- [ ] Title field + body editor + file attachment area
|
||||
- [ ] Files upload via multipart to /api/v1/files/upload
|
||||
- [ ] Save creates post then uploads files
|
||||
- [ ] Route registered and accessible from creator sidebar
|
||||
- [ ] Dark theme styling consistent with app
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` succeeds with no errors
|
||||
- Navigate to `/creator/posts/new` while logged in — editor renders with toolbar
|
||||
- Type text, attach a file, save — post created in API, file uploaded to MinIO
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``frontend/src/api/client.ts` — existing request helper to extend with requestMultipart`
|
||||
- ``frontend/src/App.tsx` — existing route definitions`
|
||||
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav component to add Posts link`
|
||||
- ``backend/routers/posts.py` — API contract from T02 (endpoint shapes)`
|
||||
- ``backend/routers/files.py` — API contract from T02 (upload endpoint)`
|
||||
- ``backend/schemas.py` — schema shapes from T01 for TypeScript types`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/package.json` — tiptap dependencies added`
|
||||
- ``frontend/src/api/client.ts` — requestMultipart helper added`
|
||||
- ``frontend/src/api/posts.ts` — new file with post API functions and types`
|
||||
- ``frontend/src/pages/PostEditor.tsx` — new file with Tiptap editor page`
|
||||
- ``frontend/src/pages/PostEditor.module.css` — new file with editor styles`
|
||||
- ``frontend/src/App.tsx` — post editor routes added`
|
||||
- ``frontend/src/pages/CreatorDashboard.tsx` — Posts link added to SidebarNav`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
82
.gsd/milestones/M023/slices/S01/tasks/T04-PLAN.md
Normal file
82
.gsd/milestones/M023/slices/S01/tasks/T04-PLAN.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
estimated_steps: 41
|
||||
estimated_files: 7
|
||||
skills_used: []
|
||||
---
|
||||
|
||||
# T04: Posts feed on creator profile with file download buttons
|
||||
|
||||
## Description
|
||||
|
||||
Build the public-facing read path: a PostsFeed component on the creator detail page showing published posts reverse-chronologically, with file download buttons using signed URLs. Also add a posts list page at `/creator/posts` for the creator to manage their posts.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create `frontend/src/components/PostsFeed.tsx` + `PostsFeed.module.css`:
|
||||
- Props: `creatorId: number`
|
||||
- Fetches posts via `listPosts(creatorId)` on mount
|
||||
- Renders each post as a card: title, rich text body (render Tiptap JSON using `generateHTML` from `@tiptap/html` or render raw HTML), created_at timestamp, attachment list
|
||||
- Each attachment shows: filename, size, download button
|
||||
- Download button calls `getDownloadUrl(attachmentId)` then opens the returned URL
|
||||
- Empty state: "No posts yet" message
|
||||
- Loading state with skeleton/spinner
|
||||
- Styling: dark theme cards consistent with existing technique cards
|
||||
|
||||
2. Integrate PostsFeed into `frontend/src/pages/CreatorDetail.tsx`:
|
||||
- Add a "Posts" section below the existing technique grid
|
||||
- Section heading: "Posts" with post count
|
||||
- Render `<PostsFeed creatorId={creator.id} />`
|
||||
- Only show section if creator has posts (or always show with empty state)
|
||||
|
||||
3. Create `frontend/src/pages/PostsList.tsx` + `PostsList.module.css`:
|
||||
- Protected route at `/creator/posts`
|
||||
- Lists the current creator's posts (draft and published)
|
||||
- Each row: title, status badge (draft/published), created date, edit button, delete button
|
||||
- "New Post" button linking to `/creator/posts/new`
|
||||
- Delete with confirmation dialog
|
||||
- Uses the same `CreatorDashboard` layout shell (SidebarNav)
|
||||
|
||||
4. Add route in `frontend/src/App.tsx`:
|
||||
- `/creator/posts` → PostsList (protected)
|
||||
|
||||
5. Install `@tiptap/html` for server-side HTML generation from Tiptap JSON:
|
||||
- `npm install @tiptap/html`
|
||||
- Use `generateHTML(doc, extensions)` to render post body in feed
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] PostsFeed component renders posts with rich text body and file attachments
|
||||
- [ ] File download buttons generate and open signed URLs
|
||||
- [ ] PostsFeed integrated into CreatorDetail page
|
||||
- [ ] PostsList page for creator to manage their posts (draft/published)
|
||||
- [ ] Routes registered in App.tsx
|
||||
- [ ] Dark theme styling consistent with app
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd frontend && npm run build` succeeds
|
||||
- Navigate to a creator profile that has posts — posts section visible with cards
|
||||
- Click download button — file downloads via signed URL
|
||||
- Navigate to `/creator/posts` while logged in — post management list visible
|
||||
|
||||
## Inputs
|
||||
|
||||
- ``frontend/src/api/posts.ts` — post API functions from T03`
|
||||
- ``frontend/src/pages/CreatorDetail.tsx` — existing creator profile page`
|
||||
- ``frontend/src/App.tsx` — route definitions (updated in T03)`
|
||||
- ``frontend/src/pages/CreatorDashboard.tsx` — SidebarNav pattern for layout`
|
||||
- ``frontend/src/pages/PostEditor.tsx` — editor page from T03 (for edit links)`
|
||||
|
||||
## Expected Output
|
||||
|
||||
- ``frontend/src/components/PostsFeed.tsx` — new file with posts feed component`
|
||||
- ``frontend/src/components/PostsFeed.module.css` — new file with feed styles`
|
||||
- ``frontend/src/pages/CreatorDetail.tsx` — updated with PostsFeed section`
|
||||
- ``frontend/src/pages/PostsList.tsx` — new file with post management page`
|
||||
- ``frontend/src/pages/PostsList.module.css` — new file with list styles`
|
||||
- ``frontend/src/App.tsx` — PostsList route added`
|
||||
- ``frontend/package.json` — @tiptap/html dependency added`
|
||||
|
||||
## Verification
|
||||
|
||||
cd frontend && npm run build 2>&1 | tail -5
|
||||
15463
.gsd/reports/M022-2026-04-04T08-51-51.html
Normal file
15463
.gsd/reports/M022-2026-04-04T08-51-51.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -130,7 +130,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
</div>
|
||||
<div class="hdr-right">
|
||||
<span class="gen-lbl">Updated</span>
|
||||
<span class="gen">Apr 4, 2026, 06:50 AM</span>
|
||||
<span class="gen">Apr 4, 2026, 08:51 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -160,6 +160,10 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="toc-group-label">M021</div>
|
||||
<ul><li><a href="M021-2026-04-04T06-50-37.html">Apr 4, 2026, 06:50 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
<div class="toc-group">
|
||||
<div class="toc-group-label">M022</div>
|
||||
<ul><li><a href="M022-2026-04-04T08-51-51.html">Apr 4, 2026, 08:51 AM</a> <span class="toc-kind toc-milestone">milestone</span></li></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
|
|
@ -168,43 +172,45 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<h2>Project Overview</h2>
|
||||
|
||||
<div class="idx-summary">
|
||||
<div class="idx-stat"><span class="idx-val">$485.08</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">685.34M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">21h 0m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">94/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">21/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">5</span><span class="idx-lbl">Reports</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">$516.43</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">728.19M</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">22h 28m</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">101/123</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">22/25</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">6</span><span class="idx-lbl">Reports</span></div>
|
||||
</div>
|
||||
<div class="idx-progress">
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:76%"></div></div>
|
||||
<span class="idx-pct">76% complete</span>
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:82%"></div></div>
|
||||
<span class="idx-pct">82% complete</span>
|
||||
</div>
|
||||
<div class="sparkline-wrap"><h3>Cost Progression</h3>
|
||||
<div class="sparkline">
|
||||
<svg viewBox="0 0 600 60" width="600" height="60" class="spark-svg">
|
||||
<polyline points="12.0,35.2 156.0,34.6 300.0,20.9 444.0,17.5 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="35.2" r="3" class="spark-dot">
|
||||
<polyline points="12.0,36.0 127.2,35.4 242.4,22.5 357.6,19.3 472.8,14.2 588.0,12.0" class="spark-line" fill="none"/>
|
||||
<circle cx="12.0" cy="36.0" r="3" class="spark-dot">
|
||||
<title>M008: M008: Credibility Debt Cleanup — Broken Links, Test Data, Jargon, Empty Metrics — $172.23</title>
|
||||
</circle><circle cx="156.0" cy="34.6" r="3" class="spark-dot">
|
||||
</circle><circle cx="127.2" cy="35.4" r="3" class="spark-dot">
|
||||
<title>M009: Homepage & First Impression — $180.97</title>
|
||||
</circle><circle cx="300.0" cy="20.9" r="3" class="spark-dot">
|
||||
</circle><circle cx="242.4" cy="22.5" r="3" class="spark-dot">
|
||||
<title>M018: M018: Phase 2 Research & Documentation — Site Audit and Forgejo Wiki Bootstrap — $365.18</title>
|
||||
</circle><circle cx="444.0" cy="17.5" r="3" class="spark-dot">
|
||||
</circle><circle cx="357.6" cy="19.3" r="3" class="spark-dot">
|
||||
<title>M019: Foundations — Auth, Consent & LightRAG — $411.26</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
</circle><circle cx="472.8" cy="14.2" r="3" class="spark-dot">
|
||||
<title>M021: Intelligence Online — Chat, Chapters & Search Cutover — $485.08</title>
|
||||
</circle><circle cx="588.0" cy="12.0" r="3" class="spark-dot">
|
||||
<title>M022: Creator Tools & Personality — $516.43</title>
|
||||
</circle>
|
||||
<text x="12" y="58" class="spark-lbl">$172.23</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$485.08</text>
|
||||
<text x="588" y="58" text-anchor="end" class="spark-lbl">$516.43</text>
|
||||
</svg>
|
||||
<div class="spark-axis">
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:26.0%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:50.0%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:74.0%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T06:50:37.759Z">M021</span>
|
||||
<span class="spark-tick" style="left:2.0%" title="2026-03-31T05:31:26.249Z">M008</span><span class="spark-tick" style="left:21.2%" title="2026-03-31T05:52:28.456Z">M009</span><span class="spark-tick" style="left:40.4%" title="2026-04-03T21:17:51.201Z">M018</span><span class="spark-tick" style="left:59.6%" title="2026-04-03T23:30:16.641Z">M019</span><span class="spark-tick" style="left:78.8%" title="2026-04-04T06:50:37.759Z">M021</span><span class="spark-tick" style="left:98.0%" title="2026-04-04T08:51:51.223Z">M022</span>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="idx-cards">
|
||||
<h2>Progression <span class="sec-count">5</span></h2>
|
||||
<h2>Progression <span class="sec-count">6</span></h2>
|
||||
<div class="cards-grid">
|
||||
<a class="report-card" href="M008-2026-03-31T05-31-26.html">
|
||||
<div class="card-top">
|
||||
|
|
@ -290,7 +296,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<div class="card-delta"><span>+$46.09</span><span>+6 slices</span><span>+1 milestone</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M021-2026-04-04T06-50-37.html">
|
||||
<a class="report-card" href="M021-2026-04-04T06-50-37.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M021: Intelligence Online — Chat, Chapters & Search Cutover</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
|
|
@ -309,6 +315,27 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span>94/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$73.82</span><span>+15 slices</span><span>+2 milestones</span></div>
|
||||
|
||||
</a>
|
||||
<a class="report-card card-latest" href="M022-2026-04-04T08-51-51.html">
|
||||
<div class="card-top">
|
||||
<span class="card-label">M022: Creator Tools & Personality</span>
|
||||
<span class="card-kind card-kind-milestone">milestone</span>
|
||||
</div>
|
||||
<div class="card-date">Apr 4, 2026, 08:51 AM</div>
|
||||
<div class="card-progress">
|
||||
<div class="card-bar-track">
|
||||
<div class="card-bar-fill" style="width:82%"></div>
|
||||
</div>
|
||||
<span class="card-pct">82%</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span>$516.43</span>
|
||||
<span>728.19M</span>
|
||||
<span>22h 28m</span>
|
||||
<span>101/123 slices</span>
|
||||
</div>
|
||||
<div class="card-delta"><span>+$31.35</span><span>+7 slices</span><span>+1 milestone</span></div>
|
||||
<div class="card-latest-badge">Latest</div>
|
||||
</a></div>
|
||||
</section>
|
||||
|
|
@ -323,7 +350,7 @@ footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
|||
<span class="ftr-sep">—</span>
|
||||
<span>/home/aux/projects/content-to-kb-automator</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>Updated Apr 4, 2026, 06:50 AM</span>
|
||||
<span>Updated Apr 4, 2026, 08:51 AM</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,22 @@
|
|||
"doneMilestones": 21,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
},
|
||||
{
|
||||
"filename": "M022-2026-04-04T08-51-51.html",
|
||||
"generatedAt": "2026-04-04T08:51:51.223Z",
|
||||
"milestoneId": "M022",
|
||||
"milestoneTitle": "Creator Tools & Personality",
|
||||
"label": "M022: Creator Tools & Personality",
|
||||
"kind": "milestone",
|
||||
"totalCost": 516.4333635000003,
|
||||
"totalTokens": 728186926,
|
||||
"totalDuration": 80906208,
|
||||
"doneSlices": 101,
|
||||
"totalSlices": 123,
|
||||
"doneMilestones": 22,
|
||||
"totalMilestones": 25,
|
||||
"phase": "planning"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
44
alembic/versions/024_add_posts_and_attachments.py
Normal file
44
alembic/versions/024_add_posts_and_attachments.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Add posts and post_attachments tables.
|
||||
|
||||
Revision ID: 024_add_posts_and_attachments
|
||||
Revises: 023_add_personality_profile
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "024_add_posts_and_attachments"
|
||||
down_revision = "023_add_personality_profile"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"posts",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("body_json", JSONB, nullable=False),
|
||||
sa.Column("is_published", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"post_attachments",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
|
||||
sa.Column("post_id", UUID(as_uuid=True), sa.ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("filename", sa.String(500), nullable=False),
|
||||
sa.Column("object_key", sa.String(1000), nullable=False),
|
||||
sa.Column("content_type", sa.String(255), nullable=False),
|
||||
sa.Column("size_bytes", sa.BigInteger, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("post_attachments")
|
||||
op.drop_table("posts")
|
||||
|
|
@ -71,6 +71,13 @@ class Settings(BaseSettings):
|
|||
# Debug mode — when True, pipeline captures full LLM prompts and responses
|
||||
debug_mode: bool = False
|
||||
|
||||
# MinIO (file storage for post attachments)
|
||||
minio_url: str = "chrysopedia-minio:9000"
|
||||
minio_access_key: str = "chrysopedia"
|
||||
minio_secret_key: str = "changeme-minio"
|
||||
minio_bucket: str = "chrysopedia"
|
||||
minio_secure: bool = False
|
||||
|
||||
# File storage
|
||||
transcript_storage_path: str = "/data/transcripts"
|
||||
video_metadata_path: str = "/data/video_meta"
|
||||
|
|
|
|||
104
backend/minio_client.py
Normal file
104
backend/minio_client.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""MinIO client singleton with lazy initialization.
|
||||
|
||||
Provides file upload, presigned download URL generation, and automatic
|
||||
bucket creation for the Chrysopedia post attachment storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Minio | None = None
|
||||
_bucket_ensured: bool = False
|
||||
|
||||
|
||||
def get_minio_client() -> Minio:
|
||||
"""Return the singleton MinIO client, creating it on first call."""
|
||||
global _client
|
||||
if _client is None:
|
||||
settings = get_settings()
|
||||
_client = Minio(
|
||||
settings.minio_url,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_secure,
|
||||
)
|
||||
logger.info("MinIO client initialized (endpoint=%s)", settings.minio_url)
|
||||
return _client
|
||||
|
||||
|
||||
def ensure_bucket() -> None:
|
||||
"""Create the configured bucket if it doesn't already exist."""
|
||||
global _bucket_ensured
|
||||
if _bucket_ensured:
|
||||
return
|
||||
settings = get_settings()
|
||||
client = get_minio_client()
|
||||
bucket = settings.minio_bucket
|
||||
try:
|
||||
if not client.bucket_exists(bucket):
|
||||
client.make_bucket(bucket)
|
||||
logger.info("Created MinIO bucket: %s", bucket)
|
||||
else:
|
||||
logger.debug("MinIO bucket already exists: %s", bucket)
|
||||
_bucket_ensured = True
|
||||
except S3Error as exc:
|
||||
logger.error("MinIO bucket check/create failed: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
def upload_file(
|
||||
object_key: str,
|
||||
data: bytes | io.BytesIO,
|
||||
length: int,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> None:
|
||||
"""Upload a file to MinIO.
|
||||
|
||||
Args:
|
||||
object_key: The storage path within the bucket.
|
||||
data: File content as bytes or BytesIO stream.
|
||||
length: Size in bytes.
|
||||
content_type: MIME type for the object.
|
||||
"""
|
||||
ensure_bucket()
|
||||
settings = get_settings()
|
||||
client = get_minio_client()
|
||||
stream = io.BytesIO(data) if isinstance(data, bytes) else data
|
||||
client.put_object(
|
||||
settings.minio_bucket,
|
||||
object_key,
|
||||
stream,
|
||||
length,
|
||||
content_type=content_type,
|
||||
)
|
||||
logger.info("Uploaded %s (%d bytes, %s)", object_key, length, content_type)
|
||||
|
||||
|
||||
def generate_download_url(object_key: str, expires: int = 3600) -> str:
|
||||
"""Generate a presigned GET URL for downloading a file.
|
||||
|
||||
Args:
|
||||
object_key: The storage path within the bucket.
|
||||
expires: URL validity in seconds (default 1 hour).
|
||||
|
||||
Returns:
|
||||
Presigned URL string.
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = get_minio_client()
|
||||
url: str = client.presigned_get_object(
|
||||
settings.minio_bucket,
|
||||
object_key,
|
||||
expires=timedelta(seconds=expires),
|
||||
)
|
||||
return url
|
||||
|
|
@ -12,6 +12,7 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Enum,
|
||||
Float,
|
||||
|
|
@ -144,6 +145,7 @@ class Creator(Base):
|
|||
# relationships
|
||||
videos: Mapped[list[SourceVideo]] = sa_relationship(back_populates="creator")
|
||||
technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator")
|
||||
posts: Mapped[list[Post]] = sa_relationship(back_populates="creator")
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
|
@ -763,3 +765,52 @@ class CreatorFollow(Base):
|
|||
# relationships
|
||||
user: Mapped[User] = sa_relationship()
|
||||
creator: Mapped[Creator] = sa_relationship()
|
||||
|
||||
|
||||
# ── Posts (Creator content feed) ─────────────────────────────────────────────
|
||||
|
||||
class Post(Base):
|
||||
"""A rich text post by a creator, optionally with file attachments."""
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
creator_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body_json: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
is_published: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now(), onupdate=_now
|
||||
)
|
||||
|
||||
# relationships
|
||||
creator: Mapped[Creator] = sa_relationship(back_populates="posts")
|
||||
attachments: Mapped[list[PostAttachment]] = sa_relationship(
|
||||
back_populates="post", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PostAttachment(Base):
|
||||
"""A file attachment on a post, stored in MinIO."""
|
||||
__tablename__ = "post_attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
post_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
object_key: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
content_type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=_now, server_default=func.now()
|
||||
)
|
||||
|
||||
# relationships
|
||||
post: Mapped[Post] = sa_relationship(back_populates="attachments")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ psycopg2-binary>=2.9,<3.0
|
|||
watchdog>=4.0,<5.0
|
||||
PyJWT>=2.8,<3.0
|
||||
bcrypt>=4.0,<6.0
|
||||
minio>=7.2,<8.0
|
||||
# Test dependencies
|
||||
pytest>=8.0,<10.0
|
||||
pytest-asyncio>=0.24,<1.0
|
||||
|
|
|
|||
|
|
@ -769,3 +769,51 @@ class PersonalityProfile(BaseModel):
|
|||
tone: ToneProfile = Field(default_factory=ToneProfile)
|
||||
style_markers: StyleMarkersProfile = Field(default_factory=StyleMarkersProfile)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# ── Posts (Creator content feed) ─────────────────────────────────────────────
|
||||
|
||||
class PostAttachmentRead(BaseModel):
|
||||
"""Read schema for a file attachment on a post."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
download_url: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PostCreate(BaseModel):
|
||||
"""Create a new post."""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
body_json: dict
|
||||
is_published: bool = False
|
||||
|
||||
|
||||
class PostUpdate(BaseModel):
|
||||
"""Partial update for an existing post."""
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
body_json: dict | None = None
|
||||
is_published: bool | None = None
|
||||
|
||||
|
||||
class PostRead(BaseModel):
|
||||
"""Full post with attachments."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
creator_id: uuid.UUID
|
||||
title: str
|
||||
body_json: dict
|
||||
is_published: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
attachments: list[PostAttachmentRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostListResponse(BaseModel):
|
||||
"""Paginated list of posts."""
|
||||
items: list[PostRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
|
|
|||
|
|
@ -204,6 +204,27 @@ services:
|
|||
start_period: 15s
|
||||
stop_grace_period: 15s
|
||||
|
||||
# ── MinIO (file storage for post attachments) ──
|
||||
chrysopedia-minio:
|
||||
image: minio/minio
|
||||
container_name: chrysopedia-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-chrysopedia}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-changeme-minio}
|
||||
volumes:
|
||||
- /vmPool/r/services/chrysopedia_minio:/data
|
||||
networks:
|
||||
- chrysopedia
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
stop_grace_period: 15s
|
||||
|
||||
# ── React web UI (nginx) ──
|
||||
chrysopedia-web:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ server {
|
|||
# after container recreates
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
|
||||
# Allow large transcript uploads (up to 50MB)
|
||||
client_max_body_size 50m;
|
||||
# Allow large file uploads (up to 100MB)
|
||||
client_max_body_size 100m;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue