inital commit, very early alpha stage
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
APP_NAME=GroupHome
|
||||
ENVIRONMENT=development
|
||||
DEV_MODE=true
|
||||
SERVER_NAME=GroupHome Local
|
||||
SERVER_ORIGIN=http://localhost:8000
|
||||
API_BASE_URL=http://localhost:8000/api
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
DATABASE_URL=sqlite:///./grouphome.db
|
||||
SESSION_SECRET=replace-this-with-a-long-random-secret
|
||||
SESSION_COOKIE_NAME=grouphome_session
|
||||
COOKIE_SECURE=false
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173
|
||||
UPLOAD_DIR=./storage/uploads
|
||||
MAX_UPLOAD_BYTES=10485760
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
@@ -23,6 +26,7 @@ var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
@@ -217,6 +221,7 @@ build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
backend/storage/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
@@ -325,4 +330,3 @@ dist
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
|
||||
171
ACCEPTANCE_TESTS.md
Normal file
171
ACCEPTANCE_TESTS.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# ACCEPTANCE_TESTS.md
|
||||
|
||||
Codex should use this file as a checklist. Implement automated tests for as many as practical and document any manual checks.
|
||||
|
||||
## Smoke tests
|
||||
|
||||
- `docker compose up --build` starts backend and frontend.
|
||||
- Backend health endpoint returns OK.
|
||||
- Frontend loads without console-breaking errors.
|
||||
- Seed data can be created with a documented command.
|
||||
|
||||
## Accountless join
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Creating invite stores only token hash.
|
||||
- Valid invite preview returns group preview.
|
||||
- Claiming invite creates/updates member, member device, and session.
|
||||
- Expired/revoked invite cannot be claimed.
|
||||
- Limited-use invite increments use count and blocks after max uses.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Opening invite link shows a polished mobile join screen.
|
||||
- User can join without email/password.
|
||||
- User can RSVP immediately after join.
|
||||
|
||||
## Structured group behavior
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Admin can create announcement.
|
||||
- Member cannot create official announcement unless permitted.
|
||||
- Admin can create event.
|
||||
- Member can RSVP to event.
|
||||
- Missing RSVP generates action item.
|
||||
- Task assigned to member generates action item.
|
||||
- Poll without member vote generates action item.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Group page defaults to dashboard, not raw chat.
|
||||
- Announcements/events/files/tasks/polls appear as cards.
|
||||
- Chat/discussions are available but secondary.
|
||||
|
||||
## Home dashboard
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- `/api/home` returns sections for needs_me, today, changed, official_updates, catch_up.
|
||||
- Action ordering prioritizes urgent/due items.
|
||||
- Local and remote objects can be represented consistently.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Home answers “What needs me?”
|
||||
- Seed demo shows at least three actionable items across groups.
|
||||
- Remote items show source server/group badge.
|
||||
|
||||
## Multi-device
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Device link start creates pending pairing code.
|
||||
- Existing session can approve pending device.
|
||||
- New device completes pairing and receives session.
|
||||
- Device list includes both devices.
|
||||
- Revoked device cannot use session.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- User can start “Link another device.”
|
||||
- Code/QR screen is understandable.
|
||||
- Existing device approval UI is clear.
|
||||
- Device management page shows current and linked devices.
|
||||
|
||||
## Recovery
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Recovery request creates hashed recovery token/code.
|
||||
- Recovery consume creates session or attaches to home profile.
|
||||
- Expired/revoked recovery token fails.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Dev-mode recovery shows/logs a usable link or code.
|
||||
- UI says “Save access”/“Recover access,” not mandatory account creation.
|
||||
|
||||
## Migration kit
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Admin can fetch migration dashboard.
|
||||
- Migration status reflects member invite/open/join/verified fields.
|
||||
- Reminder copy endpoint returns useful text with link and transition date.
|
||||
- WhatsApp export import accepts a `.txt` file and stores read-only archive messages if implemented.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Admin migration dashboard is clear.
|
||||
- Reminder copy is one-click copyable.
|
||||
- UI explains legacy channel concept.
|
||||
|
||||
## Files
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- Upload enforces auth and file size.
|
||||
- Download enforces permission.
|
||||
- Filename is sanitized.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Files page shows global and by-group files.
|
||||
- File source group/server is visible.
|
||||
|
||||
## Remote/self-hosted aggregation
|
||||
|
||||
Automated/backend:
|
||||
|
||||
- `/.well-known/group-platform.json` returns manifest.
|
||||
- `/api/sync` requires valid scoped token.
|
||||
- Connection token has scopes and expiry.
|
||||
- Home server can connect to remote server with URL + token.
|
||||
- Sync stores/caches remote structured objects.
|
||||
- `/api/home` includes remote action items after sync.
|
||||
|
||||
Manual/frontend:
|
||||
|
||||
- Connected servers page lists remote server.
|
||||
- Sync status/errors are visible.
|
||||
- Remote actions appear on Home.
|
||||
- No UI claims full federation.
|
||||
|
||||
## Responsive UI
|
||||
|
||||
Manual:
|
||||
|
||||
- 375px-wide mobile viewport is usable.
|
||||
- Bottom nav appears on mobile.
|
||||
- Desktop layout uses more horizontal space without becoming sparse.
|
||||
- Forms are readable and touch targets are adequate.
|
||||
- Empty/loading/error states are implemented.
|
||||
|
||||
## Security checklist
|
||||
|
||||
Automated or code review:
|
||||
|
||||
- Invite/recovery tokens hashed.
|
||||
- Sessions use HttpOnly cookies.
|
||||
- Long-lived tokens not stored in localStorage.
|
||||
- Role checks on admin endpoints.
|
||||
- Remote tokens stored server-side.
|
||||
- Upload validation implemented.
|
||||
- `.env.example` exists and no secrets are committed.
|
||||
|
||||
## README checklist
|
||||
|
||||
Root README must include:
|
||||
|
||||
- product concept;
|
||||
- architecture diagram/text;
|
||||
- setup commands;
|
||||
- seed demo instructions;
|
||||
- invite flow instructions;
|
||||
- device-linking instructions;
|
||||
- remote aggregation demo instructions;
|
||||
- known limitations;
|
||||
- security notes;
|
||||
- next-step roadmap.
|
||||
71
AGENTS.md
Normal file
71
AGENTS.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project mission
|
||||
|
||||
Build a browser-first group coordination platform that lowers dependence on WhatsApp-style group chats by giving organizations and members a better structured alternative: events, announcements, files, member management, tasks, polls, commitments, catch-up, and cross-group aggregation.
|
||||
|
||||
The product is not a universal chat bridge. It is an exit ramp for groups. WhatsApp or other messengers may be represented only through manual migration aids, imports, digests, and invite/reminder copy.
|
||||
|
||||
## Required stack
|
||||
|
||||
Use this stack unless the existing repository already has a strong conflicting convention:
|
||||
|
||||
- Backend: Python, FastAPI, Pydantic, SQLAlchemy, SQLite by default, optional PostgreSQL configuration.
|
||||
- Frontend: React, Vite, TypeScript.
|
||||
- Styling: mobile-first responsive UI. Prefer Tailwind CSS or a clean CSS-variable design system if Tailwind setup becomes a distraction.
|
||||
- Tests: pytest for backend; Vitest/React Testing Library for frontend where practical.
|
||||
- Packaging: Docker Compose for local development.
|
||||
|
||||
## Implementation behavior
|
||||
|
||||
- If the repository is empty, scaffold a complete monorepo.
|
||||
- Prefer a fully runnable vertical slice over many nonfunctional placeholders.
|
||||
- Do not ask product questions unless absolutely blocked. Make reasonable choices and document them.
|
||||
- Do not stop at mock screens. Implement working backend-backed flows for the core use cases.
|
||||
- Keep the browser-first/accountless join flow working at all times.
|
||||
- Make security-conscious defaults: invite token hashes, HttpOnly cookies for sessions, no long-lived auth secrets in localStorage, scoped permissions, CORS allowlists, role checks.
|
||||
- When production-grade implementation is too large for one pass, create a clean adapter/interface and a working development implementation. Mark the gap clearly in code and README.
|
||||
- Run formatting/tests/builds where possible before finishing.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not implement:
|
||||
|
||||
- Full server-to-server federation.
|
||||
- A native mobile app.
|
||||
- Unofficial WhatsApp, Signal, iMessage, Facebook, or WeChat scraping/bridging.
|
||||
- Social feed mechanics unrelated to group coordination.
|
||||
- Password-first sign-up.
|
||||
- Mandatory login before a user can open an invite and complete the first useful action.
|
||||
- Production E2EE unless specifically requested in a later task.
|
||||
|
||||
## Product principles
|
||||
|
||||
1. The group, not the individual account, is the migration unit.
|
||||
2. Account creation must be separate from membership identity.
|
||||
3. The first useful action must happen before registration friction.
|
||||
4. Chat is present but demoted. Structured objects are first-class.
|
||||
5. The home screen answers: “What needs my attention?”
|
||||
6. Users with multiple groups need aggregation, not just another inbox.
|
||||
7. Self-hosting must be possible without forcing full federation.
|
||||
8. Portability and export are part of the product promise.
|
||||
|
||||
## UX principles
|
||||
|
||||
- Mobile-first. The first session likely starts from a link in a phone messenger.
|
||||
- The UI should feel more like a group command center than a chat clone.
|
||||
- Top-level navigation should prioritize: Home, Calendar, Groups, Files, Me.
|
||||
- Chat should live inside groups and threads, not as the primary navigation item.
|
||||
- Separate “official” from “chatter.”
|
||||
- Use clear empty states, skeletons, and friendly recovery paths.
|
||||
- Avoid jargon such as OAuth, WebAuthn, passkey, token, federation in user-facing copy unless necessary.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- Use clear module boundaries: auth, groups, structured objects, aggregation, remote servers, files, notifications.
|
||||
- Use UUID primary identifiers externally. Internal numeric IDs are acceptable only if not exposed casually.
|
||||
- Validate all API inputs with Pydantic schemas.
|
||||
- Never store raw invite tokens. Store token hashes.
|
||||
- Use environment-driven configuration.
|
||||
- Include seed data for demonstration.
|
||||
- Include a README with setup, architecture, and known limitations.
|
||||
501
ARCHITECTURE_CONTRACTS.md
Normal file
501
ARCHITECTURE_CONTRACTS.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# ARCHITECTURE_CONTRACTS.md
|
||||
|
||||
## Architecture overview
|
||||
|
||||
The same FastAPI server can operate as:
|
||||
|
||||
```text
|
||||
local group server
|
||||
home server
|
||||
both
|
||||
```
|
||||
|
||||
The React frontend talks primarily to one selected home server. The home server stores local memberships and remote server connection configuration. It aggregates structured objects from connected remote group servers.
|
||||
|
||||
```text
|
||||
Browser/PWA
|
||||
|
|
||||
| HTTPS/API + HttpOnly session cookie
|
||||
v
|
||||
Home Server
|
||||
| owns home profile, devices, preferences, remote config
|
||||
| fetches remote structured sync using scoped server-side tokens
|
||||
v
|
||||
Remote Group Servers
|
||||
| own canonical groups and group data
|
||||
| expose /.well-known/group-platform.json and /api/sync
|
||||
```
|
||||
|
||||
No server-to-server federation is required. Remote group servers do not know about each other.
|
||||
|
||||
## Browser storage policy
|
||||
|
||||
Allowed in localStorage:
|
||||
|
||||
- last selected home server URL;
|
||||
- UI preferences;
|
||||
- theme choice;
|
||||
- non-sensitive onboarding flags;
|
||||
- cached non-sensitive IDs for convenience.
|
||||
|
||||
Not allowed in localStorage:
|
||||
|
||||
- raw invite tokens after claim;
|
||||
- session tokens;
|
||||
- remote server access tokens;
|
||||
- recovery tokens;
|
||||
- admin credentials.
|
||||
|
||||
Use HttpOnly cookies for sessions. Use server-side storage for remote connection credentials.
|
||||
|
||||
## Core entity relationships
|
||||
|
||||
```text
|
||||
HomeProfile 1..n HomeDevice
|
||||
HomeProfile 1..n RecoveryMethod
|
||||
HomeProfile 1..n RemoteServerConnection
|
||||
HomeProfile 0..n Member via Member.home_profile_id
|
||||
|
||||
Group 1..n Member
|
||||
Group 1..n MemberInvite
|
||||
Group 1..n Announcement
|
||||
Group 1..n Event
|
||||
Event 1..n EventRsvp
|
||||
Group 1..n Task
|
||||
Group 1..n Poll
|
||||
Poll 1..n PollOption
|
||||
PollOption 1..n PollVote
|
||||
Group 1..n FileAsset
|
||||
Group 1..n Thread
|
||||
Thread 1..n Message
|
||||
|
||||
Member 1..n MemberDevice
|
||||
Member 1..n ActionItem
|
||||
Member 1..n Notification
|
||||
|
||||
RemoteServerConnection 1..n RemoteMembership
|
||||
RemoteServerConnection 1..n RemoteSyncCursor
|
||||
RemoteServerConnection 1..n RemoteCachedObject
|
||||
```
|
||||
|
||||
A `Member` can exist without a `HomeProfile`. This is required for accountless invite-link joining. Later, a member may be attached to a home profile.
|
||||
|
||||
## Suggested SQLAlchemy fields
|
||||
|
||||
Use exact names where practical.
|
||||
|
||||
### HomeProfile
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
primary_display_name: string
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_seen_at: datetime nullable
|
||||
status: active/disabled
|
||||
```
|
||||
|
||||
### HomeDevice
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
home_profile_id: UUID nullable
|
||||
member_id: UUID nullable
|
||||
label: string
|
||||
user_agent_summary: string
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
revoked_at: datetime nullable
|
||||
trust_level: invite_member/claimed_browser/verified/passkey_ready
|
||||
```
|
||||
|
||||
### RecoveryMethod
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
home_profile_id: UUID
|
||||
kind: email/phone/recovery_code/passkey
|
||||
value_hash: string nullable
|
||||
display_hint: string
|
||||
verified_at: datetime nullable
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### Session
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
home_profile_id: UUID nullable
|
||||
member_id: UUID nullable
|
||||
home_device_id: UUID nullable
|
||||
member_device_id: UUID nullable
|
||||
csrf_token_hash: string
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
revoked_at: datetime nullable
|
||||
```
|
||||
|
||||
### Group
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
server_origin: string
|
||||
name: string
|
||||
description: text
|
||||
visibility: private/public/listed
|
||||
legacy_channel_status: none/transition/legacy
|
||||
transition_deadline: date nullable
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
archived_at: datetime nullable
|
||||
```
|
||||
|
||||
### Member
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
home_profile_id: UUID nullable
|
||||
display_name: string
|
||||
role: owner/admin/moderator/member/guest
|
||||
status: invited/opened/joined/verified/suspended/left
|
||||
joined_at: datetime nullable
|
||||
last_seen_at: datetime nullable
|
||||
notification_enabled_at: datetime nullable
|
||||
```
|
||||
|
||||
### MemberInvite
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
member_id: UUID nullable
|
||||
created_by_member_id: UUID
|
||||
label: string
|
||||
scope: specific_member/open_seat/admin_invite
|
||||
permission_role: guest/member/admin
|
||||
plain_token_display_once: not persisted
|
||||
token_hash: string
|
||||
expires_at: datetime nullable
|
||||
max_uses: int
|
||||
use_count: int
|
||||
consumed_at: datetime nullable
|
||||
revoked_at: datetime nullable
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### Announcement
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
author_member_id: UUID
|
||||
title: string
|
||||
body: text
|
||||
priority: normal/urgent
|
||||
official: bool
|
||||
requires_ack: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Event
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
created_by_member_id: UUID
|
||||
title: string
|
||||
description: text nullable
|
||||
starts_at: datetime
|
||||
ends_at: datetime nullable
|
||||
location_name: string nullable
|
||||
location_address: string nullable
|
||||
rsvp_required: bool
|
||||
changed_at: datetime nullable
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### EventRsvp
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
event_id: UUID
|
||||
member_id: UUID
|
||||
status: yes/no/maybe/unknown
|
||||
note: text nullable
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Task
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
created_by_member_id: UUID
|
||||
assigned_to_member_id: UUID nullable
|
||||
title: string
|
||||
description: text nullable
|
||||
due_at: datetime nullable
|
||||
status: open/done/cancelled
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Poll / PollOption / PollVote
|
||||
|
||||
```text
|
||||
Poll: id, group_id, title, description, closes_at, status, created_by_member_id, created_at
|
||||
PollOption: id, poll_id, label, position
|
||||
PollVote: id, poll_id, option_id, member_id, created_at
|
||||
```
|
||||
|
||||
### FileAsset
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
group_id: UUID
|
||||
uploaded_by_member_id: UUID
|
||||
filename_original: string
|
||||
filename_stored: string
|
||||
content_type: string
|
||||
size_bytes: int
|
||||
storage_path: string
|
||||
visibility: members/admins/public_link
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### Thread / Message
|
||||
|
||||
```text
|
||||
Thread: id, group_id, title, kind discussion/question/archive, created_by_member_id, created_at, updated_at
|
||||
Message: id, thread_id, author_member_id, body, created_at, edited_at nullable, deleted_at nullable
|
||||
```
|
||||
|
||||
### ActionItem
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
home_profile_id: UUID nullable
|
||||
member_id: UUID nullable
|
||||
remote_connection_id: UUID nullable
|
||||
source_type: local/remote
|
||||
source_server_origin: string
|
||||
source_group_id: string
|
||||
source_group_name: string
|
||||
type: rsvp_required/vote_required/task_assigned/event_changed/file_ack/direct_mention/admin_request
|
||||
status: open/done/dismissed
|
||||
priority: low/normal/high/urgent
|
||||
title: string
|
||||
summary: text
|
||||
object_type: event/task/poll/file/announcement/message
|
||||
object_id: string
|
||||
due_at: datetime nullable
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### RemoteServerConnection
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
home_profile_id: UUID
|
||||
server_origin: string
|
||||
server_name: string
|
||||
api_base: string
|
||||
protocol_version: string
|
||||
capabilities_json: json
|
||||
access_token_encrypted: string
|
||||
status: active/error/revoked
|
||||
last_sync_at: datetime nullable
|
||||
last_error: text nullable
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### RemoteCachedObject
|
||||
|
||||
```text
|
||||
id: UUID
|
||||
remote_connection_id: UUID
|
||||
object_type: action/event/announcement/file/thread
|
||||
remote_id: string
|
||||
group_remote_id: string
|
||||
group_name: string
|
||||
payload_json: json
|
||||
updated_at_remote: datetime nullable
|
||||
cached_at: datetime
|
||||
```
|
||||
|
||||
## API contracts
|
||||
|
||||
Use JSON. Use ISO 8601 datetimes. Use consistent error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "permission_denied",
|
||||
"message": "You do not have permission to do that.",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invite preview
|
||||
|
||||
```http
|
||||
GET /api/join/{token}/preview
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"group": {
|
||||
"id": "...",
|
||||
"name": "FC Kreuzberg U12 Parents",
|
||||
"description": "Planning, matches, files, and announcements."
|
||||
},
|
||||
"invite": {
|
||||
"label": "Parent invite",
|
||||
"expires_at": null,
|
||||
"role": "member"
|
||||
},
|
||||
"preview": {
|
||||
"announcements": [],
|
||||
"events": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claim invite
|
||||
|
||||
```http
|
||||
POST /api/auth/invite/{token}/claim
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "Anna Müller",
|
||||
"device_label": "iPhone Safari"
|
||||
}
|
||||
```
|
||||
|
||||
Response sets session cookie and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"member": {},
|
||||
"group": {},
|
||||
"next_steps": ["save_access", "enable_notifications"]
|
||||
}
|
||||
```
|
||||
|
||||
### Home dashboard
|
||||
|
||||
```http
|
||||
GET /api/home
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {},
|
||||
"sections": {
|
||||
"needs_me": [],
|
||||
"today": [],
|
||||
"changed": [],
|
||||
"official_updates": [],
|
||||
"catch_up": []
|
||||
},
|
||||
"connections": []
|
||||
}
|
||||
```
|
||||
|
||||
### Sync endpoint
|
||||
|
||||
```http
|
||||
GET /api/sync?since=cursor
|
||||
Authorization: Bearer <scoped-token>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "next-cursor",
|
||||
"server_time": "2026-06-29T10:00:00Z",
|
||||
"actions": [],
|
||||
"events": [],
|
||||
"announcements": [],
|
||||
"files": [],
|
||||
"threads": []
|
||||
}
|
||||
```
|
||||
|
||||
## Remote aggregation behavior
|
||||
|
||||
The home server stores a cursor per remote connection. Sync should be idempotent.
|
||||
|
||||
Remote cached objects should include enough payload to render in the Home dashboard without live remote calls every time. When a user performs a write action on a remote object, the MVP can open the remote group page in a new tab if write-through is not implemented. Implement write-through for RSVP if feasible.
|
||||
|
||||
## Derived action logic
|
||||
|
||||
Generate local action items from structured state:
|
||||
|
||||
- Event with `rsvp_required=true` and no RSVP by current member → `rsvp_required`.
|
||||
- Task assigned to current member and status `open` → `task_assigned`.
|
||||
- Poll open with no vote by current member → `vote_required`.
|
||||
- Event changed after member's last seen time → `event_changed`.
|
||||
- Announcement requires acknowledgement and no acknowledgement exists → `file_ack` or future `announcement_ack`.
|
||||
|
||||
It is acceptable to recompute on request in MVP, but persist/cache for remote sync.
|
||||
|
||||
## Permissions contract
|
||||
|
||||
Implement a simple helper:
|
||||
|
||||
```python
|
||||
require_role(member, group, min_role="admin")
|
||||
can(member, action, resource)
|
||||
```
|
||||
|
||||
Role hierarchy:
|
||||
|
||||
```text
|
||||
guest < member < moderator < admin < owner
|
||||
```
|
||||
|
||||
Make role checks visible in route handlers or service methods.
|
||||
|
||||
## Development remote-server demo
|
||||
|
||||
Provide a way to run two local instances:
|
||||
|
||||
```text
|
||||
home server: http://localhost:8000
|
||||
remote server: http://localhost:8001
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- docker-compose service duplication; or
|
||||
- README command using different ports and DB paths.
|
||||
|
||||
Seed each with distinct server names and groups. Show how to create a remote connection token on server 8001 and connect it from server 8000.
|
||||
|
||||
## Production limitations to document
|
||||
|
||||
Document these limitations in README:
|
||||
|
||||
- Dev email sends to console unless SMTP configured.
|
||||
- Passkey implementation may be development/pluggable if not fully implemented.
|
||||
- Web push may be a future adapter.
|
||||
- No full federation.
|
||||
- No end-to-end encryption.
|
||||
- Remote sync tokens require secure secret management in production.
|
||||
- File storage is local filesystem in dev; production should use S3-compatible storage or mounted volume.
|
||||
656
CODEX_TASK.md
Normal file
656
CODEX_TASK.md
Normal file
@@ -0,0 +1,656 @@
|
||||
# CODEX_TASK.md
|
||||
|
||||
You are Codex. Build this project from scratch as a runnable monorepo.
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a browser-first, mobile-first group coordination platform that helps groups migrate away from WhatsApp-style chaotic chats by replacing the stream with structured coordination: events, announcements, files, member management, tasks, polls, commitments, catch-up, notification preferences, and cross-group aggregation.
|
||||
|
||||
The product must support:
|
||||
|
||||
- Accountless invite-link joining.
|
||||
- Later device recovery through email, passkey-compatible architecture, QR/device linking, or admin resend.
|
||||
- A home-server concept: each user can have a home profile on a home server that stores their group memberships and remote server connection config.
|
||||
- Multiple self-hosted servers without full federation.
|
||||
- Aggregation across servers through scoped remote connections.
|
||||
- A responsive React/Vite UI optimized for phone browsers first.
|
||||
- A FastAPI backend that can run as both a home server and a group server.
|
||||
|
||||
Do not implement full federation. Do not implement native apps. Do not implement unofficial messenger bridges.
|
||||
|
||||
## Deliverables
|
||||
|
||||
Create a repository with this shape:
|
||||
|
||||
```text
|
||||
.
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py
|
||||
│ │ ├── core/
|
||||
│ │ ├── db/
|
||||
│ │ ├── models/
|
||||
│ │ ├── schemas/
|
||||
│ │ ├── routers/
|
||||
│ │ ├── services/
|
||||
│ │ └── tests/
|
||||
│ ├── pyproject.toml
|
||||
│ └── README.md
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── app/
|
||||
│ │ ├── components/
|
||||
│ │ ├── features/
|
||||
│ │ ├── routes/
|
||||
│ │ ├── api/
|
||||
│ │ ├── styles/
|
||||
│ │ └── test/
|
||||
│ ├── package.json
|
||||
│ └── README.md
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── README.md
|
||||
└── AGENTS.md
|
||||
```
|
||||
|
||||
Use a different clean structure only if there is a strong technical reason. Document the reason.
|
||||
|
||||
## Development commands
|
||||
|
||||
Implement commands equivalent to:
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
docker compose up --build
|
||||
|
||||
# backend only
|
||||
cd backend
|
||||
python -m app.db.seed
|
||||
pytest
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# frontend only
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
Prefer modern package tooling, but do not let tooling block the implementation.
|
||||
|
||||
## Core product behavior
|
||||
|
||||
### 1. Accountless join flow
|
||||
|
||||
A group admin can create a group and generate invite links.
|
||||
|
||||
A recipient opens an invite link in a browser and can immediately:
|
||||
|
||||
- see the group name, purpose, and limited preview;
|
||||
- join with a display name;
|
||||
- read official announcements;
|
||||
- see upcoming events;
|
||||
- RSVP to an event;
|
||||
- participate in chat/comments if permissions allow;
|
||||
- choose notification/recovery options later.
|
||||
|
||||
Do not require email, password, social login, or native app install before first useful action.
|
||||
|
||||
Implement:
|
||||
|
||||
- Invite tokens stored as hashes.
|
||||
- One-time or limited-use invite links.
|
||||
- Session cookie issued after invite claim.
|
||||
- Device record attached to the member.
|
||||
- Revocation/reset by admin.
|
||||
|
||||
### 2. Progressive identity
|
||||
|
||||
Users start as membership-bound participants. They may later create or attach a home profile.
|
||||
|
||||
Implement trust levels:
|
||||
|
||||
```text
|
||||
Guest link can view public/limited content
|
||||
Invite member joined via invite link
|
||||
Claimed browser browser session/device is attached
|
||||
Verified contact email verified for recovery/notifications
|
||||
Passkey-ready model/API/UI support for passkey registration/login
|
||||
Full home profile aggregates multiple memberships and servers
|
||||
```
|
||||
|
||||
If full WebAuthn/passkey implementation is feasible in the pass, implement it. Otherwise create clean backend routes, frontend UI, and a pluggable `PasskeyProvider` interface with a development provider. The rest of the product must not depend on passkeys working in production mode.
|
||||
|
||||
User-facing copy should say “Save access on this device” or “Protect access,” not “Create account” as the first step.
|
||||
|
||||
### 3. Multi-device access
|
||||
|
||||
Implement at least two working paths:
|
||||
|
||||
1. **Recovery link** by email in development mode. If SMTP is not configured, log the link/code to the backend console and show a dev hint in the UI.
|
||||
2. **Link another device** via QR/code pairing:
|
||||
- new device starts pairing and receives a code;
|
||||
- existing device approves the pending code;
|
||||
- new device completes pairing and receives a session;
|
||||
- the device list shows both devices;
|
||||
- user/admin can revoke a device.
|
||||
|
||||
Model the architecture so passkey sign-in can be added cleanly.
|
||||
|
||||
### 4. Structured group objects
|
||||
|
||||
Implement these first-class objects:
|
||||
|
||||
- Announcements
|
||||
- Events
|
||||
- RSVPs
|
||||
- Tasks
|
||||
- Polls
|
||||
- Files
|
||||
- Threads/messages
|
||||
- Members
|
||||
- Devices
|
||||
- Notification preferences
|
||||
- Commitments/action items
|
||||
- Migration status
|
||||
|
||||
Chat exists, but it is not the product center. Structured cards should appear in the group dashboard and home dashboard.
|
||||
|
||||
### 5. Home dashboard for multi-group users
|
||||
|
||||
The home page must answer “What needs my attention?” across all groups and connected servers.
|
||||
|
||||
Implement sections:
|
||||
|
||||
```text
|
||||
Needs me
|
||||
Today / Upcoming
|
||||
Changed since last visit
|
||||
Official updates
|
||||
Catch up
|
||||
```
|
||||
|
||||
Action item examples:
|
||||
|
||||
- missing RSVP;
|
||||
- task assigned to me;
|
||||
- poll I have not answered;
|
||||
- event changed;
|
||||
- file requiring acknowledgement;
|
||||
- direct mention or admin question.
|
||||
|
||||
The dashboard should group by urgency and consequence, not only chronology.
|
||||
|
||||
### 6. Group dashboard
|
||||
|
||||
The group page must not default to a raw chat feed.
|
||||
|
||||
Implement a group dashboard with:
|
||||
|
||||
```text
|
||||
Important now
|
||||
Upcoming events
|
||||
Open actions
|
||||
Official announcements
|
||||
Files
|
||||
Active discussions
|
||||
Members / admin tools depending on role
|
||||
```
|
||||
|
||||
Chat or discussion threads can be one tab/section, but not the first or only concept.
|
||||
|
||||
### 7. Calendar
|
||||
|
||||
Implement a cross-group calendar view:
|
||||
|
||||
- all events from local memberships;
|
||||
- events from connected remote servers after sync;
|
||||
- RSVP status;
|
||||
- event location;
|
||||
- attached files;
|
||||
- changed-event indicators;
|
||||
- simple list view optimized for mobile.
|
||||
|
||||
Calendar export can be a later enhancement. Add TODO or stub if not implemented.
|
||||
|
||||
### 8. Files
|
||||
|
||||
Implement file metadata and upload/download for local groups.
|
||||
|
||||
Minimum:
|
||||
|
||||
- upload file to local storage in dev mode;
|
||||
- show file list by group and globally;
|
||||
- role/permission checks;
|
||||
- file attached to event/announcement when useful;
|
||||
- safe filenames and size limits.
|
||||
|
||||
### 9. Migration kit
|
||||
|
||||
Implement practical migration features:
|
||||
|
||||
- admin-created invite link/QR-ready URL;
|
||||
- member migration status: invited, opened, joined, verified, notification-enabled;
|
||||
- WhatsApp reminder copy generator;
|
||||
- “legacy channel” status flag;
|
||||
- optional WhatsApp chat export importer for `.txt` export files, stored as an archive, not as live bridged chat.
|
||||
|
||||
The app must never require or use WhatsApp APIs.
|
||||
|
||||
### 10. Notification preferences
|
||||
|
||||
Implement data model and UI for notification preferences:
|
||||
|
||||
```text
|
||||
Immediate:
|
||||
- direct mentions
|
||||
- event changes
|
||||
- urgent admin announcements
|
||||
- tasks assigned to me
|
||||
|
||||
Quiet / digest:
|
||||
- discussions
|
||||
- new files
|
||||
- photos/general chatter
|
||||
|
||||
Mute:
|
||||
- reactions/off-topic messages
|
||||
```
|
||||
|
||||
Backend should store preferences. Implement in-app notification behavior and email dev-mode notifications/digests if feasible. Web push can be a future adapter if not implemented.
|
||||
|
||||
### 11. Home server and self-hosted aggregation
|
||||
|
||||
Every server instance can act as:
|
||||
|
||||
- a group server: owns canonical groups and their data;
|
||||
- a home server: stores a user's home profile and remote server connection config;
|
||||
- both.
|
||||
|
||||
A user belongs to a home server. The home server stores config for remote servers.
|
||||
|
||||
Implement these concepts:
|
||||
|
||||
```text
|
||||
HomeProfile
|
||||
RemoteServerConnection
|
||||
RemoteMembership
|
||||
RemoteSyncCursor
|
||||
RemoteCapability
|
||||
RemoteActionItem cache
|
||||
```
|
||||
|
||||
A remote group server exposes a structured sync API. A home server connects to it with a scoped token and aggregates structured objects.
|
||||
|
||||
Important: this is **not federation**. Servers do not replicate groups peer-to-peer. A home server simply acts as a client to the remote servers selected by the user.
|
||||
|
||||
Implement a minimal remote connection flow:
|
||||
|
||||
1. Remote server exposes `/.well-known/group-platform.json`.
|
||||
2. Remote server can create a scoped connection token for a member/admin in dev mode.
|
||||
3. Home server connects to a remote server using URL + token.
|
||||
4. Home server fetches capabilities.
|
||||
5. Home server syncs actions/events/announcements/files/threads through `/api/sync`.
|
||||
6. Home dashboard displays both local and remote objects.
|
||||
7. Remote objects are marked clearly with their source server/group.
|
||||
|
||||
Store sensitive remote tokens server-side, not in browser localStorage. Use localStorage only for non-sensitive hints such as last selected home server URL or UI preferences.
|
||||
|
||||
### 12. Open protocol seed
|
||||
|
||||
Expose:
|
||||
|
||||
```text
|
||||
GET /.well-known/group-platform.json
|
||||
GET /api/sync?since=<cursor>
|
||||
```
|
||||
|
||||
The manifest should contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_name": "Example Group Server",
|
||||
"api_base": "http://localhost:8000/api",
|
||||
"protocol_version": "0.1",
|
||||
"capabilities": {
|
||||
"events": true,
|
||||
"tasks": true,
|
||||
"files": true,
|
||||
"chat": true,
|
||||
"polls": true,
|
||||
"federation": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The sync endpoint should return structured objects, not just raw chat messages:
|
||||
|
||||
```json
|
||||
{
|
||||
"cursor": "next_cursor",
|
||||
"actions": [],
|
||||
"events": [],
|
||||
"announcements": [],
|
||||
"files": [],
|
||||
"threads": []
|
||||
}
|
||||
```
|
||||
|
||||
### 13. Responsive UI
|
||||
|
||||
Build a polished mobile-first UI.
|
||||
|
||||
Required navigation:
|
||||
|
||||
```text
|
||||
Home
|
||||
Calendar
|
||||
Groups
|
||||
Files
|
||||
Me
|
||||
```
|
||||
|
||||
Required screens:
|
||||
|
||||
- Join invite screen
|
||||
- Home dashboard
|
||||
- Calendar
|
||||
- Group list
|
||||
- Group dashboard
|
||||
- Group discussions/chat
|
||||
- Group admin / migration dashboard
|
||||
- Event detail + RSVP
|
||||
- Announcement detail
|
||||
- Task detail
|
||||
- Poll detail
|
||||
- File list and upload
|
||||
- Me/profile
|
||||
- Devices
|
||||
- Notification preferences
|
||||
- Connected servers
|
||||
- Connect remote server
|
||||
|
||||
Design requirements:
|
||||
|
||||
- bottom navigation on mobile;
|
||||
- responsive desktop layout with side navigation;
|
||||
- cards for action items, events, announcements, files, and discussions;
|
||||
- clear “official” vs “discussion” visual distinction;
|
||||
- accessible form labels and keyboard behavior;
|
||||
- loading and empty states;
|
||||
- seed-data demo should look credible immediately.
|
||||
|
||||
Use a coherent design system: spacing scale, typography, elevation, badges, status colors, rounded cards, and compact mobile layouts.
|
||||
|
||||
### 14. Seed data
|
||||
|
||||
Create seed data that demonstrates the concept:
|
||||
|
||||
- FC Kreuzberg U12 Parents
|
||||
- Class 4B Parents
|
||||
- Tenant Association
|
||||
- Food Bank Volunteers
|
||||
|
||||
Include users/members:
|
||||
|
||||
- Anna Müller as the main demo user
|
||||
- Coach Mark
|
||||
- Lisa Becker
|
||||
- Samir Khan
|
||||
- Priya N.
|
||||
- Tenant admin
|
||||
|
||||
Include events:
|
||||
|
||||
- football match with missing RSVP;
|
||||
- training location changed;
|
||||
- parent evening;
|
||||
- tenant vote deadline;
|
||||
- volunteer shift.
|
||||
|
||||
Include actions:
|
||||
|
||||
- RSVP required;
|
||||
- vote required;
|
||||
- task assigned;
|
||||
- file acknowledgement;
|
||||
- direct mention.
|
||||
|
||||
Include files:
|
||||
|
||||
- season schedule;
|
||||
- emergency contacts;
|
||||
- meeting minutes;
|
||||
- school form.
|
||||
|
||||
Include discussions:
|
||||
|
||||
- snack coordination;
|
||||
- carpool planning;
|
||||
- maintenance issue;
|
||||
- volunteer supplies.
|
||||
|
||||
### 15. API shape
|
||||
|
||||
Implement or approximate these endpoints.
|
||||
|
||||
Auth/session:
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/me
|
||||
POST /api/auth/invite/{token}/claim
|
||||
GET /api/join/{token}/preview
|
||||
POST /api/auth/recovery/request
|
||||
POST /api/auth/recovery/consume
|
||||
POST /api/auth/device-link/start
|
||||
POST /api/auth/device-link/approve
|
||||
POST /api/auth/device-link/complete
|
||||
GET /api/me/devices
|
||||
DELETE /api/me/devices/{device_id}
|
||||
POST /api/auth/passkeys/register/options
|
||||
POST /api/auth/passkeys/register/verify
|
||||
POST /api/auth/passkeys/login/options
|
||||
POST /api/auth/passkeys/login/verify
|
||||
```
|
||||
|
||||
Home/aggregation:
|
||||
|
||||
```text
|
||||
GET /api/home
|
||||
GET /api/home/actions
|
||||
GET /api/home/calendar
|
||||
GET /api/home/files
|
||||
GET /api/home/official-updates
|
||||
GET /api/home/catch-up
|
||||
```
|
||||
|
||||
Groups:
|
||||
|
||||
```text
|
||||
GET /api/groups
|
||||
POST /api/groups
|
||||
GET /api/groups/{group_id}
|
||||
PATCH /api/groups/{group_id}
|
||||
POST /api/groups/{group_id}/invites
|
||||
GET /api/groups/{group_id}/members
|
||||
PATCH /api/groups/{group_id}/members/{member_id}
|
||||
```
|
||||
|
||||
Structured objects:
|
||||
|
||||
```text
|
||||
GET /api/groups/{group_id}/announcements
|
||||
POST /api/groups/{group_id}/announcements
|
||||
GET /api/groups/{group_id}/events
|
||||
POST /api/groups/{group_id}/events
|
||||
POST /api/events/{event_id}/rsvp
|
||||
GET /api/groups/{group_id}/tasks
|
||||
POST /api/groups/{group_id}/tasks
|
||||
PATCH /api/tasks/{task_id}
|
||||
GET /api/groups/{group_id}/polls
|
||||
POST /api/groups/{group_id}/polls
|
||||
POST /api/polls/{poll_id}/vote
|
||||
GET /api/groups/{group_id}/files
|
||||
POST /api/groups/{group_id}/files
|
||||
GET /api/files/{file_id}/download
|
||||
GET /api/groups/{group_id}/threads
|
||||
POST /api/groups/{group_id}/threads
|
||||
POST /api/threads/{thread_id}/messages
|
||||
```
|
||||
|
||||
Migration:
|
||||
|
||||
```text
|
||||
GET /api/groups/{group_id}/migration
|
||||
POST /api/groups/{group_id}/migration/reminder-copy
|
||||
POST /api/groups/{group_id}/migration/import-whatsapp-export
|
||||
```
|
||||
|
||||
Notifications/preferences:
|
||||
|
||||
```text
|
||||
GET /api/me/notification-preferences
|
||||
PATCH /api/me/notification-preferences
|
||||
GET /api/me/notifications
|
||||
PATCH /api/me/notifications/{notification_id}/read
|
||||
```
|
||||
|
||||
Remote/self-hosted aggregation:
|
||||
|
||||
```text
|
||||
GET /.well-known/group-platform.json
|
||||
GET /api/sync?since=<cursor>
|
||||
POST /api/connection-tokens
|
||||
GET /api/remote/servers
|
||||
POST /api/remote/servers/connect
|
||||
POST /api/remote/servers/{connection_id}/sync
|
||||
DELETE /api/remote/servers/{connection_id}
|
||||
```
|
||||
|
||||
### 16. Data model
|
||||
|
||||
Use a practical relational schema. Include at least:
|
||||
|
||||
```text
|
||||
HomeProfile
|
||||
HomeDevice
|
||||
RecoveryMethod
|
||||
Session
|
||||
Group
|
||||
Member
|
||||
MemberDevice
|
||||
MemberInvite
|
||||
Announcement
|
||||
Event
|
||||
EventRsvp
|
||||
Task
|
||||
Poll
|
||||
PollOption
|
||||
PollVote
|
||||
FileAsset
|
||||
Thread
|
||||
Message
|
||||
NotificationPreference
|
||||
Notification
|
||||
ActionItem
|
||||
MigrationState
|
||||
AuditLog
|
||||
RemoteServerConnection
|
||||
RemoteMembership
|
||||
RemoteSyncCursor
|
||||
RemoteCachedObject
|
||||
ConnectionToken
|
||||
```
|
||||
|
||||
A user can have a HomeProfile, but joining a group must not require one. A Member can exist without a HomeProfile. Later, a Member can attach to a HomeProfile.
|
||||
|
||||
### 17. Permissions
|
||||
|
||||
Implement simple roles:
|
||||
|
||||
```text
|
||||
owner
|
||||
admin
|
||||
moderator
|
||||
member
|
||||
guest
|
||||
```
|
||||
|
||||
Enforce at least:
|
||||
|
||||
- only admins/owners can create invite links;
|
||||
- only admins/owners/moderators can create official announcements;
|
||||
- members can RSVP, vote, comment, and complete their tasks;
|
||||
- file upload can be configured but default to members;
|
||||
- migration dashboard is admin-only;
|
||||
- remote connection tokens are scoped.
|
||||
|
||||
### 18. Security requirements
|
||||
|
||||
- Store invite tokens and recovery tokens as hashes.
|
||||
- Use HttpOnly, Secure-in-production, SameSite cookies for sessions.
|
||||
- Add CSRF protection for cookie-authenticated mutating requests, or document and implement a safe dev alternative.
|
||||
- Use server-side storage for remote tokens.
|
||||
- Do not place long-lived remote access tokens in localStorage.
|
||||
- Validate upload size/type.
|
||||
- Sanitize filenames.
|
||||
- Add audit logs for admin actions, invite creation, device linking, token creation, remote connection changes.
|
||||
- Provide `.env.example` with secrets and configuration.
|
||||
- Make dev mode explicit.
|
||||
|
||||
### 19. README
|
||||
|
||||
Create a root README that explains:
|
||||
|
||||
- product concept;
|
||||
- what is implemented;
|
||||
- quick start;
|
||||
- seed accounts/flows;
|
||||
- how to join via invite link;
|
||||
- how to link another device;
|
||||
- how to create a remote connection between two local server instances;
|
||||
- architecture overview;
|
||||
- security model;
|
||||
- limitations and next steps.
|
||||
|
||||
### 20. Quality bar
|
||||
|
||||
The final app must be demonstrable without manually editing the database.
|
||||
|
||||
At minimum, after setup:
|
||||
|
||||
1. A user can open the frontend.
|
||||
2. Seed data appears.
|
||||
3. The user can use a demo invite link and join a group without account creation.
|
||||
4. The user can RSVP to an event.
|
||||
5. The user can see a cross-group action dashboard.
|
||||
6. An admin can create an invite and view migration status.
|
||||
7. A user can link a second device/session through the pairing flow.
|
||||
8. A user can connect a remote self-hosted server with URL + token and sync structured objects.
|
||||
9. The UI works well on a phone-sized viewport.
|
||||
10. Backend tests cover auth/join, permissions, actions, and remote sync.
|
||||
|
||||
## Suggested implementation order
|
||||
|
||||
1. Scaffold backend/frontend/docker.
|
||||
2. Implement DB models and seed data.
|
||||
3. Implement session/auth primitives and invite flow.
|
||||
4. Implement groups, members, announcements, events, RSVPs.
|
||||
5. Implement home dashboard/action derivation.
|
||||
6. Implement group dashboard UI.
|
||||
7. Implement files, tasks, polls, threads/messages.
|
||||
8. Implement devices/recovery/device linking.
|
||||
9. Implement migration dashboard and reminder copy generator.
|
||||
10. Implement remote server manifest, connection token, sync endpoint, home aggregation.
|
||||
11. Polish UI and responsive behavior.
|
||||
12. Add tests and README.
|
||||
|
||||
If time is limited, prioritize vertical completeness in this order:
|
||||
|
||||
```text
|
||||
invite join → group dashboard → event RSVP → home actions → admin migration → device linking → remote aggregation
|
||||
```
|
||||
|
||||
Do not spend all effort on the chat feature. The product is structured coordination.
|
||||
329
PRODUCT_SPEC.md
Normal file
329
PRODUCT_SPEC.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# PRODUCT_SPEC.md
|
||||
|
||||
## Product name placeholder
|
||||
|
||||
Use a neutral working name in code and UI, such as **GroupHome**. Keep it easy to rename.
|
||||
|
||||
## Product thesis
|
||||
|
||||
Groups remain stuck on WhatsApp-like messengers because switching costs are social, not technical. The product lowers switching costs by making the group itself movable and by making the new home materially better than chat: structured events, files, announcements, member management, tasks, polls, commitments, migration tracking, browser-first access, and no mandatory account on first use.
|
||||
|
||||
The product must not become a universal inbox. It must become a command center for group life.
|
||||
|
||||
## Target users
|
||||
|
||||
Primary:
|
||||
|
||||
- sports clubs;
|
||||
- school and parent groups;
|
||||
- tenant associations;
|
||||
- volunteer organizations;
|
||||
- local campaigns and civic groups;
|
||||
- small professional or nonprofit communities.
|
||||
|
||||
Secondary:
|
||||
|
||||
- members who belong to several groups and need a unified action view;
|
||||
- admins who want to leave WhatsApp without excluding people.
|
||||
|
||||
## Positioning
|
||||
|
||||
For organizations:
|
||||
|
||||
> Run your group professionally without forcing members onto WhatsApp.
|
||||
|
||||
For members:
|
||||
|
||||
> See what matters across all your groups without reading every chat.
|
||||
|
||||
For admins:
|
||||
|
||||
> Announcements, events, files, RSVPs, and members in one place.
|
||||
|
||||
For anti-lock-in positioning:
|
||||
|
||||
> Your group should not depend on one company's messenger.
|
||||
|
||||
## Required product flows
|
||||
|
||||
### Flow A: Admin creates a group and starts migration
|
||||
|
||||
1. Admin opens app.
|
||||
2. Admin creates group with name, description, visibility, and default permissions.
|
||||
3. Admin adds seed members manually or imports a simple CSV.
|
||||
4. Admin creates invite link.
|
||||
5. Admin gets QR-ready URL and WhatsApp reminder copy.
|
||||
6. Migration dashboard tracks invited/opened/joined/verified/notification-enabled.
|
||||
7. Admin can mark the WhatsApp channel as legacy and set a transition deadline.
|
||||
|
||||
### Flow B: Member joins without login
|
||||
|
||||
1. Member taps invite link from WhatsApp/email/SMS/QR.
|
||||
2. Browser opens join screen.
|
||||
3. Member sees group preview and official reason for moving.
|
||||
4. Member enters/accepts display name.
|
||||
5. Member is assigned a browser session/device.
|
||||
6. Member can immediately read official updates and perform one useful action, such as RSVP.
|
||||
7. Member is then nudged to save access via recovery email, passkey-ready protection, or browser notifications.
|
||||
|
||||
### Flow C: Multi-group home
|
||||
|
||||
1. Anna belongs to FC Kreuzberg U12, Class 4B Parents, Tenant Association, and Food Bank Volunteers.
|
||||
2. Anna opens Home.
|
||||
3. Home shows:
|
||||
- missing RSVPs;
|
||||
- open votes;
|
||||
- tasks assigned to Anna;
|
||||
- changed events;
|
||||
- important announcements;
|
||||
- catch-up summary of chatter.
|
||||
4. Anna does not need to open every group chat.
|
||||
|
||||
### Flow D: Multi-device access
|
||||
|
||||
1. Anna joined on phone.
|
||||
2. On PC, Anna opens the app and chooses “Link from existing device.”
|
||||
3. PC displays code/QR.
|
||||
4. Phone approves the pending device.
|
||||
5. PC receives a session.
|
||||
6. Anna sees both devices in Me → Devices.
|
||||
7. Anna can revoke a device.
|
||||
|
||||
### Flow E: Self-hosted remote aggregation
|
||||
|
||||
1. Club runs `club.example` as a group server.
|
||||
2. Anna's home server is `home.example` or the hosted default.
|
||||
3. Club server creates a scoped connection token for Anna.
|
||||
4. Anna connects `club.example` to her home server.
|
||||
5. Home server syncs structured objects through `/api/sync`.
|
||||
6. Anna's Home dashboard shows club actions alongside other groups.
|
||||
7. No group data is replicated peer-to-peer between unrelated servers.
|
||||
|
||||
## Top-level navigation
|
||||
|
||||
Use these nav items:
|
||||
|
||||
```text
|
||||
Home
|
||||
Calendar
|
||||
Groups
|
||||
Files
|
||||
Me
|
||||
```
|
||||
|
||||
Chat must not be top-level navigation.
|
||||
|
||||
## Home screen details
|
||||
|
||||
The Home screen must include the following cards/sections:
|
||||
|
||||
### Needs me
|
||||
|
||||
High priority, actionable objects:
|
||||
|
||||
- RSVP required;
|
||||
- vote required;
|
||||
- task assigned;
|
||||
- file requires acknowledgement;
|
||||
- direct mention;
|
||||
- admin request.
|
||||
|
||||
Each card must show:
|
||||
|
||||
- group name;
|
||||
- type badge;
|
||||
- due date/time if any;
|
||||
- primary action button;
|
||||
- source server when remote.
|
||||
|
||||
### Today / Upcoming
|
||||
|
||||
Unified agenda with:
|
||||
|
||||
- events;
|
||||
- deadlines;
|
||||
- changed locations/times;
|
||||
- RSVP status;
|
||||
- attached file indicators.
|
||||
|
||||
### Changed since last visit
|
||||
|
||||
Examples:
|
||||
|
||||
- event changed;
|
||||
- new official announcement;
|
||||
- task reassigned;
|
||||
- poll result finalized;
|
||||
- file uploaded.
|
||||
|
||||
### Official updates
|
||||
|
||||
Announcements and admin posts, separated from discussion chatter.
|
||||
|
||||
### Catch up
|
||||
|
||||
Rule-based summary in MVP:
|
||||
|
||||
```text
|
||||
While you were away:
|
||||
- 2 official announcements
|
||||
- 1 event changed
|
||||
- 3 open actions
|
||||
- 47 discussion messages
|
||||
```
|
||||
|
||||
Do not require AI to implement catch-up. AI hooks can be future work.
|
||||
|
||||
## Group page details
|
||||
|
||||
Default group dashboard sections:
|
||||
|
||||
```text
|
||||
Important now
|
||||
Upcoming
|
||||
Open actions
|
||||
Announcements
|
||||
Files
|
||||
Discussions
|
||||
Members/Admin tools
|
||||
```
|
||||
|
||||
Example for sports team:
|
||||
|
||||
```text
|
||||
FC Kreuzberg U12 Parents
|
||||
- Match Saturday, RSVP open
|
||||
- Training moved to Pitch 2
|
||||
- 3 drivers still needed
|
||||
- Files: Season schedule, Emergency contacts
|
||||
- Discussions: Snacks, Carpool
|
||||
```
|
||||
|
||||
## Composer behavior
|
||||
|
||||
When creating group content, allow explicit content types:
|
||||
|
||||
```text
|
||||
Announcement
|
||||
Event
|
||||
Task
|
||||
Poll
|
||||
Question/thread
|
||||
File
|
||||
Chat message
|
||||
```
|
||||
|
||||
MVP can implement simple forms for each type. Later AI can suggest structure from natural language; do not block MVP on that.
|
||||
|
||||
## Notifications
|
||||
|
||||
Implement preferences by type, not only by group.
|
||||
|
||||
User settings:
|
||||
|
||||
```text
|
||||
Immediate
|
||||
- direct mentions
|
||||
- event changes
|
||||
- urgent announcements
|
||||
- tasks assigned to me
|
||||
|
||||
Digest
|
||||
- normal discussions
|
||||
- new files
|
||||
- photos/general chatter
|
||||
|
||||
Muted
|
||||
- reactions
|
||||
- off-topic chatter
|
||||
```
|
||||
|
||||
UI must make the pitch clear:
|
||||
|
||||
> Mute the noise, not the group.
|
||||
|
||||
## Search and files
|
||||
|
||||
At MVP level:
|
||||
|
||||
- global file list;
|
||||
- by-group filter;
|
||||
- simple text search over names/descriptions;
|
||||
- upload/download local files;
|
||||
- show source server for remote files.
|
||||
|
||||
Later enhancements can include full-text search across messages.
|
||||
|
||||
## Migration kit details
|
||||
|
||||
Admin dashboard should show:
|
||||
|
||||
```text
|
||||
31 invited
|
||||
24 opened
|
||||
21 joined
|
||||
14 enabled notifications
|
||||
4 verified recovery
|
||||
7 not reached
|
||||
```
|
||||
|
||||
Generate reminder copy, for example:
|
||||
|
||||
```text
|
||||
23 of 31 people have joined our new group space.
|
||||
The match schedule, RSVP, and files are now here: {link}
|
||||
From {date}, official announcements will only be posted there.
|
||||
```
|
||||
|
||||
WhatsApp export import:
|
||||
|
||||
- accept `.txt` export;
|
||||
- parse basic lines if format is recognized;
|
||||
- store as archive items;
|
||||
- do not treat imported messages as active verified members;
|
||||
- make archive clearly read-only.
|
||||
|
||||
## Self-hosting and aggregation
|
||||
|
||||
Definitions:
|
||||
|
||||
- Group server: canonical owner of one or more groups.
|
||||
- Home server: stores a user's personal dashboard config and remote server connections.
|
||||
- Remote server connection: scoped link from a home server to a group server.
|
||||
- Federation: server-to-server peer network. Explicitly not included.
|
||||
|
||||
V2 self-hosting target:
|
||||
|
||||
- A server can be self-hosted by an organization.
|
||||
- A user can connect multiple servers to their home profile.
|
||||
- A home server aggregates structured objects.
|
||||
- Servers expose an open, documented, versioned sync API.
|
||||
- Users can export their data/config.
|
||||
|
||||
## Copy guidelines
|
||||
|
||||
Avoid words that create friction:
|
||||
|
||||
- Avoid “register” as first action.
|
||||
- Avoid “create account” before value.
|
||||
- Avoid “federation” in end-user UI.
|
||||
- Avoid “token” in end-user UI.
|
||||
|
||||
Use:
|
||||
|
||||
- “Open group”
|
||||
- “Join this group”
|
||||
- “Save access”
|
||||
- “Protect access”
|
||||
- “Link another device”
|
||||
- “Connect another group server”
|
||||
- “Get updates without WhatsApp”
|
||||
|
||||
## What not to build
|
||||
|
||||
- Do not put chat as the whole product.
|
||||
- Do not require the native app.
|
||||
- Do not bridge or scrape WhatsApp.
|
||||
- Do not make localStorage the source of identity.
|
||||
- Do not force all self-hosted servers into one federation.
|
||||
- Do not hide important state in chronological messages.
|
||||
135
README.md
135
README.md
@@ -1,2 +1,135 @@
|
||||
# comiaunicaty
|
||||
# GroupHome
|
||||
|
||||
GroupHome is a browser-first group coordination platform for organizations that want to move official work out of WhatsApp-style chat streams. It keeps chat secondary and makes structured objects first-class: announcements, events, RSVPs, tasks, polls, files, members, migration status, devices, notification preferences, and remote server aggregation.
|
||||
|
||||
## What Is Implemented
|
||||
|
||||
- FastAPI backend with SQLAlchemy and SQLite by default.
|
||||
- React/Vite/TypeScript frontend with mobile bottom navigation and desktop side navigation.
|
||||
- Accountless invite joining with hashed invite tokens and HttpOnly session cookies.
|
||||
- Prominent Chat entry point with messenger-style threads, left/right speech bubbles, author names, sent times, and configurable folding for short low-signal replies.
|
||||
- Chat composer that suggests structured follow-ups while typing and can manually create polls, events, tasks, announcements, invite links, or feedback notes from a conversation.
|
||||
- Group dashboards for important items, upcoming events, open actions, announcements, files, polls, tasks, and discussions.
|
||||
- Home dashboard sections: Needs me, Today / Upcoming, Changed since last visit, Official updates, Catch up.
|
||||
- Event RSVP, task completion, poll voting, local file upload/download, discussion threads.
|
||||
- Admin migration dashboard, QR-ready invite URL generation, reminder copy, legacy-channel status, read-only `.txt` chat archive import.
|
||||
- Recovery links in development mode, device pairing, device revocation, notification preferences, and passkey-shaped development provider.
|
||||
- Self-hosting seed protocol: `/.well-known/group-platform.json`, `/api/sync`, scoped connection codes, and remote cache aggregation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Browser / PWA
|
||||
-> React frontend
|
||||
-> FastAPI home server + group server
|
||||
-> SQLite/PostgreSQL-compatible SQLAlchemy models
|
||||
-> local file storage in development
|
||||
-> optional remote group servers through /api/sync
|
||||
```
|
||||
|
||||
One server can act as a home server, a group server, or both. Remote aggregation is not federation: the home server uses a scoped server-side connection code to fetch structured objects from selected remote group servers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
The compose backend runs seed data on startup. For local backend-only development:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m app.db.seed
|
||||
uvicorn app.main:app --reload
|
||||
pytest
|
||||
```
|
||||
|
||||
For frontend-only development:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Seed Demo
|
||||
|
||||
Seed data creates Anna Müller across:
|
||||
|
||||
- FC Kreuzberg U12 Parents
|
||||
- Class 4B Parents
|
||||
- Tenant Association
|
||||
- Food Bank Volunteers
|
||||
|
||||
It includes missing RSVPs, a changed training location, assigned tasks, an open tenant poll, files requiring acknowledgement, official announcements, discussions, and migration state.
|
||||
|
||||
The seed command prints:
|
||||
|
||||
- demo invite URL: `http://localhost:5173/join/demo-fc-invite`
|
||||
- demo remote connection code: `demo-remote-sync-code`
|
||||
|
||||
## Invite Flow
|
||||
|
||||
Open `/join/demo-fc-invite`, enter a display name, and join without email, password, or native app install. After joining, the browser receives an HttpOnly session cookie and can immediately RSVP from the join page or group dashboard.
|
||||
|
||||
## Chat On-Ramp
|
||||
|
||||
Open `Chat` from the bottom navigation or desktop side navigation. Chat is intentionally familiar: group threads, speech bubbles, names, and sent times. While typing, the composer suggests when the text looks like a poll, event, task, announcement, invite, or feedback item; the user must explicitly choose that structured chip before it creates the object. Short replies such as “ok” and “thanks” can be folded with the chat toggle.
|
||||
|
||||
## Device Linking
|
||||
|
||||
Open `Me -> Devices`.
|
||||
|
||||
1. Start a pairing code on the new device.
|
||||
2. Approve that code from an existing signed-in browser.
|
||||
3. Complete pairing on the new browser.
|
||||
4. Revoke stale devices from the device list.
|
||||
|
||||
## Remote Aggregation Demo
|
||||
|
||||
Single-server demo:
|
||||
|
||||
1. Open `Me -> Servers`.
|
||||
2. Create a connection code, or use seed code `demo-remote-sync-code`.
|
||||
3. Connect `http://localhost:8000`.
|
||||
4. Sync. Remote items appear on Home with remote source badges.
|
||||
|
||||
Two-server demo:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
DATABASE_URL=sqlite:///./remote.db SERVER_NAME="Club Server" SERVER_ORIGIN=http://localhost:8001 API_BASE_URL=http://localhost:8001/api FRONTEND_ORIGIN=http://localhost:5173 python -m app.db.seed
|
||||
DATABASE_URL=sqlite:///./remote.db SERVER_NAME="Club Server" SERVER_ORIGIN=http://localhost:8001 API_BASE_URL=http://localhost:8001/api uvicorn app.main:app --port 8001
|
||||
```
|
||||
|
||||
Then connect `http://localhost:8001` from the main app with the remote server's printed connection code.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Invite, recovery, and remote connection codes are stored only as hashes.
|
||||
- Sessions use HttpOnly cookies. A readable CSRF cookie/header is wired for production mode; development mode keeps same-site cookie auth simple.
|
||||
- Remote connection codes are stored server-side in a development adapter field, not in browser localStorage.
|
||||
- File names are sanitized and upload size is limited.
|
||||
- Role checks protect invites, official announcements, member management, migration, and remote connection codes.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- SMTP is not configured; recovery links are logged/returned in development mode.
|
||||
- Passkeys use a pluggable development provider, not production WebAuthn.
|
||||
- Web push is a future adapter.
|
||||
- No full federation, native app, unofficial messenger bridge, or production E2EE.
|
||||
- File storage is local filesystem in development; production should use mounted storage or S3-compatible storage.
|
||||
- Remote writes generally open the source context; local RSVP write-through is implemented.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Harden production CSRF and secret management.
|
||||
- Add PostgreSQL compose profile and migrations.
|
||||
- Add richer archive parsing for WhatsApp `.txt` exports.
|
||||
- Add calendar export and optional web push.
|
||||
- Replace development passkey provider with full WebAuthn.
|
||||
|
||||
27
README_USE_WITH_CODEX.md
Normal file
27
README_USE_WITH_CODEX.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# How to use this package with Codex
|
||||
|
||||
This package contains a Codex-ready build brief for a browser-first group coordination platform.
|
||||
|
||||
Recommended use:
|
||||
|
||||
1. Create an empty Git repository.
|
||||
2. Copy `AGENTS.md`, `CODEX_TASK.md`, `PRODUCT_SPEC.md`, `ARCHITECTURE_CONTRACTS.md`, and `ACCEPTANCE_TESTS.md` into the repository root.
|
||||
3. Open the repo in Codex.
|
||||
4. Ask Codex:
|
||||
|
||||
```text
|
||||
Read AGENTS.md and CODEX_TASK.md. Implement the full product described in PRODUCT_SPEC.md, ARCHITECTURE_CONTRACTS.md, and ACCEPTANCE_TESTS.md. Build a runnable monorepo. Do not stop at a prototype shell; deliver working vertical slices first, then deepen features until the acceptance tests pass.
|
||||
```
|
||||
|
||||
Alternative: paste the contents of `CODEX_TASK.md` directly into Codex after adding the other files to the repo.
|
||||
|
||||
The intended result is a monorepo with:
|
||||
|
||||
```text
|
||||
backend/ FastAPI app, DB models, API routes, seed data, tests
|
||||
frontend/ React/Vite app, mobile-first UI, client API layer, tests
|
||||
docker-compose.yml
|
||||
README.md
|
||||
```
|
||||
|
||||
The project deliberately excludes full federation, native apps, unofficial WhatsApp bridges, and production-grade end-to-end encryption. It includes local/self-hosted servers, a home-server model, and cross-server aggregation through scoped server connections.
|
||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
RUN pip install --no-cache-dir -e .
|
||||
COPY app ./app
|
||||
EXPOSE 8000
|
||||
|
||||
13
backend/README.md
Normal file
13
backend/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# GroupHome Backend
|
||||
|
||||
FastAPI, SQLAlchemy, and SQLite backend for the GroupHome coordination platform.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
python -m app.db.seed
|
||||
uvicorn app.main:app --reload
|
||||
pytest
|
||||
```
|
||||
|
||||
The seed command resets the local database and prints a demo invite URL plus a demo remote connection code.
|
||||
2
backend/app/__init__.py
Normal file
2
backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""GroupHome backend package."""
|
||||
|
||||
2
backend/app/core/__init__.py
Normal file
2
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core configuration and security helpers."""
|
||||
|
||||
36
backend/app/core/config.py
Normal file
36
backend/app/core/config.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "GroupHome"
|
||||
environment: str = "development"
|
||||
dev_mode: bool = True
|
||||
server_name: str = "GroupHome Local"
|
||||
server_origin: str = "http://localhost:8000"
|
||||
api_base_url: str = "http://localhost:8000/api"
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
database_url: str = "sqlite:///./grouphome.db"
|
||||
session_secret: str = "dev-change-me"
|
||||
session_cookie_name: str = "grouphome_session"
|
||||
cookie_secure: bool = False
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173"
|
||||
upload_dir: Path = Path("./storage/uploads")
|
||||
max_upload_bytes: int = 10 * 1024 * 1024
|
||||
remote_request_timeout_seconds: float = 8.0
|
||||
|
||||
@property
|
||||
def allowed_origins(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
settings = Settings()
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
return settings
|
||||
|
||||
44
backend/app/core/security.py
Normal file
44
backend/app/core/security.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def token_urlsafe(length: int = 32) -> str:
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
def short_code(length: int = 6) -> str:
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
settings = get_settings()
|
||||
return hmac.new(settings.session_secret.encode("utf-8"), raw_token.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def constant_time_equal(left: str, right: str) -> bool:
|
||||
return hmac.compare_digest(left, right)
|
||||
|
||||
|
||||
def session_expiry(days: int = 30) -> datetime:
|
||||
return utc_now() + timedelta(days=days)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
cleaned = Path(filename).name.strip().replace("\x00", "")
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._ -]+", "_", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip(" .")
|
||||
return cleaned[:180] or "upload.bin"
|
||||
|
||||
2
backend/app/db/__init__.py
Normal file
2
backend/app/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Database helpers."""
|
||||
|
||||
31
backend/app/db/base.py
Normal file
31
backend/app/db/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, connect_args=connect_args, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
import app.models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
320
backend/app/db/seed.py
Normal file
320
backend/app/db/seed.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, token_urlsafe, utc_now
|
||||
import app.db.base as db_base
|
||||
from app.db.base import Base, init_db
|
||||
from app.models import (
|
||||
Announcement,
|
||||
ConnectionToken,
|
||||
Event,
|
||||
FileAsset,
|
||||
Group,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberInvite,
|
||||
Message,
|
||||
MigrationState,
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
Poll,
|
||||
PollOption,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
|
||||
|
||||
DEMO_INVITE_TOKEN = "demo-fc-invite"
|
||||
DEMO_REMOTE_CONNECTION_CODE = "demo-remote-sync-code"
|
||||
|
||||
|
||||
def _add_member(db, group: Group, name: str, role: str = "member", profile: HomeProfile | None = None, status: str = "joined") -> Member:
|
||||
member = Member(
|
||||
group_id=group.id,
|
||||
home_profile_id=profile.id if profile else None,
|
||||
display_name=name,
|
||||
role=role,
|
||||
status=status,
|
||||
joined_at=utc_now() - timedelta(days=9) if status in {"joined", "verified"} else None,
|
||||
last_seen_at=utc_now() - timedelta(days=5),
|
||||
notification_enabled_at=utc_now() - timedelta(days=3) if name == "Anna Müller" else None,
|
||||
)
|
||||
db.add(member)
|
||||
db.flush()
|
||||
return member
|
||||
|
||||
|
||||
def _add_file(db, group: Group, member: Member, filename: str, description: str, requires_ack: bool = False) -> None:
|
||||
db.add(
|
||||
FileAsset(
|
||||
group_id=group.id,
|
||||
uploaded_by_member_id=member.id,
|
||||
filename_original=filename,
|
||||
filename_stored=f"seed-{filename.replace(' ', '_')}",
|
||||
content_type="text/plain",
|
||||
size_bytes=len(description.encode("utf-8")),
|
||||
storage_path=f"seed://{filename}",
|
||||
description=description,
|
||||
requires_ack=requires_ack,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
settings = get_settings()
|
||||
Base.metadata.drop_all(bind=db_base.engine)
|
||||
init_db()
|
||||
db = db_base.SessionLocal()
|
||||
try:
|
||||
anna = HomeProfile(primary_display_name="Anna Müller", last_seen_at=utc_now() - timedelta(days=5))
|
||||
db.add(anna)
|
||||
db.flush()
|
||||
|
||||
groups = [
|
||||
Group(
|
||||
server_origin=settings.server_origin,
|
||||
name="FC Kreuzberg U12 Parents",
|
||||
description="Planning, matches, drivers, files, and official team announcements.",
|
||||
visibility="private",
|
||||
legacy_channel_status="transition",
|
||||
transition_deadline=(utc_now() + timedelta(days=21)).date(),
|
||||
),
|
||||
Group(
|
||||
server_origin=settings.server_origin,
|
||||
name="Class 4B Parents",
|
||||
description="School forms, parent evenings, votes, and classroom coordination.",
|
||||
visibility="private",
|
||||
legacy_channel_status="transition",
|
||||
transition_deadline=(utc_now() + timedelta(days=28)).date(),
|
||||
),
|
||||
Group(
|
||||
server_origin=settings.server_origin,
|
||||
name="Tenant Association",
|
||||
description="Building updates, maintenance actions, meeting minutes, and votes.",
|
||||
visibility="private",
|
||||
legacy_channel_status="transition",
|
||||
transition_deadline=(utc_now() + timedelta(days=14)).date(),
|
||||
),
|
||||
Group(
|
||||
server_origin=settings.server_origin,
|
||||
name="Food Bank Volunteers",
|
||||
description="Volunteer shifts, supply lists, files, and announcements.",
|
||||
visibility="private",
|
||||
legacy_channel_status="transition",
|
||||
transition_deadline=(utc_now() + timedelta(days=30)).date(),
|
||||
),
|
||||
]
|
||||
db.add_all(groups)
|
||||
db.flush()
|
||||
fc, school, tenants, food = groups
|
||||
|
||||
anna_fc = _add_member(db, fc, "Anna Müller", "admin", anna)
|
||||
coach = _add_member(db, fc, "Coach Mark", "owner")
|
||||
lisa_fc = _add_member(db, fc, "Lisa Becker")
|
||||
samir_fc = _add_member(db, fc, "Samir Khan")
|
||||
_add_member(db, fc, "Priya N.", status="invited")
|
||||
|
||||
anna_school = _add_member(db, school, "Anna Müller", "member", anna)
|
||||
lisa_school = _add_member(db, school, "Lisa Becker", "admin")
|
||||
samir_school = _add_member(db, school, "Samir Khan")
|
||||
|
||||
anna_tenant = _add_member(db, tenants, "Anna Müller", "member", anna)
|
||||
tenant_admin = _add_member(db, tenants, "Tenant admin", "owner")
|
||||
_add_member(db, tenants, "Priya N.", "moderator")
|
||||
|
||||
anna_food = _add_member(db, food, "Anna Müller", "member", anna)
|
||||
priya_food = _add_member(db, food, "Priya N.", "admin")
|
||||
_add_member(db, food, "Samir Khan")
|
||||
|
||||
db.add(
|
||||
MemberInvite(
|
||||
group_id=fc.id,
|
||||
created_by_member_id=anna_fc.id,
|
||||
label="Parent open invite",
|
||||
scope="open_seat",
|
||||
permission_role="member",
|
||||
token_hash=hash_token(DEMO_INVITE_TOKEN),
|
||||
max_uses=100,
|
||||
)
|
||||
)
|
||||
|
||||
now = utc_now()
|
||||
db.add_all(
|
||||
[
|
||||
Announcement(
|
||||
group_id=fc.id,
|
||||
author_member_id=coach.id,
|
||||
title="Official move: match details live here",
|
||||
body="Schedules, RSVP, driver planning, and files are now handled in GroupHome.",
|
||||
priority="urgent",
|
||||
official=True,
|
||||
requires_ack=True,
|
||||
),
|
||||
Announcement(
|
||||
group_id=school.id,
|
||||
author_member_id=lisa_school.id,
|
||||
title="School form due Friday",
|
||||
body="Please download the excursion form and return it by Friday morning.",
|
||||
official=True,
|
||||
requires_ack=False,
|
||||
),
|
||||
Announcement(
|
||||
group_id=food.id,
|
||||
author_member_id=priya_food.id,
|
||||
title="Saturday shift checklist",
|
||||
body="Bring gloves if you have them. New shelf labels are attached in files.",
|
||||
official=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
Event(
|
||||
group_id=fc.id,
|
||||
created_by_member_id=coach.id,
|
||||
title="League match vs. Neukölln",
|
||||
description="Please RSVP and add a note if you can drive.",
|
||||
starts_at=now + timedelta(days=2, hours=3),
|
||||
ends_at=now + timedelta(days=2, hours=5),
|
||||
location_name="Willi-Boos-Sportplatz",
|
||||
location_address="Gneisenaustr. 36, Berlin",
|
||||
rsvp_required=True,
|
||||
),
|
||||
Event(
|
||||
group_id=fc.id,
|
||||
created_by_member_id=coach.id,
|
||||
title="Training moved to Pitch 2",
|
||||
description="The usual pitch is closed for maintenance.",
|
||||
starts_at=now + timedelta(days=1, hours=2),
|
||||
ends_at=now + timedelta(days=1, hours=4),
|
||||
location_name="Pitch 2",
|
||||
rsvp_required=False,
|
||||
changed_at=now - timedelta(days=1),
|
||||
),
|
||||
Event(
|
||||
group_id=school.id,
|
||||
created_by_member_id=lisa_school.id,
|
||||
title="Parent evening",
|
||||
description="Agenda: class trip, reading groups, and summer project.",
|
||||
starts_at=now + timedelta(days=5, hours=1),
|
||||
location_name="Classroom 4B",
|
||||
rsvp_required=True,
|
||||
),
|
||||
Event(
|
||||
group_id=food.id,
|
||||
created_by_member_id=priya_food.id,
|
||||
title="Volunteer shift",
|
||||
description="Sorting and front desk support.",
|
||||
starts_at=now + timedelta(days=3, hours=4),
|
||||
location_name="Food Bank Hall",
|
||||
rsvp_required=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
Task(
|
||||
group_id=fc.id,
|
||||
created_by_member_id=coach.id,
|
||||
assigned_to_member_id=anna_fc.id,
|
||||
title="Confirm one more driver",
|
||||
description="We need one extra car for Saturday.",
|
||||
due_at=now + timedelta(days=1),
|
||||
),
|
||||
Task(
|
||||
group_id=food.id,
|
||||
created_by_member_id=priya_food.id,
|
||||
assigned_to_member_id=anna_food.id,
|
||||
title="Bring label printer",
|
||||
description="Use it for shelf relabeling before the morning shift.",
|
||||
due_at=now + timedelta(days=2),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
poll = Poll(
|
||||
group_id=tenants.id,
|
||||
created_by_member_id=tenant_admin.id,
|
||||
title="Vote: courtyard repair appointment",
|
||||
description="Choose the appointment that works for your household.",
|
||||
closes_at=now + timedelta(days=4),
|
||||
status="open",
|
||||
)
|
||||
db.add(poll)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
PollOption(poll_id=poll.id, label="Tuesday morning", position=1),
|
||||
PollOption(poll_id=poll.id, label="Thursday afternoon", position=2),
|
||||
PollOption(poll_id=poll.id, label="Either is fine", position=3),
|
||||
]
|
||||
)
|
||||
|
||||
_add_file(db, fc, coach, "Season schedule.txt", "Full U12 season schedule and match locations.", True)
|
||||
_add_file(db, fc, coach, "Emergency contacts.txt", "Emergency contacts and consent notes.", False)
|
||||
_add_file(db, tenants, tenant_admin, "Meeting minutes.txt", "Tenant association meeting notes.", False)
|
||||
_add_file(db, school, lisa_school, "School excursion form.txt", "Please print and return this form.", True)
|
||||
|
||||
for group, creator, title, body in [
|
||||
(fc, lisa_fc, "Snack coordination", "I can bring oranges. Who can bring water?"),
|
||||
(fc, samir_fc, "Carpool planning", "I have two seats from Kottbusser Tor."),
|
||||
(tenants, tenant_admin, "Maintenance issue", "Elevator inspection is scheduled next week."),
|
||||
(food, priya_food, "Volunteer supplies", "We are low on tape and shelf labels."),
|
||||
]:
|
||||
thread = Thread(group_id=group.id, created_by_member_id=creator.id, title=title, kind="discussion")
|
||||
db.add(thread)
|
||||
db.flush()
|
||||
db.add(Message(thread_id=thread.id, author_member_id=creator.id, body=body))
|
||||
|
||||
db.add(
|
||||
Notification(
|
||||
home_profile_id=anna.id,
|
||||
member_id=anna_fc.id,
|
||||
title="Coach Mark mentioned you",
|
||||
body="Can you confirm the last driver for Saturday?",
|
||||
category="direct_mentions",
|
||||
)
|
||||
)
|
||||
|
||||
for owner_member in [anna_fc, anna_school, anna_tenant, anna_food]:
|
||||
for category, delivery in [
|
||||
("direct_mentions", "immediate"),
|
||||
("event_changes", "immediate"),
|
||||
("urgent_announcements", "immediate"),
|
||||
("tasks_assigned", "immediate"),
|
||||
("discussions", "digest"),
|
||||
("new_files", "digest"),
|
||||
("general_chatter", "digest"),
|
||||
("reactions", "muted"),
|
||||
("off_topic", "muted"),
|
||||
]:
|
||||
db.add(NotificationPreference(home_profile_id=anna.id, member_id=owner_member.id, category=category, delivery=delivery))
|
||||
|
||||
for group in groups:
|
||||
db.add(MigrationState(group_id=group.id))
|
||||
|
||||
db.add(
|
||||
ConnectionToken(
|
||||
created_by_member_id=anna_fc.id,
|
||||
label="Demo remote sync code",
|
||||
token_hash=hash_token(DEMO_REMOTE_CONNECTION_CODE),
|
||||
scopes_json=["sync:read"],
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
print("Seed complete.")
|
||||
print(f"Demo invite URL: {settings.frontend_origin}/join/{DEMO_INVITE_TOKEN}")
|
||||
print(f"Demo remote connection code: {DEMO_REMOTE_CONNECTION_CODE}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
71
backend/app/main.py
Normal file
71
backend/app/main.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import constant_time_equal, hash_token
|
||||
from app.db.base import SessionLocal, init_db
|
||||
from app.models import AppSession
|
||||
from app.routers import auth, chat, groups, home, remote
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_protection(request: Request, call_next):
|
||||
if not settings.dev_mode and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if session_id:
|
||||
header_token = request.headers.get("x-csrf-token") or request.cookies.get("grouphome_csrf")
|
||||
if not header_token:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"error": {"code": "csrf_required", "message": "Security check failed.", "details": {}}},
|
||||
)
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
session = db.get(AppSession, session_id)
|
||||
if not session or not constant_time_equal(session.csrf_token_hash, hash_token(header_token)):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"error": {"code": "csrf_invalid", "message": "Security check failed.", "details": {}}},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
if settings.dev_mode:
|
||||
raise exc
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"code": "server_error", "message": "Something went wrong.", "details": {}}},
|
||||
)
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(home.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(groups.router)
|
||||
app.include_router(remote.api_router)
|
||||
app.include_router(remote.well_known_router)
|
||||
439
backend/app/models/__init__.py
Normal file
439
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,439 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.security import utc_now
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
def uuid_str() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
class HomeProfile(Base):
|
||||
__tablename__ = "home_profiles"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
primary_display_name: Mapped[str] = mapped_column(String(160))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
|
||||
members: Mapped[list["Member"]] = relationship(back_populates="home_profile")
|
||||
devices: Mapped[list["HomeDevice"]] = relationship(back_populates="home_profile")
|
||||
|
||||
|
||||
class HomeDevice(Base):
|
||||
__tablename__ = "home_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
label: Mapped[str] = mapped_column(String(160))
|
||||
user_agent_summary: Mapped[str] = mapped_column(String(255), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
trust_level: Mapped[str] = mapped_column(String(32), default="claimed_browser")
|
||||
|
||||
home_profile: Mapped[HomeProfile | None] = relationship(back_populates="devices")
|
||||
|
||||
|
||||
class RecoveryMethod(Base):
|
||||
__tablename__ = "recovery_methods"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str] = mapped_column(ForeignKey("home_profiles.id"))
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
value_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
display_hint: Mapped[str] = mapped_column(String(160), default="")
|
||||
recovery_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
recovery_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class AppSession(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
home_device_id: Mapped[str | None] = mapped_column(ForeignKey("home_devices.id"), nullable=True)
|
||||
member_device_id: Mapped[str | None] = mapped_column(ForeignKey("member_devices.id"), nullable=True)
|
||||
csrf_token_hash: Mapped[str] = mapped_column(String(128))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
server_origin: Mapped[str] = mapped_column(String(255))
|
||||
name: Mapped[str] = mapped_column(String(180))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
visibility: Mapped[str] = mapped_column(String(32), default="private")
|
||||
default_permissions_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
legacy_channel_status: Mapped[str] = mapped_column(String(32), default="transition")
|
||||
transition_deadline: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
members: Mapped[list["Member"]] = relationship(back_populates="group")
|
||||
|
||||
|
||||
class Member(Base):
|
||||
__tablename__ = "members"
|
||||
__table_args__ = (UniqueConstraint("group_id", "home_profile_id", name="uq_member_group_profile"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(160))
|
||||
role: Mapped[str] = mapped_column(String(32), default="member")
|
||||
status: Mapped[str] = mapped_column(String(32), default="invited")
|
||||
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
notification_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
group: Mapped[Group] = relationship(back_populates="members")
|
||||
home_profile: Mapped[HomeProfile | None] = relationship(back_populates="members")
|
||||
devices: Mapped[list["MemberDevice"]] = relationship(back_populates="member")
|
||||
|
||||
|
||||
class MemberDevice(Base):
|
||||
__tablename__ = "member_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
label: Mapped[str] = mapped_column(String(160))
|
||||
user_agent_summary: Mapped[str] = mapped_column(String(255), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
trust_level: Mapped[str] = mapped_column(String(32), default="claimed_browser")
|
||||
|
||||
member: Mapped[Member] = relationship(back_populates="devices")
|
||||
|
||||
|
||||
class MemberInvite(Base):
|
||||
__tablename__ = "member_invites"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
label: Mapped[str] = mapped_column(String(160))
|
||||
scope: Mapped[str] = mapped_column(String(32), default="open_seat")
|
||||
permission_role: Mapped[str] = mapped_column(String(32), default="member")
|
||||
token_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
max_uses: Mapped[int] = mapped_column(Integer, default=50)
|
||||
use_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
opened_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Announcement(Base):
|
||||
__tablename__ = "announcements"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
author_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
priority: Mapped[str] = mapped_column(String(32), default="normal")
|
||||
official: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
requires_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
location_name: Mapped[str | None] = mapped_column(String(220), nullable=True)
|
||||
location_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
rsvp_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class EventRsvp(Base):
|
||||
__tablename__ = "event_rsvps"
|
||||
__table_args__ = (UniqueConstraint("event_id", "member_id", name="uq_event_member_rsvp"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
event_id: Mapped[str] = mapped_column(ForeignKey("events.id"))
|
||||
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
status: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
assigned_to_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closes_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
poll_id: Mapped[str] = mapped_column(ForeignKey("polls.id"))
|
||||
label: Mapped[str] = mapped_column(String(220))
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class PollVote(Base):
|
||||
__tablename__ = "poll_votes"
|
||||
__table_args__ = (UniqueConstraint("poll_id", "member_id", name="uq_poll_member_vote"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
poll_id: Mapped[str] = mapped_column(ForeignKey("polls.id"))
|
||||
option_id: Mapped[str] = mapped_column(ForeignKey("poll_options.id"))
|
||||
member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class FileAsset(Base):
|
||||
__tablename__ = "file_assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
uploaded_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
filename_original: Mapped[str] = mapped_column(String(255))
|
||||
filename_stored: Mapped[str] = mapped_column(String(255))
|
||||
content_type: Mapped[str] = mapped_column(String(160), default="application/octet-stream")
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
storage_path: Mapped[str] = mapped_column(String(500))
|
||||
visibility: Mapped[str] = mapped_column(String(32), default="members")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
requires_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Thread(Base):
|
||||
__tablename__ = "threads"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"))
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
kind: Mapped[str] = mapped_column(String(32), default="discussion")
|
||||
created_by_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
thread_id: Mapped[str] = mapped_column(ForeignKey("threads.id"))
|
||||
author_member_id: Mapped[str] = mapped_column(ForeignKey("members.id"))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class NotificationPreference(Base):
|
||||
__tablename__ = "notification_preferences"
|
||||
__table_args__ = (UniqueConstraint("home_profile_id", "member_id", "category", name="uq_preference_owner_category"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(80))
|
||||
delivery: Mapped[str] = mapped_column(String(32), default="immediate")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
category: Mapped[str] = mapped_column(String(80), default="general")
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class ActionItem(Base):
|
||||
__tablename__ = "action_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
remote_connection_id: Mapped[str | None] = mapped_column(ForeignKey("remote_server_connections.id"), nullable=True)
|
||||
source_type: Mapped[str] = mapped_column(String(32), default="local")
|
||||
source_server_origin: Mapped[str] = mapped_column(String(255))
|
||||
source_group_id: Mapped[str] = mapped_column(String(80))
|
||||
source_group_name: Mapped[str] = mapped_column(String(180))
|
||||
type: Mapped[str] = mapped_column(String(80))
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
priority: Mapped[str] = mapped_column(String(32), default="normal")
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
object_type: Mapped[str] = mapped_column(String(80))
|
||||
object_id: Mapped[str] = mapped_column(String(80))
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class MigrationState(Base):
|
||||
__tablename__ = "migration_states"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), unique=True)
|
||||
invited_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
opened_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
joined_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
verified_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notification_enabled_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
not_reached_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
actor_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
actor_home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(120))
|
||||
resource_type: Mapped[str] = mapped_column(String(80), default="")
|
||||
resource_id: Mapped[str] = mapped_column(String(80), default="")
|
||||
details_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class RemoteServerConnection(Base):
|
||||
__tablename__ = "remote_server_connections"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
home_profile_id: Mapped[str] = mapped_column(ForeignKey("home_profiles.id"))
|
||||
server_origin: Mapped[str] = mapped_column(String(255))
|
||||
server_name: Mapped[str] = mapped_column(String(180))
|
||||
api_base: Mapped[str] = mapped_column(String(255))
|
||||
protocol_version: Mapped[str] = mapped_column(String(32), default="0.1")
|
||||
capabilities_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
access_token_encrypted: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class RemoteMembership(Base):
|
||||
__tablename__ = "remote_memberships"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
|
||||
remote_group_id: Mapped[str] = mapped_column(String(80))
|
||||
remote_member_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
group_name: Mapped[str] = mapped_column(String(180), default="")
|
||||
role: Mapped[str] = mapped_column(String(32), default="member")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class RemoteSyncCursor(Base):
|
||||
__tablename__ = "remote_sync_cursors"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
|
||||
cursor: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class RemoteCachedObject(Base):
|
||||
__tablename__ = "remote_cached_objects"
|
||||
__table_args__ = (UniqueConstraint("remote_connection_id", "object_type", "remote_id", name="uq_remote_object"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
remote_connection_id: Mapped[str] = mapped_column(ForeignKey("remote_server_connections.id"))
|
||||
object_type: Mapped[str] = mapped_column(String(80))
|
||||
remote_id: Mapped[str] = mapped_column(String(120))
|
||||
group_remote_id: Mapped[str] = mapped_column(String(120))
|
||||
group_name: Mapped[str] = mapped_column(String(180), default="")
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
updated_at_remote: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class ConnectionToken(Base):
|
||||
__tablename__ = "connection_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
created_by_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id"), nullable=True)
|
||||
label: Mapped[str] = mapped_column(String(160), default="Remote connection")
|
||||
token_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
|
||||
scopes_json: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
|
||||
class DeviceLinkCode(Base):
|
||||
__tablename__ = "device_link_codes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str)
|
||||
code_hash: Mapped[str] = mapped_column(String(128), index=True, unique=True)
|
||||
requested_device_label: Mapped[str] = mapped_column(String(160))
|
||||
requested_user_agent: Mapped[str] = mapped_column(String(255), default="")
|
||||
approved_by_home_profile_id: Mapped[str | None] = mapped_column(ForeignKey("home_profiles.id"), nullable=True)
|
||||
approved_by_member_id: Mapped[str | None] = mapped_column(ForeignKey("members.id"), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||
|
||||
2
backend/app/routers/__init__.py
Normal file
2
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routers."""
|
||||
|
||||
321
backend/app/routers/auth.py
Normal file
321
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, short_code, token_urlsafe, utc_now
|
||||
from app.db.base import get_db
|
||||
from app.models import (
|
||||
DeviceLinkCode,
|
||||
Group,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberDevice,
|
||||
MemberInvite,
|
||||
RecoveryMethod,
|
||||
)
|
||||
from app.schemas import DeviceLinkCodeIn, DeviceLinkComplete, DeviceLinkStart, InviteClaim, PasskeyStub, RecoveryConsume, RecoveryRequest
|
||||
from app.services.auth import (
|
||||
CurrentContext,
|
||||
audit,
|
||||
create_session,
|
||||
ensure_home_profile,
|
||||
get_current_context,
|
||||
get_members_for_context,
|
||||
get_optional_context,
|
||||
set_session_cookies,
|
||||
)
|
||||
from app.services.passkeys import passkey_provider
|
||||
from app.services.serializers import device_dict, group_dict, member_dict, profile_dict
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
|
||||
def _invite_or_404(db: Session, raw_token: str) -> MemberInvite:
|
||||
invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(raw_token)))
|
||||
now = utc_now()
|
||||
expired = invite and invite.expires_at and (invite.expires_at < (now if invite.expires_at.tzinfo else now.replace(tzinfo=None)))
|
||||
if not invite or invite.revoked_at or expired or invite.use_count >= invite.max_uses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "invite_unavailable", "message": "This invite is no longer available.", "details": {}}},
|
||||
)
|
||||
return invite
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict:
|
||||
return {"ok": True, "name": get_settings().app_name}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(ctx: CurrentContext = Depends(get_optional_context), db: Session = Depends(get_db)) -> dict:
|
||||
memberships = []
|
||||
for member in get_members_for_context(db, ctx):
|
||||
group = member.group
|
||||
memberships.append({"member": member_dict(member), "group": group_dict(group)})
|
||||
return {
|
||||
"authenticated": ctx.authenticated,
|
||||
"profile": profile_dict(ctx.home_profile, ctx.member),
|
||||
"member": member_dict(ctx.member) if ctx.member else None,
|
||||
"memberships": memberships,
|
||||
"dev_mode": get_settings().dev_mode,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/dev/demo-session")
|
||||
def dev_demo_session(response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
settings = get_settings()
|
||||
if not settings.dev_mode:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Not found.", "details": {}}})
|
||||
profile = db.scalar(select(HomeProfile).where(HomeProfile.primary_display_name == "Anna Müller"))
|
||||
if not profile:
|
||||
raise HTTPException(status_code=409, detail={"error": {"code": "seed_missing", "message": "Run python -m app.db.seed first.", "details": {}}})
|
||||
device = HomeDevice(
|
||||
home_profile_id=profile.id,
|
||||
label="Demo browser",
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="verified",
|
||||
)
|
||||
db.add(device)
|
||||
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
|
||||
audit(db, action="dev_demo_session", resource_type="home_profile", resource_id=profile.id)
|
||||
db.commit()
|
||||
set_session_cookies(response, session, csrf_token)
|
||||
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
|
||||
|
||||
|
||||
@router.get("/join/{token}/preview")
|
||||
def invite_preview(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
invite = _invite_or_404(db, token)
|
||||
group = invite and db.get(Group, invite.group_id)
|
||||
if invite.member_id:
|
||||
member = db.get(Member, invite.member_id)
|
||||
if member and member.status == "invited":
|
||||
member.status = "opened"
|
||||
invite.opened_count += 1
|
||||
from app.models import Announcement, Event
|
||||
from app.services.serializers import announcement_dict, event_dict
|
||||
|
||||
announcements = [
|
||||
announcement_dict(item, group)
|
||||
for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True)).limit(3)).all()
|
||||
]
|
||||
events = [event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(3)).all()]
|
||||
db.commit()
|
||||
return {
|
||||
"group": group_dict(group),
|
||||
"invite": {"label": invite.label, "expires_at": invite.expires_at.isoformat() if invite.expires_at else None, "role": invite.permission_role},
|
||||
"preview": {"announcements": announcements, "events": events},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/invite/{token}/claim")
|
||||
def claim_invite(token: str, payload: InviteClaim, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
invite = _invite_or_404(db, token)
|
||||
group = db.get(Group, invite.group_id)
|
||||
member = db.get(Member, invite.member_id) if invite.member_id else None
|
||||
if member is None:
|
||||
member = Member(
|
||||
group_id=group.id,
|
||||
display_name=payload.display_name,
|
||||
role=invite.permission_role,
|
||||
status="joined",
|
||||
joined_at=utc_now(),
|
||||
last_seen_at=utc_now(),
|
||||
)
|
||||
db.add(member)
|
||||
db.flush()
|
||||
else:
|
||||
member.display_name = payload.display_name
|
||||
member.role = invite.permission_role if member.role == "guest" else member.role
|
||||
member.status = "joined"
|
||||
member.joined_at = member.joined_at or utc_now()
|
||||
invite.use_count += 1
|
||||
if invite.use_count >= invite.max_uses:
|
||||
invite.consumed_at = utc_now()
|
||||
device = MemberDevice(
|
||||
member_id=member.id,
|
||||
label=payload.device_label,
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="claimed_browser",
|
||||
)
|
||||
db.add(device)
|
||||
session, csrf_token = create_session(db, home_profile=member.home_profile, member=member, member_device=device)
|
||||
audit(db, action="invite_claimed", resource_type="invite", resource_id=invite.id, details={"group_id": group.id})
|
||||
db.commit()
|
||||
set_session_cookies(response, session, csrf_token)
|
||||
return {
|
||||
"member": member_dict(member),
|
||||
"group": group_dict(group),
|
||||
"csrf_token": csrf_token,
|
||||
"next_steps": ["save_access", "enable_notifications"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/recovery/request")
|
||||
def recovery_request(payload: RecoveryRequest, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
profile = ensure_home_profile(db, ctx)
|
||||
raw = token_urlsafe(32)
|
||||
method = RecoveryMethod(
|
||||
home_profile_id=profile.id,
|
||||
kind="email",
|
||||
value_hash=hash_token(payload.email.lower()),
|
||||
display_hint=payload.email[:2] + "***" + payload.email[payload.email.rfind("@") :],
|
||||
recovery_token_hash=hash_token(raw),
|
||||
recovery_expires_at=utc_now() + timedelta(minutes=30),
|
||||
)
|
||||
db.add(method)
|
||||
audit(db, ctx=ctx, action="recovery_requested", resource_type="home_profile", resource_id=profile.id)
|
||||
db.commit()
|
||||
result = {"ok": True, "message": "Check your email for a recovery link."}
|
||||
if get_settings().dev_mode:
|
||||
result.update({"dev_code": raw, "dev_link": f"{get_settings().frontend_origin}/me?recover={raw}"})
|
||||
print(f"[dev recovery] {payload.email}: {raw}")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/auth/recovery/consume")
|
||||
def recovery_consume(payload: RecoveryConsume, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
method = db.scalar(select(RecoveryMethod).where(RecoveryMethod.recovery_token_hash == hash_token(payload.recovery_code)))
|
||||
now = utc_now()
|
||||
expired = method and method.recovery_expires_at and method.recovery_expires_at < (
|
||||
now if method.recovery_expires_at.tzinfo else now.replace(tzinfo=None)
|
||||
)
|
||||
if not method or method.revoked_at or expired:
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "recovery_invalid", "message": "This recovery link has expired.", "details": {}}})
|
||||
profile = db.get(HomeProfile, method.home_profile_id)
|
||||
method.verified_at = method.verified_at or utc_now()
|
||||
method.recovery_token_hash = None
|
||||
device = HomeDevice(
|
||||
home_profile_id=profile.id,
|
||||
label=payload.device_label,
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="verified",
|
||||
)
|
||||
db.add(device)
|
||||
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
|
||||
audit(db, action="recovery_consumed", resource_type="home_profile", resource_id=profile.id)
|
||||
db.commit()
|
||||
set_session_cookies(response, session, csrf_token)
|
||||
return {"profile": profile_dict(profile), "csrf_token": csrf_token}
|
||||
|
||||
|
||||
@router.post("/auth/device-link/start")
|
||||
def device_link_start(payload: DeviceLinkStart, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
code = short_code()
|
||||
pending = DeviceLinkCode(
|
||||
code_hash=hash_token(code),
|
||||
requested_device_label=payload.device_label,
|
||||
requested_user_agent=request.headers.get("user-agent", "")[:255],
|
||||
expires_at=utc_now() + timedelta(minutes=10),
|
||||
)
|
||||
db.add(pending)
|
||||
db.commit()
|
||||
return {"pairing_id": pending.id, "code": code, "expires_at": pending.expires_at.isoformat()}
|
||||
|
||||
|
||||
@router.post("/auth/device-link/approve")
|
||||
def device_link_approve(payload: DeviceLinkCodeIn, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
|
||||
now = utc_now()
|
||||
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
|
||||
if not pending or expired or pending.completed_at:
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "That link code is not active.", "details": {}}})
|
||||
pending.approved_by_home_profile_id = ctx.home_profile.id if ctx.home_profile else None
|
||||
pending.approved_by_member_id = ctx.member.id if ctx.member else None
|
||||
pending.approved_at = utc_now()
|
||||
audit(db, ctx=ctx, action="device_link_approved", resource_type="device_link", resource_id=pending.id)
|
||||
db.commit()
|
||||
return {"ok": True, "device_label": pending.requested_device_label}
|
||||
|
||||
|
||||
@router.post("/auth/device-link/complete")
|
||||
def device_link_complete(payload: DeviceLinkComplete, response: Response, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
pending = db.scalar(select(DeviceLinkCode).where(DeviceLinkCode.code_hash == hash_token(payload.code.upper().replace(" ", ""))))
|
||||
now = utc_now()
|
||||
expired = pending and pending.expires_at < (now if pending.expires_at.tzinfo else now.replace(tzinfo=None))
|
||||
if not pending or expired or not pending.approved_at or pending.completed_at:
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_not_ready", "message": "This link code has not been approved yet.", "details": {}}})
|
||||
|
||||
profile = db.get(HomeProfile, pending.approved_by_home_profile_id) if pending.approved_by_home_profile_id else None
|
||||
member = db.get(Member, pending.approved_by_member_id) if pending.approved_by_member_id else None
|
||||
if profile:
|
||||
device = HomeDevice(
|
||||
home_profile_id=profile.id,
|
||||
label=payload.device_label or pending.requested_device_label,
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="claimed_browser",
|
||||
)
|
||||
db.add(device)
|
||||
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
|
||||
elif member:
|
||||
member_device = MemberDevice(
|
||||
member_id=member.id,
|
||||
label=payload.device_label or pending.requested_device_label,
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="claimed_browser",
|
||||
)
|
||||
db.add(member_device)
|
||||
session, csrf_token = create_session(db, member=member, member_device=member_device)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "pairing_invalid", "message": "No approving device was found.", "details": {}}})
|
||||
pending.completed_at = utc_now()
|
||||
audit(db, action="device_link_completed", resource_type="device_link", resource_id=pending.id)
|
||||
db.commit()
|
||||
set_session_cookies(response, session, csrf_token)
|
||||
return {"ok": True, "csrf_token": csrf_token}
|
||||
|
||||
|
||||
@router.get("/me/devices")
|
||||
def devices(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
items = []
|
||||
current_id = ctx.home_device.id if ctx.home_device else (ctx.member_device.id if ctx.member_device else None)
|
||||
if ctx.home_profile:
|
||||
items.extend([device_dict(item, current_id) for item in db.scalars(select(HomeDevice).where(HomeDevice.home_profile_id == ctx.home_profile.id)).all()])
|
||||
member_ids = [member.id for member in get_members_for_context(db, ctx)]
|
||||
if member_ids:
|
||||
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id.in_(member_ids))).all()])
|
||||
elif ctx.member:
|
||||
items.extend([device_dict(item, current_id) for item in db.scalars(select(MemberDevice).where(MemberDevice.member_id == ctx.member.id)).all()])
|
||||
return {"devices": items}
|
||||
|
||||
|
||||
@router.delete("/me/devices/{device_id}")
|
||||
def revoke_device(device_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
device = None
|
||||
if ctx.home_profile:
|
||||
device = db.scalar(select(HomeDevice).where(HomeDevice.id == device_id, HomeDevice.home_profile_id == ctx.home_profile.id))
|
||||
if not device and ctx.member:
|
||||
device = db.scalar(select(MemberDevice).where(MemberDevice.id == device_id, MemberDevice.member_id == ctx.member.id))
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Device not found.", "details": {}}})
|
||||
device.revoked_at = utc_now()
|
||||
audit(db, ctx=ctx, action="device_revoked", resource_type="device", resource_id=device_id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/auth/passkeys/register/options")
|
||||
def passkey_register_options(payload: PasskeyStub) -> dict:
|
||||
return passkey_provider.registration_options(payload.display_name)
|
||||
|
||||
|
||||
@router.post("/auth/passkeys/register/verify")
|
||||
def passkey_register_verify(payload: dict) -> dict:
|
||||
return passkey_provider.verify_registration(payload)
|
||||
|
||||
|
||||
@router.post("/auth/passkeys/login/options")
|
||||
def passkey_login_options() -> dict:
|
||||
return passkey_provider.login_options()
|
||||
|
||||
|
||||
@router.post("/auth/passkeys/login/verify")
|
||||
def passkey_login_verify(payload: dict) -> dict:
|
||||
return passkey_provider.verify_login(payload)
|
||||
138
backend/app/routers/chat.py
Normal file
138
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import get_db
|
||||
from app.models import Group, Member, Message, Thread
|
||||
from app.schemas import MessageCreate, ThreadCreate
|
||||
from app.services.auth import CurrentContext, audit, get_current_context, get_member_for_group, get_members_for_context
|
||||
from app.services.serializers import group_dict, iso, member_dict
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["chat"])
|
||||
|
||||
|
||||
LOW_SIGNAL_PHRASES = {"ok", "okay", "yes", "no", "thanks", "thank you", "great", "👍", "+1", "fine", "done"}
|
||||
|
||||
|
||||
def low_signal(body: str) -> bool:
|
||||
normalized = body.strip().lower()
|
||||
return len(normalized) <= 24 and (normalized in LOW_SIGNAL_PHRASES or normalized.replace("!", "") in LOW_SIGNAL_PHRASES)
|
||||
|
||||
|
||||
def message_chat_dict(message: Message, members_by_id: dict[str, Member], current_member_id: str | None) -> dict:
|
||||
author = members_by_id.get(message.author_member_id)
|
||||
return {
|
||||
"id": message.id,
|
||||
"thread_id": message.thread_id,
|
||||
"author_member_id": message.author_member_id,
|
||||
"author_name": author.display_name if author else "Member",
|
||||
"body": message.body,
|
||||
"created_at": iso(message.created_at),
|
||||
"mine": message.author_member_id == current_member_id,
|
||||
"low_signal": low_signal(message.body),
|
||||
}
|
||||
|
||||
|
||||
def thread_chat_dict(db: Session, thread: Thread, group: Group, current_member_id: str | None, include_messages: bool = True) -> dict:
|
||||
members = db.scalars(select(Member).where(Member.group_id == group.id)).all()
|
||||
members_by_id = {member.id: member for member in members}
|
||||
messages = []
|
||||
latest_message = None
|
||||
if include_messages:
|
||||
rows = list(db.scalars(select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at)).all())
|
||||
messages = [message_chat_dict(message, members_by_id, current_member_id) for message in rows]
|
||||
latest_message = messages[-1] if messages else None
|
||||
else:
|
||||
row = db.scalar(select(Message).where(Message.thread_id == thread.id).order_by(desc(Message.created_at)).limit(1))
|
||||
latest_message = message_chat_dict(row, members_by_id, current_member_id) if row else None
|
||||
return {
|
||||
"id": thread.id,
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"title": thread.title,
|
||||
"kind": thread.kind,
|
||||
"created_at": iso(thread.created_at),
|
||||
"updated_at": iso(thread.updated_at),
|
||||
"latest_message": latest_message,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/chat")
|
||||
def chat_home(
|
||||
group_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
ctx: CurrentContext = Depends(get_current_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
memberships = get_members_for_context(db, ctx)
|
||||
if not memberships:
|
||||
return {"groups": [], "threads": [], "active_group": None, "active_thread": None, "current_member_id": None}
|
||||
|
||||
groups = [db.get(Group, member.group_id) for member in memberships]
|
||||
groups = [group for group in groups if group is not None and not group.archived_at]
|
||||
active_group = next((group for group in groups if group.id == group_id), groups[0])
|
||||
active_member = get_member_for_group(db, ctx, active_group.id)
|
||||
|
||||
threads = list(db.scalars(select(Thread).where(Thread.group_id == active_group.id).order_by(desc(Thread.updated_at))).all())
|
||||
if not threads:
|
||||
thread = Thread(group_id=active_group.id, title="General", kind="discussion", created_by_member_id=active_member.id)
|
||||
db.add(thread)
|
||||
db.flush()
|
||||
db.commit()
|
||||
threads = [thread]
|
||||
active_thread = next((thread for thread in threads if thread.id == thread_id), threads[0])
|
||||
|
||||
members = list(db.scalars(select(Member).where(Member.group_id == active_group.id, Member.status.in_(["joined", "verified"]))).all())
|
||||
return {
|
||||
"groups": [
|
||||
{"group": group_dict(group), "member": member_dict(next(member for member in memberships if member.group_id == group.id))}
|
||||
for group in groups
|
||||
],
|
||||
"active_group": group_dict(active_group),
|
||||
"active_member": member_dict(active_member) if active_member else None,
|
||||
"members": [member_dict(member) for member in members],
|
||||
"threads": [thread_chat_dict(db, thread, active_group, active_member.id if active_member else None, include_messages=False) for thread in threads],
|
||||
"active_thread": thread_chat_dict(db, active_thread, active_group, active_member.id if active_member else None, include_messages=True),
|
||||
"current_member_id": active_member.id if active_member else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/chat/threads")
|
||||
def create_chat_thread(payload: ThreadCreate, group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = db.get(Group, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
|
||||
member = get_member_for_group(db, ctx, group.id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
|
||||
thread = Thread(group_id=group.id, title=payload.title, kind=payload.kind, created_by_member_id=member.id)
|
||||
db.add(thread)
|
||||
audit(db, ctx=ctx, action="chat_thread_created", resource_type="thread", resource_id=thread.id)
|
||||
db.commit()
|
||||
return {"thread": thread_chat_dict(db, thread, group, member.id)}
|
||||
|
||||
|
||||
@router.post("/chat/threads/{thread_id}/messages")
|
||||
def create_chat_message(thread_id: str, payload: MessageCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
thread = db.get(Thread, thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Thread not found.", "details": {}}})
|
||||
group = db.get(Group, thread.group_id)
|
||||
member = get_member_for_group(db, ctx, group.id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
|
||||
if thread.kind == "archive":
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "archive_read_only", "message": "Imported archive threads are read-only.", "details": {}}})
|
||||
from app.core.security import utc_now
|
||||
|
||||
now = utc_now()
|
||||
message = Message(thread_id=thread.id, author_member_id=member.id, body=payload.body, created_at=now)
|
||||
thread.updated_at = now
|
||||
db.add(message)
|
||||
audit(db, ctx=ctx, action="chat_message_created", resource_type="thread", resource_id=thread.id)
|
||||
db.commit()
|
||||
members = {item.id: item for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()}
|
||||
return {"message": message_chat_dict(message, members, member.id)}
|
||||
645
backend/app/routers/groups.py
Normal file
645
backend/app/routers/groups.py
Normal file
@@ -0,0 +1,645 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, sanitize_filename, token_urlsafe, utc_now
|
||||
from app.db.base import get_db
|
||||
from app.models import (
|
||||
Announcement,
|
||||
Event,
|
||||
EventRsvp,
|
||||
FileAsset,
|
||||
Group,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberInvite,
|
||||
Message,
|
||||
Poll,
|
||||
PollOption,
|
||||
PollVote,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
from app.schemas import (
|
||||
AnnouncementCreate,
|
||||
EventCreate,
|
||||
GroupCreate,
|
||||
GroupPatch,
|
||||
InviteCreate,
|
||||
MessageCreate,
|
||||
MigrationReminderRequest,
|
||||
PollCreate,
|
||||
PollVoteCreate,
|
||||
RsvpCreate,
|
||||
TaskCreate,
|
||||
TaskPatch,
|
||||
ThreadCreate,
|
||||
)
|
||||
from app.services.auth import (
|
||||
CurrentContext,
|
||||
audit,
|
||||
create_session,
|
||||
get_current_context,
|
||||
get_member_for_group,
|
||||
get_optional_context,
|
||||
set_session_cookies,
|
||||
)
|
||||
from app.services.dashboard import group_dashboard
|
||||
from app.services.permissions import can, require_role
|
||||
from app.services.serializers import (
|
||||
announcement_dict,
|
||||
event_dict,
|
||||
file_dict,
|
||||
group_dict,
|
||||
member_dict,
|
||||
message_dict,
|
||||
poll_dict,
|
||||
task_dict,
|
||||
thread_dict,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["groups"])
|
||||
|
||||
|
||||
def _group_or_404(db: Session, group_id: str) -> Group:
|
||||
group = db.get(Group, group_id)
|
||||
if not group or group.archived_at:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
|
||||
return group
|
||||
|
||||
|
||||
def _member_or_403(db: Session, ctx: CurrentContext, group_id: str) -> Member:
|
||||
member = get_member_for_group(db, ctx, group_id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "Join this group to continue.", "details": {}}})
|
||||
return member
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def list_groups(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
if ctx.home_profile:
|
||||
members = db.scalars(select(Member).where(Member.home_profile_id == ctx.home_profile.id, Member.status.in_(["joined", "verified"]))).all()
|
||||
elif ctx.member:
|
||||
members = [ctx.member]
|
||||
else:
|
||||
members = []
|
||||
items = []
|
||||
for member in members:
|
||||
group = db.get(Group, member.group_id)
|
||||
if group:
|
||||
items.append({"group": group_dict(group), "member": member_dict(member), "dashboard": group_dashboard(db, group, member)})
|
||||
return {"groups": items}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
def create_group(
|
||||
payload: GroupCreate,
|
||||
response: Response,
|
||||
request: Request,
|
||||
ctx: CurrentContext = Depends(get_optional_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
settings = get_settings()
|
||||
profile = ctx.home_profile
|
||||
if not profile:
|
||||
profile = HomeProfile(primary_display_name=payload.owner_display_name)
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
group = Group(
|
||||
server_origin=settings.server_origin,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
visibility=payload.visibility,
|
||||
legacy_channel_status="transition",
|
||||
)
|
||||
db.add(group)
|
||||
db.flush()
|
||||
owner = Member(
|
||||
group_id=group.id,
|
||||
home_profile_id=profile.id,
|
||||
display_name=payload.owner_display_name,
|
||||
role="owner",
|
||||
status="joined",
|
||||
joined_at=utc_now(),
|
||||
)
|
||||
db.add(owner)
|
||||
if not ctx.authenticated:
|
||||
device = HomeDevice(
|
||||
home_profile_id=profile.id,
|
||||
label="Admin browser",
|
||||
user_agent_summary=request.headers.get("user-agent", "")[:255],
|
||||
trust_level="claimed_browser",
|
||||
)
|
||||
db.add(device)
|
||||
session, csrf_token = create_session(db, home_profile=profile, home_device=device)
|
||||
set_session_cookies(response, session, csrf_token)
|
||||
audit(db, ctx=ctx, action="group_created", resource_type="group", resource_id=group.id)
|
||||
db.commit()
|
||||
return {"group": group_dict(group), "member": member_dict(owner)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}")
|
||||
def get_group(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
members = []
|
||||
if can(member, "manage_members"):
|
||||
members = [member_dict(item) for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()]
|
||||
return {"group": group_dict(group), "member": member_dict(member), "dashboard": group_dashboard(db, group, member), "members": members}
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}")
|
||||
def patch_group(group_id: str, payload: GroupPatch, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "admin")
|
||||
if payload.name is not None:
|
||||
group.name = payload.name
|
||||
if payload.description is not None:
|
||||
group.description = payload.description
|
||||
if payload.visibility is not None:
|
||||
group.visibility = payload.visibility
|
||||
if payload.legacy_channel_status is not None:
|
||||
group.legacy_channel_status = payload.legacy_channel_status
|
||||
if payload.transition_deadline is not None:
|
||||
group.transition_deadline = date.fromisoformat(payload.transition_deadline) if payload.transition_deadline else None
|
||||
group.updated_at = utc_now()
|
||||
audit(db, ctx=ctx, action="group_updated", resource_type="group", resource_id=group.id)
|
||||
db.commit()
|
||||
return {"group": group_dict(group)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/invites")
|
||||
def create_invite(group_id: str, payload: InviteCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
actor = _member_or_403(db, ctx, group.id)
|
||||
require_role(actor, "admin")
|
||||
raw_token = token_urlsafe(24)
|
||||
invite = MemberInvite(
|
||||
group_id=group.id,
|
||||
member_id=payload.member_id,
|
||||
created_by_member_id=actor.id,
|
||||
label=payload.label,
|
||||
scope=payload.scope,
|
||||
permission_role=payload.permission_role,
|
||||
token_hash=hash_token(raw_token),
|
||||
expires_at=payload.expires_at,
|
||||
max_uses=payload.max_uses,
|
||||
)
|
||||
db.add(invite)
|
||||
audit(db, ctx=ctx, action="invite_created", resource_type="group", resource_id=group.id, details={"invite_id": invite.id})
|
||||
db.commit()
|
||||
frontend_origin = get_settings().frontend_origin.rstrip("/")
|
||||
return {"invite": {"id": invite.id, "label": invite.label, "max_uses": invite.max_uses}, "token_display_once": raw_token, "invite_url": f"{frontend_origin}/join/{raw_token}"}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
def group_members(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "admin")
|
||||
return {"members": [member_dict(item) for item in db.scalars(select(Member).where(Member.group_id == group.id)).all()]}
|
||||
|
||||
|
||||
@router.patch("/groups/{group_id}/members/{member_id}")
|
||||
def patch_member(group_id: str, member_id: str, payload: dict, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
actor = _member_or_403(db, ctx, group.id)
|
||||
require_role(actor, "admin")
|
||||
member = db.get(Member, member_id)
|
||||
if not member or member.group_id != group.id:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Member not found.", "details": {}}})
|
||||
if "role" in payload:
|
||||
member.role = payload["role"]
|
||||
if "status" in payload:
|
||||
member.status = payload["status"]
|
||||
audit(db, ctx=ctx, action="member_updated", resource_type="member", resource_id=member.id)
|
||||
db.commit()
|
||||
return {"member": member_dict(member)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/announcements")
|
||||
def list_announcements(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
return {"announcements": [announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id)).all()]}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/announcements")
|
||||
def create_announcement(group_id: str, payload: AnnouncementCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
if payload.official:
|
||||
require_role(member, "moderator")
|
||||
announcement = Announcement(
|
||||
group_id=group.id,
|
||||
author_member_id=member.id,
|
||||
title=payload.title,
|
||||
body=payload.body,
|
||||
priority=payload.priority,
|
||||
official=payload.official,
|
||||
requires_ack=payload.requires_ack,
|
||||
)
|
||||
db.add(announcement)
|
||||
audit(db, ctx=ctx, action="announcement_created", resource_type="announcement", resource_id=announcement.id)
|
||||
db.commit()
|
||||
return {"announcement": announcement_dict(announcement, group)}
|
||||
|
||||
|
||||
@router.get("/announcements/{announcement_id}")
|
||||
def announcement_detail(announcement_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
announcement = db.get(Announcement, announcement_id)
|
||||
if not announcement:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Announcement not found.", "details": {}}})
|
||||
group = db.get(Group, announcement.group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
return {"announcement": announcement_dict(announcement, group)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/events")
|
||||
def list_events(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
events = db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at)).all()
|
||||
return {
|
||||
"events": [
|
||||
event_dict(item, group, (db.scalar(select(EventRsvp).where(EventRsvp.event_id == item.id, EventRsvp.member_id == member.id)) or EventRsvp(status="unknown")).status)
|
||||
for item in events
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/events")
|
||||
def create_event(group_id: str, payload: EventCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "moderator")
|
||||
event = Event(
|
||||
group_id=group.id,
|
||||
created_by_member_id=member.id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
starts_at=payload.starts_at,
|
||||
ends_at=payload.ends_at,
|
||||
location_name=payload.location_name,
|
||||
location_address=payload.location_address,
|
||||
rsvp_required=payload.rsvp_required,
|
||||
)
|
||||
db.add(event)
|
||||
audit(db, ctx=ctx, action="event_created", resource_type="event", resource_id=event.id)
|
||||
db.commit()
|
||||
return {"event": event_dict(event, group)}
|
||||
|
||||
|
||||
@router.get("/events/{event_id}")
|
||||
def event_detail(event_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
event = db.get(Event, event_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Event not found.", "details": {}}})
|
||||
group = db.get(Group, event.group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event.id, EventRsvp.member_id == member.id))
|
||||
return {"event": event_dict(event, group, rsvp.status if rsvp else "unknown")}
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/rsvp")
|
||||
def rsvp(event_id: str, payload: RsvpCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
event = db.get(Event, event_id)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Event not found.", "details": {}}})
|
||||
group = db.get(Group, event.group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
existing = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event.id, EventRsvp.member_id == member.id))
|
||||
if existing:
|
||||
existing.status = payload.status
|
||||
existing.note = payload.note
|
||||
existing.updated_at = utc_now()
|
||||
rsvp_row = existing
|
||||
else:
|
||||
rsvp_row = EventRsvp(event_id=event.id, member_id=member.id, status=payload.status, note=payload.note, updated_at=utc_now())
|
||||
db.add(rsvp_row)
|
||||
audit(db, ctx=ctx, action="event_rsvp", resource_type="event", resource_id=event.id, details={"status": payload.status})
|
||||
db.commit()
|
||||
return {"rsvp": {"id": rsvp_row.id, "event_id": event.id, "member_id": member.id, "status": rsvp_row.status, "note": rsvp_row.note, "updated_at": rsvp_row.updated_at.isoformat()}, "event": event_dict(event, group, rsvp_row.status)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/tasks")
|
||||
def list_tasks(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
return {"tasks": [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id)).all()]}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/tasks")
|
||||
def create_task(group_id: str, payload: TaskCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "moderator")
|
||||
task = Task(
|
||||
group_id=group.id,
|
||||
created_by_member_id=member.id,
|
||||
assigned_to_member_id=payload.assigned_to_member_id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
due_at=payload.due_at,
|
||||
)
|
||||
db.add(task)
|
||||
audit(db, ctx=ctx, action="task_created", resource_type="task", resource_id=task.id)
|
||||
db.commit()
|
||||
return {"task": task_dict(task, group)}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def task_detail(task_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Task not found.", "details": {}}})
|
||||
group = db.get(Group, task.group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
return {"task": task_dict(task, group)}
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
def patch_task(task_id: str, payload: TaskPatch, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Task not found.", "details": {}}})
|
||||
group = db.get(Group, task.group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
if task.assigned_to_member_id != member.id:
|
||||
require_role(member, "moderator")
|
||||
for field in ["title", "description", "assigned_to_member_id", "due_at", "status"]:
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(task, field, value)
|
||||
task.updated_at = utc_now()
|
||||
audit(db, ctx=ctx, action="task_updated", resource_type="task", resource_id=task.id)
|
||||
db.commit()
|
||||
return {"task": task_dict(task, group)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/polls")
|
||||
def list_polls(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
polls = []
|
||||
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
|
||||
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
|
||||
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
|
||||
polls.append(poll_dict(poll, options, votes, group))
|
||||
return {"polls": polls}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/polls")
|
||||
def create_poll(group_id: str, payload: PollCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
poll = Poll(group_id=group.id, created_by_member_id=member.id, title=payload.title, description=payload.description, closes_at=payload.closes_at)
|
||||
db.add(poll)
|
||||
db.flush()
|
||||
for index, option in enumerate(payload.options):
|
||||
db.add(PollOption(poll_id=poll.id, label=option.label, position=index))
|
||||
audit(db, ctx=ctx, action="poll_created", resource_type="poll", resource_id=poll.id)
|
||||
db.commit()
|
||||
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
|
||||
return {"poll": poll_dict(poll, options, [], group)}
|
||||
|
||||
|
||||
@router.get("/polls/{poll_id}")
|
||||
def poll_detail(poll_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
poll = db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Poll not found.", "details": {}}})
|
||||
group = db.get(Group, poll.group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
|
||||
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
|
||||
return {"poll": poll_dict(poll, options, votes, group)}
|
||||
|
||||
|
||||
@router.post("/polls/{poll_id}/vote")
|
||||
def vote_poll(poll_id: str, payload: PollVoteCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
poll = db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Poll not found.", "details": {}}})
|
||||
group = db.get(Group, poll.group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
option = db.get(PollOption, payload.option_id)
|
||||
if not option or option.poll_id != poll.id:
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "invalid_option", "message": "Choose one of the poll options.", "details": {}}})
|
||||
existing = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
|
||||
if existing:
|
||||
existing.option_id = option.id
|
||||
else:
|
||||
db.add(PollVote(poll_id=poll.id, option_id=option.id, member_id=member.id))
|
||||
audit(db, ctx=ctx, action="poll_voted", resource_type="poll", resource_id=poll.id)
|
||||
db.commit()
|
||||
options = list(db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all())
|
||||
votes = list(db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all())
|
||||
return {"poll": poll_dict(poll, options, votes, group)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/files")
|
||||
def list_group_files(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
return {"files": [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()]}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/files")
|
||||
async def upload_file(
|
||||
group_id: str,
|
||||
upload: UploadFile = File(...),
|
||||
description: str = Form(""),
|
||||
requires_ack: bool = Form(False),
|
||||
ctx: CurrentContext = Depends(get_current_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
if not can(member, "upload_file"):
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "permission_denied", "message": "You cannot upload files here.", "details": {}}})
|
||||
content = await upload.read()
|
||||
settings = get_settings()
|
||||
if len(content) > settings.max_upload_bytes:
|
||||
raise HTTPException(status_code=413, detail={"error": {"code": "file_too_large", "message": "This file is too large.", "details": {}}})
|
||||
original = sanitize_filename(upload.filename or "upload.bin")
|
||||
stored = f"{token_urlsafe(8)}-{original}"
|
||||
group_dir = Path(settings.upload_dir) / group.id
|
||||
group_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = group_dir / stored
|
||||
path.write_bytes(content)
|
||||
file_asset = FileAsset(
|
||||
group_id=group.id,
|
||||
uploaded_by_member_id=member.id,
|
||||
filename_original=original,
|
||||
filename_stored=stored,
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
size_bytes=len(content),
|
||||
storage_path=str(path),
|
||||
description=description,
|
||||
requires_ack=requires_ack,
|
||||
)
|
||||
db.add(file_asset)
|
||||
audit(db, ctx=ctx, action="file_uploaded", resource_type="file", resource_id=file_asset.id)
|
||||
db.commit()
|
||||
return {"file": file_dict(file_asset, group)}
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/download")
|
||||
def download_file(file_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)):
|
||||
file_asset = db.get(FileAsset, file_id)
|
||||
if not file_asset:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "File not found.", "details": {}}})
|
||||
group = db.get(Group, file_asset.group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
if file_asset.storage_path.startswith("seed://"):
|
||||
return PlainTextResponse(file_asset.description or file_asset.filename_original, media_type="text/plain")
|
||||
return FileResponse(file_asset.storage_path, media_type=file_asset.content_type, filename=file_asset.filename_original)
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/threads")
|
||||
def list_threads(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
_member_or_403(db, ctx, group.id)
|
||||
threads = []
|
||||
for thread in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all():
|
||||
messages = list(db.scalars(select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at)).all())
|
||||
threads.append(thread_dict(thread, messages, group))
|
||||
return {"threads": threads}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/threads")
|
||||
def create_thread(group_id: str, payload: ThreadCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
thread = Thread(group_id=group.id, created_by_member_id=member.id, title=payload.title, kind=payload.kind)
|
||||
db.add(thread)
|
||||
audit(db, ctx=ctx, action="thread_created", resource_type="thread", resource_id=thread.id)
|
||||
db.commit()
|
||||
return {"thread": thread_dict(thread, group=group)}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/messages")
|
||||
def create_message(thread_id: str, payload: MessageCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
thread = db.get(Thread, thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Thread not found.", "details": {}}})
|
||||
group = db.get(Group, thread.group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
if thread.kind == "archive":
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "archive_read_only", "message": "Imported archive threads are read-only.", "details": {}}})
|
||||
message = Message(thread_id=thread.id, author_member_id=member.id, body=payload.body)
|
||||
thread.updated_at = utc_now()
|
||||
db.add(message)
|
||||
audit(db, ctx=ctx, action="message_created", resource_type="thread", resource_id=thread.id)
|
||||
db.commit()
|
||||
return {"message": message_dict(message)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/migration")
|
||||
def migration_dashboard(group_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "admin")
|
||||
members = list(db.scalars(select(Member).where(Member.group_id == group.id)).all())
|
||||
invites = list(db.scalars(select(MemberInvite).where(MemberInvite.group_id == group.id)).all())
|
||||
stats = {
|
||||
"invited": len([item for item in members if item.status == "invited"]) + sum(item.max_uses for item in invites),
|
||||
"opened": len([item for item in members if item.status == "opened"]) + sum(item.opened_count for item in invites),
|
||||
"joined": len([item for item in members if item.status in {"joined", "verified"}]),
|
||||
"verified": len([item for item in members if item.status == "verified" or item.home_profile_id]),
|
||||
"notification_enabled": len([item for item in members if item.notification_enabled_at]),
|
||||
"not_reached": len([item for item in members if item.status == "invited"]),
|
||||
}
|
||||
return {
|
||||
"group": group_dict(group),
|
||||
"stats": stats,
|
||||
"members": [member_dict(item) for item in members],
|
||||
"invites": [
|
||||
{
|
||||
"id": item.id,
|
||||
"label": item.label,
|
||||
"scope": item.scope,
|
||||
"role": item.permission_role,
|
||||
"max_uses": item.max_uses,
|
||||
"use_count": item.use_count,
|
||||
"opened_count": item.opened_count,
|
||||
"expires_at": item.expires_at.isoformat() if item.expires_at else None,
|
||||
"revoked_at": item.revoked_at.isoformat() if item.revoked_at else None,
|
||||
}
|
||||
for item in invites
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/migration/reminder-copy")
|
||||
def migration_reminder_copy(
|
||||
group_id: str,
|
||||
payload: MigrationReminderRequest,
|
||||
ctx: CurrentContext = Depends(get_current_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "admin")
|
||||
raw_token = token_urlsafe(24)
|
||||
invite = MemberInvite(
|
||||
group_id=group.id,
|
||||
created_by_member_id=member.id,
|
||||
label="Migration reminder invite",
|
||||
scope="open_seat",
|
||||
permission_role="member",
|
||||
token_hash=hash_token(raw_token),
|
||||
max_uses=250,
|
||||
)
|
||||
db.add(invite)
|
||||
db.flush()
|
||||
origin = (payload.frontend_origin or get_settings().frontend_origin).rstrip("/")
|
||||
link = f"{origin}/join/{raw_token}"
|
||||
stats = migration_dashboard(group_id, ctx, db)["stats"]
|
||||
deadline = group.transition_deadline.isoformat() if group.transition_deadline else "the transition date"
|
||||
copy = (
|
||||
f"{stats['joined']} people have joined our new group space.\n"
|
||||
f"The schedule, RSVP, files, and official updates are now here: {link}\n"
|
||||
f"From {deadline}, official announcements will only be posted there."
|
||||
)
|
||||
audit(db, ctx=ctx, action="migration_reminder_created", resource_type="group", resource_id=group.id)
|
||||
db.commit()
|
||||
return {"copy": copy, "invite_url": link, "token_display_once": raw_token}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/migration/import-whatsapp-export")
|
||||
async def import_whatsapp_export(
|
||||
group_id: str,
|
||||
upload: UploadFile = File(...),
|
||||
ctx: CurrentContext = Depends(get_current_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
group = _group_or_404(db, group_id)
|
||||
member = _member_or_403(db, ctx, group.id)
|
||||
require_role(member, "admin")
|
||||
if not (upload.filename or "").lower().endswith(".txt"):
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "unsupported_file", "message": "Upload a .txt chat export.", "details": {}}})
|
||||
content = (await upload.read()).decode("utf-8", errors="replace")
|
||||
thread = Thread(group_id=group.id, created_by_member_id=member.id, title="Imported chat archive", kind="archive")
|
||||
db.add(thread)
|
||||
db.flush()
|
||||
imported = 0
|
||||
for line in content.splitlines()[:500]:
|
||||
body = line.strip()
|
||||
if not body:
|
||||
continue
|
||||
db.add(Message(thread_id=thread.id, author_member_id=member.id, body=body))
|
||||
imported += 1
|
||||
audit(db, ctx=ctx, action="whatsapp_export_imported", resource_type="group", resource_id=group.id, details={"messages": imported})
|
||||
db.commit()
|
||||
return {"thread": thread_dict(thread, group=group), "imported_messages": imported, "read_only": True}
|
||||
167
backend/app/routers/home.py
Normal file
167
backend/app/routers/home.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import utc_now
|
||||
from app.db.base import get_db
|
||||
from app.models import Notification, NotificationPreference
|
||||
from app.schemas import NotificationPreferencePatch
|
||||
from app.services.auth import CurrentContext, get_current_context
|
||||
from app.services.dashboard import calendar_items, file_items, home_dashboard, local_actions_for_context
|
||||
from app.services.serializers import profile_dict
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["home"])
|
||||
|
||||
|
||||
DEFAULT_PREFERENCES = {
|
||||
"direct_mentions": "immediate",
|
||||
"event_changes": "immediate",
|
||||
"urgent_announcements": "immediate",
|
||||
"tasks_assigned": "immediate",
|
||||
"discussions": "digest",
|
||||
"new_files": "digest",
|
||||
"general_chatter": "digest",
|
||||
"reactions": "muted",
|
||||
"off_topic": "muted",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
def home(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
dashboard = home_dashboard(db, ctx)
|
||||
return {
|
||||
"profile": profile_dict(ctx.home_profile, ctx.member),
|
||||
"sections": dashboard["sections"],
|
||||
"connections": dashboard["connections"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/home/actions")
|
||||
def home_actions(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
return {"actions": home_dashboard(db, ctx)["sections"]["needs_me"]}
|
||||
|
||||
|
||||
@router.get("/home/calendar")
|
||||
def home_calendar(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
return {"events": calendar_items(db, ctx)}
|
||||
|
||||
|
||||
@router.get("/home/files")
|
||||
def home_files(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
return {"files": file_items(db, ctx)}
|
||||
|
||||
|
||||
@router.get("/home/official-updates")
|
||||
def official_updates(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
return {"official_updates": home_dashboard(db, ctx)["sections"]["official_updates"]}
|
||||
|
||||
|
||||
@router.get("/home/catch-up")
|
||||
def catch_up(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
return {"catch_up": home_dashboard(db, ctx)["sections"]["catch_up"]}
|
||||
|
||||
|
||||
def _preference_owner(ctx: CurrentContext) -> tuple[str | None, str | None]:
|
||||
if ctx.home_profile:
|
||||
return ctx.home_profile.id, None
|
||||
if ctx.member:
|
||||
return None, ctx.member.id
|
||||
return None, None
|
||||
|
||||
|
||||
@router.get("/me/notification-preferences")
|
||||
def get_notification_preferences(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
home_profile_id, member_id = _preference_owner(ctx)
|
||||
rows = db.scalars(
|
||||
select(NotificationPreference).where(
|
||||
NotificationPreference.home_profile_id == home_profile_id,
|
||||
NotificationPreference.member_id == member_id,
|
||||
)
|
||||
).all()
|
||||
result = dict(DEFAULT_PREFERENCES)
|
||||
for row in rows:
|
||||
result[row.category] = row.delivery
|
||||
return {
|
||||
"headline": "Mute the noise, not the group.",
|
||||
"preferences": result,
|
||||
"groups": {
|
||||
"Immediate": ["direct_mentions", "event_changes", "urgent_announcements", "tasks_assigned"],
|
||||
"Quiet / digest": ["discussions", "new_files", "general_chatter"],
|
||||
"Mute": ["reactions", "off_topic"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/me/notification-preferences")
|
||||
def patch_notification_preferences(
|
||||
payload: NotificationPreferencePatch,
|
||||
ctx: CurrentContext = Depends(get_current_context),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
home_profile_id, member_id = _preference_owner(ctx)
|
||||
if home_profile_id is None and member_id is None:
|
||||
raise HTTPException(status_code=401, detail={"error": {"code": "not_authenticated", "message": "Open an invite first.", "details": {}}})
|
||||
for category, delivery in payload.preferences.items():
|
||||
row = db.scalar(
|
||||
select(NotificationPreference).where(
|
||||
NotificationPreference.home_profile_id == home_profile_id,
|
||||
NotificationPreference.member_id == member_id,
|
||||
NotificationPreference.category == category,
|
||||
)
|
||||
)
|
||||
if row:
|
||||
row.delivery = delivery
|
||||
row.enabled = delivery != "muted"
|
||||
row.updated_at = utc_now()
|
||||
else:
|
||||
db.add(
|
||||
NotificationPreference(
|
||||
home_profile_id=home_profile_id,
|
||||
member_id=member_id,
|
||||
category=category,
|
||||
delivery=delivery,
|
||||
enabled=delivery != "muted",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return get_notification_preferences(ctx, db)
|
||||
|
||||
|
||||
@router.get("/me/notifications")
|
||||
def notifications(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
query = select(Notification)
|
||||
if ctx.home_profile:
|
||||
query = query.where(Notification.home_profile_id == ctx.home_profile.id)
|
||||
elif ctx.member:
|
||||
query = query.where(Notification.member_id == ctx.member.id)
|
||||
rows = db.scalars(query.order_by(Notification.created_at.desc()).limit(50)).all()
|
||||
return {
|
||||
"notifications": [
|
||||
{
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"body": item.body,
|
||||
"category": item.category,
|
||||
"read_at": item.read_at.isoformat() if item.read_at else None,
|
||||
"created_at": item.created_at.isoformat(),
|
||||
}
|
||||
for item in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/me/notifications/{notification_id}/read")
|
||||
def mark_notification_read(notification_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
query = select(Notification).where(Notification.id == notification_id)
|
||||
if ctx.home_profile:
|
||||
query = query.where(Notification.home_profile_id == ctx.home_profile.id)
|
||||
elif ctx.member:
|
||||
query = query.where(Notification.member_id == ctx.member.id)
|
||||
notification = db.scalar(query)
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Notification not found.", "details": {}}})
|
||||
notification.read_at = utc_now()
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
119
backend/app/routers/remote.py
Normal file
119
backend/app/routers/remote.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_token, token_urlsafe
|
||||
from app.db.base import get_db
|
||||
from app.models import ConnectionToken, Group, RemoteServerConnection
|
||||
from app.schemas import ConnectionTokenCreate, RemoteConnect
|
||||
from app.services.auth import CurrentContext, audit, ensure_home_profile, get_current_context, get_member_for_group
|
||||
from app.services.permissions import require_role
|
||||
from app.services.remote import fetch_manifest, manifest, mask_store_token, sync_connection, sync_payload_for_token, validate_connection_token
|
||||
from app.services.serializers import remote_connection_dict
|
||||
|
||||
api_router = APIRouter(prefix="/api", tags=["remote"])
|
||||
well_known_router = APIRouter(tags=["remote"])
|
||||
|
||||
|
||||
@well_known_router.get("/.well-known/group-platform.json")
|
||||
def well_known_manifest() -> dict:
|
||||
return manifest()
|
||||
|
||||
|
||||
@api_router.get("/sync")
|
||||
def sync(since: str | None = None, authorization: str | None = Header(default=None), db: Session = Depends(get_db)) -> dict:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail={"error": {"code": "token_required", "message": "Connection code required.", "details": {}}})
|
||||
raw_token = authorization.split(" ", 1)[1].strip()
|
||||
token = validate_connection_token(db, raw_token)
|
||||
if not token or "sync:read" not in token.scopes_json:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail={"error": {"code": "permission_denied", "message": "This connection cannot sync.", "details": {}}})
|
||||
return sync_payload_for_token(db, token)
|
||||
|
||||
|
||||
@api_router.post("/connection-tokens")
|
||||
def create_connection_token(payload: ConnectionTokenCreate, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
actor = None
|
||||
if payload.group_id:
|
||||
group = db.get(Group, payload.group_id)
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Group not found.", "details": {}}})
|
||||
actor = get_member_for_group(db, ctx, group.id)
|
||||
require_role(actor, "admin")
|
||||
elif ctx.member:
|
||||
actor = ctx.member
|
||||
raw = token_urlsafe(32)
|
||||
token = ConnectionToken(
|
||||
created_by_member_id=actor.id if actor else None,
|
||||
group_id=payload.group_id,
|
||||
label=payload.label,
|
||||
token_hash=hash_token(raw),
|
||||
scopes_json=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
db.add(token)
|
||||
audit(db, ctx=ctx, action="connection_token_created", resource_type="connection_token", resource_id=token.id)
|
||||
db.commit()
|
||||
return {"connection_code_display_once": raw, "token": {"id": token.id, "label": token.label, "scopes": token.scopes_json, "expires_at": token.expires_at.isoformat() if token.expires_at else None}}
|
||||
|
||||
|
||||
@api_router.get("/remote/servers")
|
||||
def remote_servers(ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
if not ctx.home_profile:
|
||||
return {"servers": []}
|
||||
rows = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
|
||||
return {"servers": [remote_connection_dict(item) for item in rows]}
|
||||
|
||||
|
||||
@api_router.post("/remote/servers/connect")
|
||||
def connect_remote(payload: RemoteConnect, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
profile = ensure_home_profile(db, ctx)
|
||||
server_url = str(payload.server_url).rstrip("/")
|
||||
try:
|
||||
remote_manifest = fetch_manifest(server_url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail={"error": {"code": "remote_unreachable", "message": "Could not read that server's group manifest.", "details": {"reason": str(exc)}}}) from exc
|
||||
connection = RemoteServerConnection(
|
||||
home_profile_id=profile.id,
|
||||
server_origin=server_url,
|
||||
server_name=remote_manifest.get("server_name", server_url),
|
||||
api_base=remote_manifest.get("api_base", f"{server_url}/api"),
|
||||
protocol_version=remote_manifest.get("protocol_version", "0.1"),
|
||||
capabilities_json=remote_manifest.get("capabilities", {}),
|
||||
access_token_encrypted=mask_store_token(payload.connection_code),
|
||||
status="active",
|
||||
)
|
||||
db.add(connection)
|
||||
db.flush()
|
||||
audit(db, ctx=ctx, action="remote_server_connected", resource_type="remote_connection", resource_id=connection.id)
|
||||
sync_connection(db, connection)
|
||||
db.commit()
|
||||
return {"server": remote_connection_dict(connection)}
|
||||
|
||||
|
||||
@api_router.post("/remote/servers/{connection_id}/sync")
|
||||
def sync_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
if not ctx.home_profile:
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before connecting servers.", "details": {}}})
|
||||
connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id))
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}})
|
||||
sync_connection(db, connection)
|
||||
audit(db, ctx=ctx, action="remote_server_synced", resource_type="remote_connection", resource_id=connection.id)
|
||||
db.commit()
|
||||
return {"server": remote_connection_dict(connection)}
|
||||
|
||||
|
||||
@api_router.delete("/remote/servers/{connection_id}")
|
||||
def delete_remote_server(connection_id: str, ctx: CurrentContext = Depends(get_current_context), db: Session = Depends(get_db)) -> dict:
|
||||
if not ctx.home_profile:
|
||||
raise HTTPException(status_code=403, detail={"error": {"code": "profile_required", "message": "Save access before managing servers.", "details": {}}})
|
||||
connection = db.scalar(select(RemoteServerConnection).where(RemoteServerConnection.id == connection_id, RemoteServerConnection.home_profile_id == ctx.home_profile.id))
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail={"error": {"code": "not_found", "message": "Connected server not found.", "details": {}}})
|
||||
connection.status = "revoked"
|
||||
audit(db, ctx=ctx, action="remote_server_removed", resource_type="remote_connection", resource_id=connection.id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
143
backend/app/schemas/__init__.py
Normal file
143
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class InviteClaim(BaseModel):
|
||||
display_name: str = Field(min_length=1, max_length=160)
|
||||
device_label: str = Field(default="Browser", max_length=160)
|
||||
|
||||
|
||||
class RecoveryRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=255)
|
||||
|
||||
|
||||
class RecoveryConsume(BaseModel):
|
||||
recovery_code: str = Field(min_length=6, max_length=300)
|
||||
device_label: str = Field(default="Recovered browser", max_length=160)
|
||||
|
||||
|
||||
class DeviceLinkStart(BaseModel):
|
||||
device_label: str = Field(default="New browser", max_length=160)
|
||||
|
||||
|
||||
class DeviceLinkCodeIn(BaseModel):
|
||||
code: str = Field(min_length=6, max_length=32)
|
||||
|
||||
|
||||
class DeviceLinkComplete(BaseModel):
|
||||
code: str = Field(min_length=6, max_length=32)
|
||||
device_label: str = Field(default="Linked browser", max_length=160)
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=180)
|
||||
description: str = Field(default="", max_length=4000)
|
||||
visibility: Literal["private", "public", "listed"] = "private"
|
||||
owner_display_name: str = Field(default="Group admin", max_length=160)
|
||||
|
||||
|
||||
class GroupPatch(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=180)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
visibility: Literal["private", "public", "listed"] | None = None
|
||||
legacy_channel_status: Literal["none", "transition", "legacy"] | None = None
|
||||
transition_deadline: str | None = None
|
||||
|
||||
|
||||
class InviteCreate(BaseModel):
|
||||
label: str = Field(default="Group invite", max_length=160)
|
||||
scope: Literal["specific_member", "open_seat", "admin_invite"] = "open_seat"
|
||||
permission_role: Literal["guest", "member", "admin"] = "member"
|
||||
max_uses: int = Field(default=50, ge=1, le=500)
|
||||
expires_at: datetime | None = None
|
||||
member_id: str | None = None
|
||||
|
||||
|
||||
class AnnouncementCreate(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=220)
|
||||
body: str = Field(default="", max_length=12000)
|
||||
priority: Literal["normal", "urgent"] = "normal"
|
||||
official: bool = True
|
||||
requires_ack: bool = False
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=220)
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
starts_at: datetime
|
||||
ends_at: datetime | None = None
|
||||
location_name: str | None = Field(default=None, max_length=220)
|
||||
location_address: str | None = Field(default=None, max_length=255)
|
||||
rsvp_required: bool = True
|
||||
|
||||
|
||||
class RsvpCreate(BaseModel):
|
||||
status: Literal["yes", "no", "maybe", "unknown"]
|
||||
note: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=220)
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
assigned_to_member_id: str | None = None
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=220)
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
assigned_to_member_id: str | None = None
|
||||
due_at: datetime | None = None
|
||||
status: Literal["open", "done", "cancelled"] | None = None
|
||||
|
||||
|
||||
class PollOptionIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=220)
|
||||
|
||||
|
||||
class PollCreate(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=220)
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
closes_at: datetime | None = None
|
||||
options: list[PollOptionIn] = Field(min_length=2, max_length=12)
|
||||
|
||||
|
||||
class PollVoteCreate(BaseModel):
|
||||
option_id: str
|
||||
|
||||
|
||||
class ThreadCreate(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=220)
|
||||
kind: Literal["discussion", "question", "archive"] = "discussion"
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=12000)
|
||||
|
||||
|
||||
class NotificationPreferencePatch(BaseModel):
|
||||
preferences: dict[str, Literal["immediate", "digest", "muted"]]
|
||||
|
||||
|
||||
class MigrationReminderRequest(BaseModel):
|
||||
frontend_origin: str | None = None
|
||||
|
||||
|
||||
class ConnectionTokenCreate(BaseModel):
|
||||
label: str = Field(default="Home server connection", max_length=160)
|
||||
group_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=lambda: ["sync:read"])
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class RemoteConnect(BaseModel):
|
||||
server_url: HttpUrl
|
||||
connection_code: str = Field(min_length=10, max_length=300)
|
||||
|
||||
|
||||
class PasskeyStub(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=160)
|
||||
2
backend/app/services/__init__.py
Normal file
2
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Application services."""
|
||||
|
||||
194
backend/app/services/auth.py
Normal file
194
backend/app/services/auth.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, session_expiry, token_urlsafe, utc_now
|
||||
from app.db.base import get_db
|
||||
from app.models import (
|
||||
AppSession,
|
||||
AuditLog,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberDevice,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentContext:
|
||||
session: AppSession | None
|
||||
home_profile: HomeProfile | None
|
||||
member: Member | None
|
||||
home_device: HomeDevice | None
|
||||
member_device: MemberDevice | None
|
||||
|
||||
@property
|
||||
def authenticated(self) -> bool:
|
||||
return self.session is not None
|
||||
|
||||
|
||||
def _expired(dt: datetime) -> bool:
|
||||
now = utc_now()
|
||||
if dt.tzinfo is None:
|
||||
return dt < now.replace(tzinfo=None)
|
||||
return dt < now
|
||||
|
||||
|
||||
def create_session(
|
||||
db: Session,
|
||||
*,
|
||||
home_profile: HomeProfile | None = None,
|
||||
member: Member | None = None,
|
||||
home_device: HomeDevice | None = None,
|
||||
member_device: MemberDevice | None = None,
|
||||
) -> tuple[AppSession, str]:
|
||||
csrf_token = token_urlsafe(24)
|
||||
session = AppSession(
|
||||
home_profile_id=home_profile.id if home_profile else None,
|
||||
member_id=member.id if member else None,
|
||||
home_device_id=home_device.id if home_device else None,
|
||||
member_device_id=member_device.id if member_device else None,
|
||||
csrf_token_hash=hash_token(csrf_token),
|
||||
expires_at=session_expiry(),
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
return session, csrf_token
|
||||
|
||||
|
||||
def set_session_cookies(response: Response, session: AppSession, csrf_token: str) -> None:
|
||||
settings = get_settings()
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
session.id,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
"grouphome_csrf",
|
||||
csrf_token,
|
||||
httponly=False,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookies(response: Response) -> None:
|
||||
settings = get_settings()
|
||||
response.delete_cookie(settings.session_cookie_name, path="/")
|
||||
response.delete_cookie("grouphome_csrf", path="/")
|
||||
|
||||
|
||||
def load_context_from_request(request: Request, db: Session) -> CurrentContext:
|
||||
settings = get_settings()
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
return CurrentContext(None, None, None, None, None)
|
||||
session = db.get(AppSession, session_id)
|
||||
if not session or session.revoked_at or _expired(session.expires_at):
|
||||
return CurrentContext(None, None, None, None, None)
|
||||
|
||||
home_profile = db.get(HomeProfile, session.home_profile_id) if session.home_profile_id else None
|
||||
member = db.get(Member, session.member_id) if session.member_id else None
|
||||
home_device = db.get(HomeDevice, session.home_device_id) if session.home_device_id else None
|
||||
member_device = db.get(MemberDevice, session.member_device_id) if session.member_device_id else None
|
||||
|
||||
now = utc_now()
|
||||
if home_device and not home_device.revoked_at:
|
||||
home_device.last_seen_at = now
|
||||
if member_device and not member_device.revoked_at:
|
||||
member_device.last_seen_at = now
|
||||
db.flush()
|
||||
|
||||
return CurrentContext(session, home_profile, member, home_device, member_device)
|
||||
|
||||
|
||||
def get_optional_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
|
||||
return load_context_from_request(request, db)
|
||||
|
||||
|
||||
def get_current_context(request: Request, db: Session = Depends(get_db)) -> CurrentContext:
|
||||
ctx = load_context_from_request(request, db)
|
||||
if not ctx.authenticated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": {"code": "not_authenticated", "message": "Open an invite or recover access to continue.", "details": {}}},
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
def get_members_for_context(db: Session, ctx: CurrentContext) -> list[Member]:
|
||||
if ctx.home_profile:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Member).where(
|
||||
Member.home_profile_id == ctx.home_profile.id,
|
||||
Member.status.in_(["joined", "verified"]),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if ctx.member:
|
||||
return [ctx.member]
|
||||
return []
|
||||
|
||||
|
||||
def get_member_for_group(db: Session, ctx: CurrentContext, group_id: str) -> Member | None:
|
||||
if ctx.member and ctx.member.group_id == group_id and ctx.member.status != "left":
|
||||
return ctx.member
|
||||
if ctx.home_profile:
|
||||
return db.scalar(
|
||||
select(Member).where(
|
||||
Member.home_profile_id == ctx.home_profile.id,
|
||||
Member.group_id == group_id,
|
||||
Member.status.in_(["joined", "verified"]),
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_home_profile(db: Session, ctx: CurrentContext, display_name: str | None = None) -> HomeProfile:
|
||||
if ctx.home_profile:
|
||||
return ctx.home_profile
|
||||
name = display_name or (ctx.member.display_name if ctx.member else "GroupHome member")
|
||||
profile = HomeProfile(primary_display_name=name)
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
if ctx.member and ctx.member.home_profile_id is None:
|
||||
ctx.member.home_profile_id = profile.id
|
||||
if ctx.session:
|
||||
ctx.session.home_profile_id = profile.id
|
||||
ctx.home_profile = profile
|
||||
db.flush()
|
||||
return profile
|
||||
|
||||
|
||||
def audit(
|
||||
db: Session,
|
||||
*,
|
||||
ctx: CurrentContext | None = None,
|
||||
action: str,
|
||||
resource_type: str = "",
|
||||
resource_id: str = "",
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_member_id=ctx.member.id if ctx and ctx.member else None,
|
||||
actor_home_profile_id=ctx.home_profile.id if ctx and ctx.home_profile else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details_json=details or {},
|
||||
)
|
||||
)
|
||||
341
backend/app/services/dashboard.py
Normal file
341
backend/app/services/dashboard.py
Normal file
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import utc_now
|
||||
from app.models import (
|
||||
Announcement,
|
||||
Event,
|
||||
EventRsvp,
|
||||
FileAsset,
|
||||
Group,
|
||||
Member,
|
||||
Poll,
|
||||
PollOption,
|
||||
PollVote,
|
||||
RemoteCachedObject,
|
||||
RemoteServerConnection,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
from app.services.auth import CurrentContext, get_members_for_context
|
||||
from app.services.serializers import (
|
||||
action_dict,
|
||||
announcement_dict,
|
||||
event_dict,
|
||||
file_dict,
|
||||
poll_dict,
|
||||
remote_connection_dict,
|
||||
task_dict,
|
||||
thread_dict,
|
||||
)
|
||||
|
||||
|
||||
PRIORITY_ORDER = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
|
||||
|
||||
|
||||
def _db_now():
|
||||
return utc_now().replace(tzinfo=None)
|
||||
|
||||
|
||||
def _member_rsvp_status(db: Session, event_id: str, member_id: str) -> str:
|
||||
rsvp = db.scalar(select(EventRsvp).where(EventRsvp.event_id == event_id, EventRsvp.member_id == member_id))
|
||||
return rsvp.status if rsvp else "unknown"
|
||||
|
||||
|
||||
def _local_actions_for_member(db: Session, member: Member) -> list[dict[str, Any]]:
|
||||
group = db.get(Group, member.group_id)
|
||||
if not group:
|
||||
return []
|
||||
now = _db_now()
|
||||
actions: list[dict[str, Any]] = []
|
||||
|
||||
events = db.scalars(select(Event).where(Event.group_id == group.id, Event.starts_at >= now - timedelta(days=1))).all()
|
||||
for event in events:
|
||||
status = _member_rsvp_status(db, event.id, member.id)
|
||||
if event.rsvp_required and status == "unknown":
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:rsvp:{member.id}:{event.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="rsvp_required",
|
||||
priority="urgent" if event.starts_at <= now + timedelta(days=2) else "high",
|
||||
title=f"RSVP: {event.title}",
|
||||
summary=event.location_name or "Let the group know if you can make it.",
|
||||
object_type="event",
|
||||
object_id=event.id,
|
||||
due_at=event.starts_at,
|
||||
)
|
||||
)
|
||||
if event.changed_at and (not member.last_seen_at or event.changed_at > member.last_seen_at):
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:event_changed:{member.id}:{event.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="event_changed",
|
||||
priority="high",
|
||||
title=f"Changed: {event.title}",
|
||||
summary="Time or location changed since your last visit.",
|
||||
object_type="event",
|
||||
object_id=event.id,
|
||||
due_at=event.starts_at,
|
||||
)
|
||||
)
|
||||
|
||||
tasks = db.scalars(select(Task).where(Task.assigned_to_member_id == member.id, Task.status == "open")).all()
|
||||
for task in tasks:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:task:{member.id}:{task.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="task_assigned",
|
||||
priority="high" if task.due_at and task.due_at <= now + timedelta(days=3) else "normal",
|
||||
title=task.title,
|
||||
summary=task.description or "Task assigned to you.",
|
||||
object_type="task",
|
||||
object_id=task.id,
|
||||
due_at=task.due_at,
|
||||
)
|
||||
)
|
||||
|
||||
polls = db.scalars(select(Poll).where(Poll.group_id == group.id, Poll.status == "open")).all()
|
||||
for poll in polls:
|
||||
existing_vote = db.scalar(select(PollVote).where(PollVote.poll_id == poll.id, PollVote.member_id == member.id))
|
||||
if not existing_vote:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:poll:{member.id}:{poll.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="vote_required",
|
||||
priority="high" if poll.closes_at and poll.closes_at <= now + timedelta(days=2) else "normal",
|
||||
title=poll.title,
|
||||
summary=poll.description or "Add your vote.",
|
||||
object_type="poll",
|
||||
object_id=poll.id,
|
||||
due_at=poll.closes_at,
|
||||
)
|
||||
)
|
||||
|
||||
files = db.scalars(select(FileAsset).where(FileAsset.group_id == group.id, FileAsset.requires_ack.is_(True))).all()
|
||||
for file in files:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:file_ack:{member.id}:{file.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="file_ack",
|
||||
priority="normal",
|
||||
title=f"Read: {file.filename_original}",
|
||||
summary=file.description or "Please review this file.",
|
||||
object_type="file",
|
||||
object_id=file.id,
|
||||
due_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
announcements = db.scalars(
|
||||
select(Announcement).where(Announcement.group_id == group.id, Announcement.requires_ack.is_(True), Announcement.official.is_(True))
|
||||
).all()
|
||||
for announcement in announcements:
|
||||
actions.append(
|
||||
action_dict(
|
||||
id=f"local:announcement_ack:{member.id}:{announcement.id}",
|
||||
source_type="local",
|
||||
source_server_origin=group.server_origin,
|
||||
source_group_id=group.id,
|
||||
source_group_name=group.name,
|
||||
type="admin_request",
|
||||
priority="urgent" if announcement.priority == "urgent" else "normal",
|
||||
title=announcement.title,
|
||||
summary=announcement.body[:180],
|
||||
object_type="announcement",
|
||||
object_id=announcement.id,
|
||||
due_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def local_actions_for_context(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
for member in get_members_for_context(db, ctx):
|
||||
actions.extend(_local_actions_for_member(db, member))
|
||||
return sort_actions(actions)
|
||||
|
||||
|
||||
def sort_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(
|
||||
actions,
|
||||
key=lambda item: (
|
||||
PRIORITY_ORDER.get(item.get("priority", "normal"), 9),
|
||||
item.get("due_at") or "9999-12-31T00:00:00",
|
||||
item.get("title") or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def remote_items_for_context(db: Session, ctx: CurrentContext, object_type: str | None = None) -> list[dict[str, Any]]:
|
||||
if not ctx.home_profile:
|
||||
return []
|
||||
connections = db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
|
||||
connection_ids = [connection.id for connection in connections]
|
||||
if not connection_ids:
|
||||
return []
|
||||
query = select(RemoteCachedObject).where(RemoteCachedObject.remote_connection_id.in_(connection_ids))
|
||||
if object_type:
|
||||
query = query.where(RemoteCachedObject.object_type == object_type)
|
||||
rows = db.scalars(query).all()
|
||||
connection_by_id = {connection.id: connection for connection in connections}
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
payload = dict(row.payload_json or {})
|
||||
connection = connection_by_id.get(row.remote_connection_id)
|
||||
payload.setdefault("source_type", "remote")
|
||||
payload.setdefault("source_server_origin", connection.server_origin if connection else "")
|
||||
payload.setdefault("source_group_id", row.group_remote_id)
|
||||
payload.setdefault("source_group_name", row.group_name)
|
||||
payload.setdefault("remote_connection_id", row.remote_connection_id)
|
||||
items.append(payload)
|
||||
return items
|
||||
|
||||
|
||||
def home_dashboard(db: Session, ctx: CurrentContext) -> dict[str, Any]:
|
||||
members = get_members_for_context(db, ctx)
|
||||
member_group_ids = [member.group_id for member in members]
|
||||
now = _db_now()
|
||||
|
||||
local_actions = local_actions_for_context(db, ctx)
|
||||
remote_actions = remote_items_for_context(db, ctx, "action")
|
||||
needs_me = sort_actions(local_actions + remote_actions)
|
||||
|
||||
today: list[dict[str, Any]] = []
|
||||
if member_group_ids:
|
||||
events = db.scalars(
|
||||
select(Event)
|
||||
.where(Event.group_id.in_(member_group_ids), Event.starts_at >= now - timedelta(hours=6))
|
||||
.order_by(Event.starts_at)
|
||||
.limit(50)
|
||||
).all()
|
||||
member_by_group = {member.group_id: member for member in members}
|
||||
for event in events:
|
||||
group = db.get(Group, event.group_id)
|
||||
member = member_by_group.get(event.group_id)
|
||||
today.append(event_dict(event, group, _member_rsvp_status(db, event.id, member.id) if member else None))
|
||||
today.extend(remote_items_for_context(db, ctx, "event"))
|
||||
today = sorted(today, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")[:50]
|
||||
|
||||
changed = [action for action in needs_me if action["type"] in {"event_changed", "admin_request"}][:20]
|
||||
|
||||
official_updates: list[dict[str, Any]] = []
|
||||
if member_group_ids:
|
||||
announcements = db.scalars(
|
||||
select(Announcement)
|
||||
.where(Announcement.group_id.in_(member_group_ids), Announcement.official.is_(True))
|
||||
.order_by(desc(Announcement.created_at))
|
||||
.limit(20)
|
||||
).all()
|
||||
for announcement in announcements:
|
||||
official_updates.append(announcement_dict(announcement, db.get(Group, announcement.group_id)))
|
||||
official_updates.extend(remote_items_for_context(db, ctx, "announcement"))
|
||||
|
||||
discussion_count = 0
|
||||
if member_group_ids:
|
||||
discussion_count = db.scalar(select(func.count()).select_from(Thread).where(Thread.group_id.in_(member_group_ids), Thread.kind == "discussion")) or 0
|
||||
|
||||
catch_up = [
|
||||
{"label": "official announcements", "count": len(official_updates)},
|
||||
{"label": "changed events", "count": len([item for item in changed if item["type"] == "event_changed"])},
|
||||
{"label": "open actions", "count": len(needs_me)},
|
||||
{"label": "discussion threads", "count": discussion_count + len(remote_items_for_context(db, ctx, "thread"))},
|
||||
]
|
||||
|
||||
connections = []
|
||||
if ctx.home_profile:
|
||||
connections = [
|
||||
remote_connection_dict(connection)
|
||||
for connection in db.scalars(select(RemoteServerConnection).where(RemoteServerConnection.home_profile_id == ctx.home_profile.id)).all()
|
||||
]
|
||||
|
||||
return {
|
||||
"sections": {
|
||||
"needs_me": needs_me,
|
||||
"today": today,
|
||||
"changed": changed,
|
||||
"official_updates": official_updates,
|
||||
"catch_up": catch_up,
|
||||
},
|
||||
"connections": connections,
|
||||
}
|
||||
|
||||
|
||||
def calendar_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
items = home_dashboard(db, ctx)["sections"]["today"]
|
||||
return sorted(items, key=lambda item: item.get("starts_at") or "9999-12-31T00:00:00")
|
||||
|
||||
|
||||
def file_items(db: Session, ctx: CurrentContext) -> list[dict[str, Any]]:
|
||||
members = get_members_for_context(db, ctx)
|
||||
group_ids = [member.group_id for member in members]
|
||||
files: list[dict[str, Any]] = []
|
||||
if group_ids:
|
||||
for file in db.scalars(select(FileAsset).where(FileAsset.group_id.in_(group_ids)).order_by(desc(FileAsset.created_at))).all():
|
||||
files.append(file_dict(file, db.get(Group, file.group_id)))
|
||||
files.extend(remote_items_for_context(db, ctx, "file"))
|
||||
return files
|
||||
|
||||
|
||||
def group_dashboard(db: Session, group: Group, member: Member | None = None) -> dict[str, Any]:
|
||||
rsvp_member_id = member.id if member else ""
|
||||
announcements = [
|
||||
announcement_dict(item, group)
|
||||
for item in db.scalars(
|
||||
select(Announcement).where(Announcement.group_id == group.id).order_by(desc(Announcement.created_at)).limit(20)
|
||||
).all()
|
||||
]
|
||||
events = [
|
||||
event_dict(item, group, _member_rsvp_status(db, item.id, rsvp_member_id) if rsvp_member_id else None)
|
||||
for item in db.scalars(select(Event).where(Event.group_id == group.id).order_by(Event.starts_at).limit(30)).all()
|
||||
]
|
||||
tasks = [task_dict(item, group) for item in db.scalars(select(Task).where(Task.group_id == group.id).order_by(Task.status, Task.due_at)).all()]
|
||||
files = [file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id).order_by(desc(FileAsset.created_at))).all()]
|
||||
|
||||
polls = []
|
||||
for poll in db.scalars(select(Poll).where(Poll.group_id == group.id).order_by(desc(Poll.created_at))).all():
|
||||
options = db.scalars(select(PollOption).where(PollOption.poll_id == poll.id).order_by(PollOption.position)).all()
|
||||
votes = db.scalars(select(PollVote).where(PollVote.poll_id == poll.id)).all()
|
||||
polls.append(poll_dict(poll, list(options), list(votes), group))
|
||||
|
||||
threads = [
|
||||
thread_dict(item, group=group)
|
||||
for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at)).limit(20)).all()
|
||||
]
|
||||
important_now = _local_actions_for_member(db, member) if member else []
|
||||
return {
|
||||
"important_now": sort_actions(important_now)[:8],
|
||||
"upcoming": events[:8],
|
||||
"open_actions": sort_actions(important_now),
|
||||
"announcements": announcements,
|
||||
"tasks": tasks,
|
||||
"polls": polls,
|
||||
"files": files,
|
||||
"discussions": threads,
|
||||
}
|
||||
39
backend/app/services/passkeys.py
Normal file
39
backend/app/services/passkeys.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.security import token_urlsafe
|
||||
|
||||
|
||||
class PasskeyProvider:
|
||||
def registration_options(self, display_name: str | None = None) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_registration(self, payload: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def login_options(self) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_login(self, payload: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DevelopmentPasskeyProvider(PasskeyProvider):
|
||||
"""Development-only passkey-shaped adapter.
|
||||
|
||||
It preserves the API contract and UI flow without claiming production WebAuthn.
|
||||
"""
|
||||
|
||||
def registration_options(self, display_name: str | None = None) -> dict:
|
||||
return {"challenge": token_urlsafe(24), "display_name": display_name, "development_only": True}
|
||||
|
||||
def verify_registration(self, payload: dict) -> dict:
|
||||
return {"verified": True, "trust_level": "passkey_ready", "development_only": True}
|
||||
|
||||
def login_options(self) -> dict:
|
||||
return {"challenge": token_urlsafe(24), "development_only": True}
|
||||
|
||||
def verify_login(self, payload: dict) -> dict:
|
||||
return {"verified": True, "development_only": True}
|
||||
|
||||
|
||||
passkey_provider: PasskeyProvider = DevelopmentPasskeyProvider()
|
||||
41
backend/app/services/permissions.py
Normal file
41
backend/app/services/permissions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import Member
|
||||
|
||||
|
||||
ROLE_ORDER = {
|
||||
"guest": 0,
|
||||
"member": 10,
|
||||
"moderator": 20,
|
||||
"admin": 30,
|
||||
"owner": 40,
|
||||
}
|
||||
|
||||
|
||||
def has_role(member: Member | None, min_role: str) -> bool:
|
||||
if member is None:
|
||||
return False
|
||||
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER.get(min_role, 999)
|
||||
|
||||
|
||||
def require_role(member: Member | None, min_role: str = "admin") -> None:
|
||||
if not has_role(member, min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": {"code": "permission_denied", "message": "You do not have permission to do that.", "details": {}}},
|
||||
)
|
||||
|
||||
|
||||
def can(member: Member | None, action: str, resource: object | None = None) -> bool:
|
||||
if member is None:
|
||||
return False
|
||||
if action in {"rsvp", "vote", "comment", "upload_file", "view_group"}:
|
||||
return ROLE_ORDER.get(member.role, -1) >= ROLE_ORDER["member"]
|
||||
if action == "create_official_announcement":
|
||||
return has_role(member, "moderator")
|
||||
if action in {"create_invite", "view_migration", "manage_members", "create_connection_token"}:
|
||||
return has_role(member, "admin")
|
||||
if action in {"create_event", "create_task"}:
|
||||
return has_role(member, "moderator")
|
||||
return False
|
||||
|
||||
175
backend/app/services/remote.py
Normal file
175
backend/app/services/remote.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import hash_token, utc_now
|
||||
from app.models import ConnectionToken, RemoteCachedObject, RemoteServerConnection, RemoteSyncCursor
|
||||
from app.services.dashboard import home_dashboard
|
||||
|
||||
|
||||
def mask_store_token(raw_token: str) -> str:
|
||||
return f"dev:{raw_token}"
|
||||
|
||||
|
||||
def unmask_store_token(stored: str) -> str:
|
||||
if stored.startswith("dev:"):
|
||||
return stored[4:]
|
||||
return stored
|
||||
|
||||
|
||||
def validate_connection_token(db: Session, raw_token: str) -> ConnectionToken | None:
|
||||
token = db.scalar(select(ConnectionToken).where(ConnectionToken.token_hash == hash_token(raw_token)))
|
||||
if not token or token.revoked_at:
|
||||
return None
|
||||
if token.expires_at:
|
||||
now = utc_now()
|
||||
expires = token.expires_at if token.expires_at.tzinfo else token.expires_at.replace(tzinfo=now.tzinfo)
|
||||
if expires < now:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def manifest() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"server_name": settings.server_name,
|
||||
"api_base": settings.api_base_url,
|
||||
"protocol_version": "0.1",
|
||||
"capabilities": {
|
||||
"events": True,
|
||||
"tasks": True,
|
||||
"files": True,
|
||||
"chat": True,
|
||||
"polls": True,
|
||||
"federation": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sync_payload_for_token(db: Session, token: ConnectionToken | None) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
fake_ctx = type("SyncContext", (), {"home_profile": None, "member": None, "session": None})()
|
||||
payload = home_dashboard(db, fake_ctx) if False else None
|
||||
actions: list[dict[str, Any]] = []
|
||||
events: list[dict[str, Any]] = []
|
||||
announcements: list[dict[str, Any]] = []
|
||||
files: list[dict[str, Any]] = []
|
||||
threads: list[dict[str, Any]] = []
|
||||
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.models import Announcement, Event, FileAsset, Group, Poll, Task, Thread
|
||||
from app.services.dashboard import _local_actions_for_member
|
||||
from app.services.serializers import announcement_dict, event_dict, file_dict, thread_dict
|
||||
|
||||
group_filter = [token.group_id] if token and token.group_id else [group.id for group in db.scalars(select(Group)).all()]
|
||||
groups = [db.get(Group, group_id) for group_id in group_filter]
|
||||
for group in [item for item in groups if item]:
|
||||
for member in group.members:
|
||||
if member.status in {"joined", "verified"}:
|
||||
actions.extend(_local_actions_for_member(db, member))
|
||||
break
|
||||
events.extend([event_dict(item, group) for item in db.scalars(select(Event).where(Event.group_id == group.id)).all()])
|
||||
announcements.extend(
|
||||
[announcement_dict(item, group) for item in db.scalars(select(Announcement).where(Announcement.group_id == group.id, Announcement.official.is_(True))).all()]
|
||||
)
|
||||
files.extend([file_dict(item, group) for item in db.scalars(select(FileAsset).where(FileAsset.group_id == group.id)).all()])
|
||||
threads.extend(
|
||||
[thread_dict(item, group=group) for item in db.scalars(select(Thread).where(Thread.group_id == group.id).order_by(desc(Thread.updated_at))).all()]
|
||||
)
|
||||
|
||||
for collection in (actions, events, announcements, files, threads):
|
||||
for item in collection:
|
||||
item["source_type"] = "remote"
|
||||
item["source_server_origin"] = settings.server_origin
|
||||
|
||||
return {
|
||||
"cursor": utc_now().isoformat(),
|
||||
"server_time": utc_now().isoformat(),
|
||||
"actions": actions,
|
||||
"events": events,
|
||||
"announcements": announcements,
|
||||
"files": files,
|
||||
"threads": threads,
|
||||
}
|
||||
|
||||
|
||||
def fetch_manifest(server_url: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
|
||||
response = client.get(f"{server_url.rstrip('/')}/.well-known/group-platform.json")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def sync_connection(db: Session, connection: RemoteServerConnection) -> RemoteServerConnection:
|
||||
settings = get_settings()
|
||||
cursor = db.scalar(select(RemoteSyncCursor).where(RemoteSyncCursor.remote_connection_id == connection.id))
|
||||
since = cursor.cursor if cursor else None
|
||||
params = {"since": since} if since else {}
|
||||
raw_token = unmask_store_token(connection.access_token_encrypted)
|
||||
try:
|
||||
with httpx.Client(timeout=settings.remote_request_timeout_seconds, follow_redirects=True) as client:
|
||||
response = client.get(f"{connection.api_base.rstrip('/')}/sync", params=params, headers={"Authorization": f"Bearer {raw_token}"})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
connection.status = "error"
|
||||
connection.last_error = str(exc)
|
||||
connection.updated_at = utc_now()
|
||||
db.flush()
|
||||
return connection
|
||||
|
||||
for object_type, collection_name in [
|
||||
("action", "actions"),
|
||||
("event", "events"),
|
||||
("announcement", "announcements"),
|
||||
("file", "files"),
|
||||
("thread", "threads"),
|
||||
]:
|
||||
for item in payload.get(collection_name, []):
|
||||
remote_id = str(item.get("id") or item.get("object_id") or f"{object_type}:{len(item)}")
|
||||
group_remote_id = str(item.get("source_group_id") or item.get("group_id") or "remote")
|
||||
group_name = str(item.get("source_group_name") or item.get("group_name") or connection.server_name)
|
||||
existing = db.scalar(
|
||||
select(RemoteCachedObject).where(
|
||||
RemoteCachedObject.remote_connection_id == connection.id,
|
||||
RemoteCachedObject.object_type == object_type,
|
||||
RemoteCachedObject.remote_id == remote_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.group_remote_id = group_remote_id
|
||||
existing.group_name = group_name
|
||||
existing.payload_json = item
|
||||
existing.cached_at = utc_now()
|
||||
else:
|
||||
db.add(
|
||||
RemoteCachedObject(
|
||||
remote_connection_id=connection.id,
|
||||
object_type=object_type,
|
||||
remote_id=remote_id,
|
||||
group_remote_id=group_remote_id,
|
||||
group_name=group_name,
|
||||
payload_json=item,
|
||||
)
|
||||
)
|
||||
next_cursor = payload.get("cursor")
|
||||
if cursor:
|
||||
cursor.cursor = next_cursor
|
||||
cursor.updated_at = utc_now()
|
||||
else:
|
||||
db.add(RemoteSyncCursor(remote_connection_id=connection.id, cursor=next_cursor))
|
||||
connection.status = "active"
|
||||
connection.last_error = None
|
||||
connection.last_sync_at = utc_now()
|
||||
connection.updated_at = utc_now()
|
||||
db.flush()
|
||||
return connection
|
||||
|
||||
260
backend/app/services/serializers.py
Normal file
260
backend/app/services/serializers.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models import (
|
||||
Announcement,
|
||||
Event,
|
||||
FileAsset,
|
||||
Group,
|
||||
HomeDevice,
|
||||
HomeProfile,
|
||||
Member,
|
||||
MemberDevice,
|
||||
Message,
|
||||
Poll,
|
||||
PollOption,
|
||||
PollVote,
|
||||
RemoteServerConnection,
|
||||
Task,
|
||||
Thread,
|
||||
)
|
||||
|
||||
|
||||
def iso(value: datetime | date | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def group_dict(group: Group) -> dict[str, Any]:
|
||||
return {
|
||||
"id": group.id,
|
||||
"server_origin": group.server_origin,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"visibility": group.visibility,
|
||||
"legacy_channel_status": group.legacy_channel_status,
|
||||
"transition_deadline": iso(group.transition_deadline),
|
||||
"created_at": iso(group.created_at),
|
||||
"updated_at": iso(group.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def member_dict(member: Member) -> dict[str, Any]:
|
||||
return {
|
||||
"id": member.id,
|
||||
"group_id": member.group_id,
|
||||
"home_profile_id": member.home_profile_id,
|
||||
"display_name": member.display_name,
|
||||
"role": member.role,
|
||||
"status": member.status,
|
||||
"joined_at": iso(member.joined_at),
|
||||
"last_seen_at": iso(member.last_seen_at),
|
||||
"notification_enabled_at": iso(member.notification_enabled_at),
|
||||
}
|
||||
|
||||
|
||||
def profile_dict(profile: HomeProfile | None, member: Member | None = None) -> dict[str, Any] | None:
|
||||
if profile:
|
||||
return {
|
||||
"id": profile.id,
|
||||
"primary_display_name": profile.primary_display_name,
|
||||
"status": profile.status,
|
||||
"last_seen_at": iso(profile.last_seen_at),
|
||||
}
|
||||
if member:
|
||||
return {
|
||||
"id": None,
|
||||
"primary_display_name": member.display_name,
|
||||
"status": "membership_only",
|
||||
"last_seen_at": iso(member.last_seen_at),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def announcement_dict(announcement: Announcement, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": announcement.id,
|
||||
"group_id": announcement.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"author_member_id": announcement.author_member_id,
|
||||
"title": announcement.title,
|
||||
"body": announcement.body,
|
||||
"priority": announcement.priority,
|
||||
"official": announcement.official,
|
||||
"requires_ack": announcement.requires_ack,
|
||||
"created_at": iso(announcement.created_at),
|
||||
"updated_at": iso(announcement.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def event_dict(event: Event, group: Group | None = None, rsvp_status: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event.id,
|
||||
"group_id": event.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"created_by_member_id": event.created_by_member_id,
|
||||
"title": event.title,
|
||||
"description": event.description,
|
||||
"starts_at": iso(event.starts_at),
|
||||
"ends_at": iso(event.ends_at),
|
||||
"location_name": event.location_name,
|
||||
"location_address": event.location_address,
|
||||
"rsvp_required": event.rsvp_required,
|
||||
"rsvp_status": rsvp_status,
|
||||
"changed_at": iso(event.changed_at),
|
||||
"created_at": iso(event.created_at),
|
||||
"updated_at": iso(event.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def task_dict(task: Task, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": task.id,
|
||||
"group_id": task.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"created_by_member_id": task.created_by_member_id,
|
||||
"assigned_to_member_id": task.assigned_to_member_id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"due_at": iso(task.due_at),
|
||||
"status": task.status,
|
||||
"created_at": iso(task.created_at),
|
||||
"updated_at": iso(task.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def poll_dict(poll: Poll, options: list[PollOption], votes: list[PollVote], group: Group | None = None) -> dict[str, Any]:
|
||||
counts: dict[str, int] = {option.id: 0 for option in options}
|
||||
for vote in votes:
|
||||
counts[vote.option_id] = counts.get(vote.option_id, 0) + 1
|
||||
return {
|
||||
"id": poll.id,
|
||||
"group_id": poll.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"title": poll.title,
|
||||
"description": poll.description,
|
||||
"closes_at": iso(poll.closes_at),
|
||||
"status": poll.status,
|
||||
"created_by_member_id": poll.created_by_member_id,
|
||||
"created_at": iso(poll.created_at),
|
||||
"options": [{"id": option.id, "label": option.label, "position": option.position, "vote_count": counts.get(option.id, 0)} for option in options],
|
||||
}
|
||||
|
||||
|
||||
def file_dict(file: FileAsset, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": file.id,
|
||||
"group_id": file.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"uploaded_by_member_id": file.uploaded_by_member_id,
|
||||
"filename_original": file.filename_original,
|
||||
"content_type": file.content_type,
|
||||
"size_bytes": file.size_bytes,
|
||||
"visibility": file.visibility,
|
||||
"description": file.description,
|
||||
"requires_ack": file.requires_ack,
|
||||
"created_at": iso(file.created_at),
|
||||
"download_url": f"/api/files/{file.id}/download",
|
||||
}
|
||||
|
||||
|
||||
def thread_dict(thread: Thread, messages: list[Message] | None = None, group: Group | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": thread.id,
|
||||
"group_id": thread.group_id,
|
||||
"group_name": group.name if group else None,
|
||||
"source_type": "local",
|
||||
"source_server_origin": group.server_origin if group else None,
|
||||
"title": thread.title,
|
||||
"kind": thread.kind,
|
||||
"created_by_member_id": thread.created_by_member_id,
|
||||
"created_at": iso(thread.created_at),
|
||||
"updated_at": iso(thread.updated_at),
|
||||
"messages": [message_dict(message) for message in messages or []],
|
||||
}
|
||||
|
||||
|
||||
def message_dict(message: Message) -> dict[str, Any]:
|
||||
return {
|
||||
"id": message.id,
|
||||
"thread_id": message.thread_id,
|
||||
"author_member_id": message.author_member_id,
|
||||
"body": message.body,
|
||||
"created_at": iso(message.created_at),
|
||||
"edited_at": iso(message.edited_at),
|
||||
"deleted_at": iso(message.deleted_at),
|
||||
}
|
||||
|
||||
|
||||
def action_dict(
|
||||
*,
|
||||
id: str,
|
||||
source_type: str,
|
||||
source_server_origin: str,
|
||||
source_group_id: str,
|
||||
source_group_name: str,
|
||||
type: str,
|
||||
priority: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
object_type: str,
|
||||
object_id: str,
|
||||
due_at: datetime | None = None,
|
||||
status: str = "open",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": id,
|
||||
"source_type": source_type,
|
||||
"source_server_origin": source_server_origin,
|
||||
"source_group_id": source_group_id,
|
||||
"source_group_name": source_group_name,
|
||||
"type": type,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
"due_at": iso(due_at),
|
||||
}
|
||||
|
||||
|
||||
def device_dict(device: HomeDevice | MemberDevice, current_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": device.id,
|
||||
"label": device.label,
|
||||
"created_at": iso(device.created_at),
|
||||
"last_seen_at": iso(device.last_seen_at),
|
||||
"revoked_at": iso(device.revoked_at),
|
||||
"trust_level": device.trust_level,
|
||||
"current": device.id == current_id,
|
||||
}
|
||||
|
||||
|
||||
def remote_connection_dict(connection: RemoteServerConnection) -> dict[str, Any]:
|
||||
return {
|
||||
"id": connection.id,
|
||||
"server_origin": connection.server_origin,
|
||||
"server_name": connection.server_name,
|
||||
"api_base": connection.api_base,
|
||||
"protocol_version": connection.protocol_version,
|
||||
"capabilities": connection.capabilities_json,
|
||||
"status": connection.status,
|
||||
"last_sync_at": iso(connection.last_sync_at),
|
||||
"last_error": connection.last_error,
|
||||
"created_at": iso(connection.created_at),
|
||||
}
|
||||
|
||||
172
backend/app/tests/test_acceptance.py
Normal file
172
backend/app/tests/test_acceptance.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "test.db"
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}")
|
||||
monkeypatch.setenv("SESSION_SECRET", "test-secret")
|
||||
monkeypatch.setenv("DEV_MODE", "true")
|
||||
monkeypatch.setenv("UPLOAD_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setenv("FRONTEND_ORIGIN", "http://testserver")
|
||||
monkeypatch.setenv("SERVER_ORIGIN", "http://testserver")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://testserver/api")
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
import app.db.base as base
|
||||
from app.core.config import get_settings as refreshed_settings
|
||||
from app.db.seed import seed
|
||||
from app.main import app
|
||||
|
||||
settings = refreshed_settings()
|
||||
base.settings = settings
|
||||
base.engine.dispose()
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
base.engine = create_engine(settings.database_url, connect_args={"check_same_thread": False}, future=True)
|
||||
base.SessionLocal = sessionmaker(bind=base.engine, autoflush=False, autocommit=False, expire_on_commit=False, future=True)
|
||||
seed()
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_invite_is_hashed_and_claim_creates_session(client):
|
||||
from app.core.security import hash_token
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.seed import DEMO_INVITE_TOKEN
|
||||
from app.models import MemberInvite
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
invite = db.scalar(select(MemberInvite).where(MemberInvite.token_hash == hash_token(DEMO_INVITE_TOKEN)))
|
||||
assert invite is not None
|
||||
assert invite.token_hash != DEMO_INVITE_TOKEN
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
preview = client.get(f"/api/join/{DEMO_INVITE_TOKEN}/preview")
|
||||
assert preview.status_code == 200
|
||||
assert preview.json()["group"]["name"] == "FC Kreuzberg U12 Parents"
|
||||
|
||||
claimed = client.post(f"/api/auth/invite/{DEMO_INVITE_TOKEN}/claim", json={"display_name": "New Parent", "device_label": "Pytest browser"})
|
||||
assert claimed.status_code == 200
|
||||
assert "grouphome_session" in claimed.cookies
|
||||
assert claimed.json()["member"]["status"] == "joined"
|
||||
|
||||
|
||||
def test_limited_invite_blocks_after_max_use(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
groups = client.get("/api/groups").json()["groups"]
|
||||
group_id = groups[0]["group"]["id"]
|
||||
created = client.post(f"/api/groups/{group_id}/invites", json={"label": "Once", "max_uses": 1})
|
||||
token = created.json()["token_display_once"]
|
||||
|
||||
first = TestClient(client.app)
|
||||
assert first.post(f"/api/auth/invite/{token}/claim", json={"display_name": "First", "device_label": "Browser"}).status_code == 200
|
||||
second = TestClient(client.app)
|
||||
assert second.post(f"/api/auth/invite/{token}/claim", json={"display_name": "Second", "device_label": "Browser"}).status_code == 404
|
||||
|
||||
|
||||
def test_home_dashboard_contains_required_sections_and_actions(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
response = client.get("/api/home")
|
||||
assert response.status_code == 200
|
||||
sections = response.json()["sections"]
|
||||
assert set(sections) == {"needs_me", "today", "changed", "official_updates", "catch_up"}
|
||||
types = {item["type"] for item in sections["needs_me"]}
|
||||
assert {"rsvp_required", "task_assigned", "vote_required"}.issubset(types)
|
||||
|
||||
|
||||
def test_permissions_and_rsvp_flow(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
groups = client.get("/api/groups").json()["groups"]
|
||||
school = next(item for item in groups if item["group"]["name"] == "Class 4B Parents")
|
||||
group_id = school["group"]["id"]
|
||||
denied = client.post(f"/api/groups/{group_id}/announcements", json={"title": "Official", "body": "No", "official": True})
|
||||
assert denied.status_code == 403
|
||||
|
||||
fc = next(item for item in groups if item["group"]["name"] == "FC Kreuzberg U12 Parents")
|
||||
event = next(item for item in fc["dashboard"]["upcoming"] if item["rsvp_required"])
|
||||
rsvp = client.post(f"/api/events/{event['id']}/rsvp", json={"status": "yes"})
|
||||
assert rsvp.status_code == 200
|
||||
home = client.get("/api/home").json()
|
||||
assert event["id"] not in [item["object_id"] for item in home["sections"]["needs_me"] if item["type"] == "rsvp_required"]
|
||||
|
||||
|
||||
def test_device_link_flow(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
start = client.post("/api/auth/device-link/start", json={"device_label": "Laptop"})
|
||||
code = start.json()["code"]
|
||||
assert client.post("/api/auth/device-link/approve", json={"code": code}).status_code == 200
|
||||
other = TestClient(client.app)
|
||||
complete = other.post("/api/auth/device-link/complete", json={"code": code, "device_label": "Laptop"})
|
||||
assert complete.status_code == 200
|
||||
devices = client.get("/api/me/devices").json()["devices"]
|
||||
assert len(devices) >= 2
|
||||
|
||||
|
||||
def test_recovery_flow(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
requested = client.post("/api/auth/recovery/request", json={"email": "anna@example.test"})
|
||||
code = requested.json()["dev_code"]
|
||||
other = TestClient(client.app)
|
||||
consumed = other.post("/api/auth/recovery/consume", json={"recovery_code": code, "device_label": "Recovered"})
|
||||
assert consumed.status_code == 200
|
||||
|
||||
|
||||
def test_remote_manifest_token_and_sync(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
manifest = client.get("/.well-known/group-platform.json")
|
||||
assert manifest.status_code == 200
|
||||
assert manifest.json()["capabilities"]["federation"] is False
|
||||
|
||||
token_response = client.post("/api/connection-tokens", json={"label": "Test sync", "scopes": ["sync:read"]})
|
||||
raw = token_response.json()["connection_code_display_once"]
|
||||
denied = client.get("/api/sync")
|
||||
assert denied.status_code == 401
|
||||
synced = client.get("/api/sync", headers={"Authorization": f"Bearer {raw}"})
|
||||
assert synced.status_code == 200
|
||||
assert synced.json()["events"]
|
||||
|
||||
|
||||
def test_chat_home_and_message_metadata(client):
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
chat = client.get("/api/chat")
|
||||
assert chat.status_code == 200
|
||||
payload = chat.json()
|
||||
assert payload["groups"]
|
||||
assert payload["threads"]
|
||||
thread_id = payload["active_thread"]["id"]
|
||||
|
||||
sent = client.post(f"/api/chat/threads/{thread_id}/messages", json={"body": "ok"})
|
||||
assert sent.status_code == 200
|
||||
message = sent.json()["message"]
|
||||
assert message["author_name"] == "Anna Müller"
|
||||
assert message["mine"] is True
|
||||
assert message["low_signal"] is True
|
||||
|
||||
|
||||
def test_file_upload_requires_auth_and_sanitizes_filename(client, tmp_path):
|
||||
no_auth = TestClient(client.app)
|
||||
assert no_auth.post("/api/groups/not-real/files", files={"upload": ("bad.txt", b"x", "text/plain")}).status_code == 401
|
||||
|
||||
client.post("/api/auth/dev/demo-session", json={})
|
||||
group_id = client.get("/api/groups").json()["groups"][0]["group"]["id"]
|
||||
uploaded = client.post(
|
||||
f"/api/groups/{group_id}/files",
|
||||
data={"description": "test"},
|
||||
files={"upload": ("../../unsafe name.txt", b"hello", "text/plain")},
|
||||
)
|
||||
assert uploaded.status_code == 200
|
||||
assert uploaded.json()["file"]["filename_original"] == "unsafe name.txt"
|
||||
33
backend/pyproject.toml
Normal file
33
backend/pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "grouphome-backend"
|
||||
version = "0.1.0"
|
||||
description = "FastAPI backend for the GroupHome browser-first group coordination platform."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.2.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["app/tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 140
|
||||
1782
codex_group_platform_instruction.md
Normal file
1782
codex_group_platform_instruction.md
Normal file
File diff suppressed because it is too large
Load Diff
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
command: sh -c "python -m app.db.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
environment:
|
||||
DEV_MODE: "true"
|
||||
SERVER_NAME: "GroupHome Local"
|
||||
SERVER_ORIGIN: "http://localhost:8000"
|
||||
API_BASE_URL: "http://localhost:8000/api"
|
||||
FRONTEND_ORIGIN: "http://localhost:5173"
|
||||
DATABASE_URL: "sqlite:////data/grouphome.db"
|
||||
SESSION_SECRET: "dev-compose-change-me"
|
||||
CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173"
|
||||
UPLOAD_DIR: "/data/uploads"
|
||||
volumes:
|
||||
- backend-data:/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
command: npm run dev
|
||||
environment:
|
||||
VITE_API_BASE_URL: ""
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
|
||||
8
frontend/Dockerfile
Normal file
8
frontend/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# GroupHome Frontend
|
||||
|
||||
React, Vite, and TypeScript frontend for the GroupHome demo.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run test
|
||||
```
|
||||
|
||||
The dev server proxies `/api` and `/.well-known` to `http://localhost:8000`.
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f7f4ee" />
|
||||
<title>GroupHome</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3262
frontend/package-lock.json
generated
Normal file
3262
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "grouphome-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
59
frontend/src/api/client.ts
Normal file
59
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ApiError = {
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
function csrfToken() {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((part) => part.startsWith("grouphome_csrf="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
const isForm = options.body instanceof FormData;
|
||||
if (!isForm && options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const csrf = csrfToken();
|
||||
if (csrf && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
if (!response.ok) {
|
||||
let payload: ApiError = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = { error: { code: "request_failed", message: response.statusText } };
|
||||
}
|
||||
throw new Error(payload.error?.message ?? "Request failed");
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return api<T>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function deleteJson<T>(path: string): Promise<T> {
|
||||
return api<T>(path, { method: "DELETE" });
|
||||
}
|
||||
|
||||
114
frontend/src/api/types.ts
Normal file
114
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
export type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
server_origin: string;
|
||||
legacy_channel_status: string;
|
||||
transition_deadline?: string | null;
|
||||
};
|
||||
|
||||
export type Member = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ActionItem = {
|
||||
id: string;
|
||||
source_type: "local" | "remote";
|
||||
source_server_origin: string;
|
||||
source_group_id: string;
|
||||
source_group_name: string;
|
||||
type: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
due_at?: string | null;
|
||||
};
|
||||
|
||||
export type EventItem = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
source_type?: string;
|
||||
source_server_origin?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
starts_at: string;
|
||||
ends_at?: string | null;
|
||||
location_name?: string | null;
|
||||
location_address?: string | null;
|
||||
rsvp_required: boolean;
|
||||
rsvp_status?: string | null;
|
||||
changed_at?: string | null;
|
||||
};
|
||||
|
||||
export type Announcement = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
official: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
due_at?: string | null;
|
||||
status: string;
|
||||
assigned_to_member_id?: string | null;
|
||||
};
|
||||
|
||||
export type FileAsset = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
source_type?: string;
|
||||
source_server_origin?: string;
|
||||
filename_original: string;
|
||||
description?: string | null;
|
||||
size_bytes: number;
|
||||
download_url?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Poll = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
closes_at?: string | null;
|
||||
options: { id: string; label: string; position: number; vote_count: number }[];
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
id: string;
|
||||
group_id: string;
|
||||
group_name?: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
updated_at?: string;
|
||||
latest_message?: ChatMessage | null;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
author_member_id: string;
|
||||
author_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
mine: boolean;
|
||||
low_signal: boolean;
|
||||
};
|
||||
93
frontend/src/app/App.tsx
Normal file
93
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Layout } from "./Layout";
|
||||
import { CalendarPage } from "../routes/CalendarPage";
|
||||
import { ChatPage } from "../routes/ChatPage";
|
||||
import { DetailPage } from "../routes/DetailPage";
|
||||
import { FilesPage } from "../routes/FilesPage";
|
||||
import { GroupPage } from "../routes/GroupPage";
|
||||
import { GroupsPage } from "../routes/GroupsPage";
|
||||
import { HomePage } from "../routes/HomePage";
|
||||
import { JoinPage } from "../routes/JoinPage";
|
||||
import { MePage } from "../routes/MePage";
|
||||
|
||||
type MeResponse = {
|
||||
authenticated: boolean;
|
||||
dev_mode: boolean;
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [ready, setReady] = useState(location.pathname.startsWith("/join/"));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function bootstrap() {
|
||||
if (location.pathname.startsWith("/join/")) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api<MeResponse>("/api/me");
|
||||
if (!me.authenticated && me.dev_mode) {
|
||||
await postJson("/api/auth/dev/demo-session", {});
|
||||
}
|
||||
if (!cancelled) setReady(true);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Could not open the app.");
|
||||
setReady(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
setReady(false);
|
||||
bootstrap();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
if (!ready) {
|
||||
return <Loading label="Opening GroupHome" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="standalone">
|
||||
<div className="error-panel">
|
||||
<h1>GroupHome</h1>
|
||||
<p>{error}</p>
|
||||
<p>Run the backend seed command, then refresh this page.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/join/:token" element={<JoinPage />} />
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/groups/:groupId" element={<GroupPage />} />
|
||||
<Route path="/groups/:groupId/admin" element={<GroupPage initialTab="admin" />} />
|
||||
<Route path="/groups/:groupId/discussions" element={<GroupPage initialTab="discussions" />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/me/devices" element={<MePage initialPanel="devices" />} />
|
||||
<Route path="/me/notifications" element={<MePage initialPanel="notifications" />} />
|
||||
<Route path="/me/servers/connect" element={<MePage initialPanel="servers" />} />
|
||||
<Route path="/events/:id" element={<DetailPage kind="events" />} />
|
||||
<Route path="/announcements/:id" element={<DetailPage kind="announcements" />} />
|
||||
<Route path="/tasks/:id" element={<DetailPage kind="tasks" />} />
|
||||
<Route path="/polls/:id" element={<DetailPage kind="polls" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
46
frontend/src/app/Layout.tsx
Normal file
46
frontend/src/app/Layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CalendarDays, Folder, Home, MessageCircle, User, UsersRound } from "lucide-react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Home", icon: Home },
|
||||
{ to: "/calendar", label: "Calendar", icon: CalendarDays },
|
||||
{ to: "/chat", label: "Chat", icon: MessageCircle, primary: true },
|
||||
{ to: "/groups", label: "Groups", icon: UsersRound },
|
||||
{ to: "/files", label: "Files", icon: Folder },
|
||||
{ to: "/me", label: "Me", icon: User }
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="side-nav" aria-label="Main navigation">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark">GH</span>
|
||||
<div>
|
||||
<strong>GroupHome</strong>
|
||||
<small>Command center</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
{nav.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "nav-item primary" : "nav-item"}${isActive ? " active" : ""}`}>
|
||||
<item.icon size={19} aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main-panel">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Main navigation">
|
||||
{nav.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.to === "/"} className={({ isActive }) => `${item.primary ? "bottom-item primary" : "bottom-item"}${isActive ? " active" : ""}`}>
|
||||
<item.icon size={20} aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/app/format.ts
Normal file
25
frontend/src/app/format.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function fileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
10
frontend/src/components/Badge.tsx
Normal file
10
frontend/src/components/Badge.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type BadgeProps = {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "urgent" | "good" | "remote" | "quiet";
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = "neutral" }: BadgeProps) {
|
||||
return <span className={`badge badge-${tone}`}>{children}</span>;
|
||||
}
|
||||
10
frontend/src/components/Button.tsx
Normal file
10
frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
};
|
||||
|
||||
export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
|
||||
return <button className={`button button-${variant} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
10
frontend/src/components/Card.tsx
Normal file
10
frontend/src/components/Card.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type CardProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Card({ children, className = "" }: CardProps) {
|
||||
return <article className={`card ${className}`}>{children}</article>;
|
||||
}
|
||||
12
frontend/src/components/EmptyState.tsx
Normal file
12
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
export function EmptyState({ title, body }: { title: string; body?: string }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<Inbox size={22} aria-hidden />
|
||||
<strong>{title}</strong>
|
||||
{body ? <p>{body}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
frontend/src/components/FormRow.tsx
Normal file
23
frontend/src/components/FormRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FormRow({ label, children }: Props) {
|
||||
return (
|
||||
<label className="form-row">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className="input" {...props} />;
|
||||
}
|
||||
|
||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea className="input textarea" {...props} />;
|
||||
}
|
||||
9
frontend/src/components/Loading.tsx
Normal file
9
frontend/src/components/Loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function Loading({ label = "Loading" }: { label?: string }) {
|
||||
return (
|
||||
<div className="loading" role="status" aria-live="polite">
|
||||
<span className="skeleton skeleton-dot" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
frontend/src/components/Section.tsx
Normal file
23
frontend/src/components/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SectionProps = {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Section({ title, eyebrow, action, children }: SectionProps) {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/base.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
54
frontend/src/routes/CalendarPage.tsx
Normal file
54
frontend/src/routes/CalendarPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { EventItem } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function CalendarPage() {
|
||||
const [events, setEvents] = useState<EventItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<{ events: EventItem[] }>("/api/home/calendar").then((data) => setEvents(data.events));
|
||||
}, []);
|
||||
|
||||
if (!events) return <Loading label="Loading calendar" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Calendar</span>
|
||||
<h1>Upcoming across groups</h1>
|
||||
<p>Events from local memberships and connected group servers.</p>
|
||||
</header>
|
||||
<Section title="Agenda">
|
||||
<div className="timeline">
|
||||
{events.length === 0 ? <EmptyState title="No events scheduled" /> : null}
|
||||
{events.map((event) => (
|
||||
<Card key={`${event.source_type}-${event.id}`} className="timeline-card">
|
||||
<div className="date-block">{formatDateTime(event.starts_at)}</div>
|
||||
<div>
|
||||
<div className="card-topline">
|
||||
<Badge tone={event.source_type === "remote" ? "remote" : "neutral"}>{event.group_name}</Badge>
|
||||
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
|
||||
</div>
|
||||
<h3>{event.title}</h3>
|
||||
{event.location_name ? (
|
||||
<p className="inline-meta">
|
||||
<MapPin size={15} /> {event.location_name}
|
||||
</p>
|
||||
) : null}
|
||||
{event.rsvp_required ? <p>RSVP: {event.rsvp_status ?? "unknown"}</p> : null}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
298
frontend/src/routes/ChatPage.tsx
Normal file
298
frontend/src/routes/ChatPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { CalendarPlus, CheckSquare, ChevronDown, Link2, Megaphone, MessageCircle, Plus, Send, Vote } from "lucide-react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { ChatMessage, Group, Member, Thread } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
|
||||
type ChatResponse = {
|
||||
groups: { group: Group; member: Member }[];
|
||||
active_group: Group | null;
|
||||
active_member: Member | null;
|
||||
members: Member[];
|
||||
threads: Thread[];
|
||||
active_thread: Thread | null;
|
||||
current_member_id: string | null;
|
||||
};
|
||||
|
||||
type StructureKind = "message" | "poll" | "event" | "task" | "announcement" | "invite" | "feedback";
|
||||
|
||||
const structureOptions: { kind: StructureKind; label: string; icon: typeof MessageCircle }[] = [
|
||||
{ kind: "message", label: "Message", icon: MessageCircle },
|
||||
{ kind: "poll", label: "Poll", icon: Vote },
|
||||
{ kind: "event", label: "Event", icon: CalendarPlus },
|
||||
{ kind: "task", label: "Task", icon: CheckSquare },
|
||||
{ kind: "announcement", label: "Announcement", icon: Megaphone },
|
||||
{ kind: "invite", label: "Invite", icon: Link2 },
|
||||
{ kind: "feedback", label: "Feedback", icon: MessageCircle }
|
||||
];
|
||||
|
||||
function titleFromDraft(draft: string) {
|
||||
const firstLine = draft.trim().split("\n").find(Boolean) || "New item";
|
||||
return firstLine.replace(/^[-*]\s*/, "").slice(0, 140);
|
||||
}
|
||||
|
||||
function pollOptionsFromDraft(draft: string) {
|
||||
const lines = draft
|
||||
.split("\n")
|
||||
.map((line) => line.trim().replace(/^[-*]\s*/, ""))
|
||||
.filter(Boolean);
|
||||
const optionLines = lines.slice(1).filter((line) => line.length <= 80);
|
||||
if (optionLines.length >= 2) return optionLines.slice(0, 8).map((label) => ({ label }));
|
||||
if (draft.includes(",")) {
|
||||
const parts = draft
|
||||
.split(":")
|
||||
.pop()
|
||||
?.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts && parts.length >= 2) return parts.slice(0, 8).map((label) => ({ label }));
|
||||
}
|
||||
return [{ label: "Yes" }, { label: "No" }];
|
||||
}
|
||||
|
||||
function guessedKind(draft: string): StructureKind {
|
||||
const text = draft.toLowerCase();
|
||||
if (!text.trim()) return "message";
|
||||
if (/(invite|join link|new people|qr)/.test(text)) return "invite";
|
||||
if (/(poll|vote|choose|which option|yes\/no)/.test(text)) return "poll";
|
||||
if (/(training|match|meeting|appointment|event|tomorrow|tonight|rescheduled|moved|changed)/.test(text)) return "event";
|
||||
if (/(todo|task|can someone|please bring|need someone|driver|bring)/.test(text)) return "task";
|
||||
if (/(announce|official|important|urgent|from now)/.test(text)) return "announcement";
|
||||
if (/(feedback|idea|concern|issue)/.test(text)) return "feedback";
|
||||
return "message";
|
||||
}
|
||||
|
||||
function defaultStartsAt(draft: string) {
|
||||
const lower = draft.toLowerCase();
|
||||
const date = new Date();
|
||||
if (lower.includes("tomorrow")) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
} else {
|
||||
date.setDate(date.getDate() + 2);
|
||||
}
|
||||
date.setHours(lower.includes("morning") ? 9 : lower.includes("afternoon") ? 15 : 18, 0, 0, 0);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function displayMessages(messages: ChatMessage[], foldNoise: boolean) {
|
||||
if (!foldNoise) return messages.map((message) => ({ type: "message" as const, message }));
|
||||
const items: ({ type: "message"; message: ChatMessage } | { type: "fold"; count: number; names: string[] })[] = [];
|
||||
let folded: ChatMessage[] = [];
|
||||
const flush = () => {
|
||||
if (folded.length) {
|
||||
items.push({ type: "fold", count: folded.length, names: [...new Set(folded.map((message) => message.author_name))].slice(0, 3) });
|
||||
folded = [];
|
||||
}
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.low_signal && !message.mine) {
|
||||
folded.push(message);
|
||||
} else {
|
||||
flush();
|
||||
items.push({ type: "message", message });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [data, setData] = useState<ChatResponse | null>(null);
|
||||
const [groupId, setGroupId] = useState<string>("");
|
||||
const [threadId, setThreadId] = useState<string>("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [selectedKind, setSelectedKind] = useState<StructureKind>("message");
|
||||
const [newThreadTitle, setNewThreadTitle] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [foldNoise, setFoldNoise] = useState(() => localStorage.getItem("grouphome.foldNoise") === "true");
|
||||
|
||||
const suggestedKind = useMemo(() => guessedKind(draft), [draft]);
|
||||
const activeGroupId = data?.active_group?.id ?? groupId;
|
||||
const activeThreadId = data?.active_thread?.id ?? threadId;
|
||||
|
||||
async function load(nextGroupId = groupId, nextThreadId = threadId) {
|
||||
const params = new URLSearchParams();
|
||||
if (nextGroupId) params.set("group_id", nextGroupId);
|
||||
if (nextThreadId) params.set("thread_id", nextThreadId);
|
||||
const result = await api<ChatResponse>(`/api/chat${params.toString() ? `?${params.toString()}` : ""}`);
|
||||
setData(result);
|
||||
setGroupId(result.active_group?.id ?? "");
|
||||
setThreadId(result.active_thread?.id ?? "");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("grouphome.foldNoise", String(foldNoise));
|
||||
}, [foldNoise]);
|
||||
|
||||
async function chooseGroup(nextGroupId: string) {
|
||||
setGroupId(nextGroupId);
|
||||
setThreadId("");
|
||||
await load(nextGroupId, "");
|
||||
}
|
||||
|
||||
async function chooseThread(nextThreadId: string) {
|
||||
setThreadId(nextThreadId);
|
||||
await load(groupId, nextThreadId);
|
||||
}
|
||||
|
||||
async function createThread(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!activeGroupId || !newThreadTitle.trim()) return;
|
||||
const result = await postJson<{ thread: Thread }>(`/api/chat/threads?group_id=${activeGroupId}`, { title: newThreadTitle, kind: "discussion" });
|
||||
setNewThreadTitle("");
|
||||
await load(activeGroupId, result.thread.id);
|
||||
}
|
||||
|
||||
async function sendMessage(body: string) {
|
||||
await postJson(`/api/chat/threads/${activeThreadId}/messages`, { body });
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!draft.trim() || !activeGroupId || !activeThreadId) return;
|
||||
const kind = selectedKind;
|
||||
const title = titleFromDraft(draft);
|
||||
try {
|
||||
if (kind === "message") {
|
||||
await sendMessage(draft);
|
||||
} else if (kind === "poll") {
|
||||
await postJson(`/api/groups/${activeGroupId}/polls`, { title, description: draft, options: pollOptionsFromDraft(draft) });
|
||||
await sendMessage(`Created poll: ${title}`);
|
||||
} else if (kind === "event") {
|
||||
await postJson(`/api/groups/${activeGroupId}/events`, { title, description: draft, starts_at: defaultStartsAt(draft), rsvp_required: true, location_name: "TBD" });
|
||||
await sendMessage(`Created event: ${title}`);
|
||||
} else if (kind === "task") {
|
||||
await postJson(`/api/groups/${activeGroupId}/tasks`, { title, description: draft });
|
||||
await sendMessage(`Created task: ${title}`);
|
||||
} else if (kind === "announcement") {
|
||||
const canPostOfficial = ["owner", "admin", "moderator"].includes(data?.active_member?.role ?? "");
|
||||
await postJson(`/api/groups/${activeGroupId}/announcements`, { title, body: draft, official: canPostOfficial, priority: /urgent|important/i.test(draft) ? "urgent" : "normal" });
|
||||
await sendMessage(`${canPostOfficial ? "Posted official announcement" : "Posted announcement"}: ${title}`);
|
||||
} else if (kind === "invite") {
|
||||
const invite = await postJson<{ invite_url: string }>(`/api/groups/${activeGroupId}/invites`, { label: title, max_uses: 50 });
|
||||
await sendMessage(`Invite link: ${invite.invite_url}`);
|
||||
} else if (kind === "feedback") {
|
||||
await sendMessage(`Feedback: ${draft}`);
|
||||
}
|
||||
setDraft("");
|
||||
setSelectedKind("message");
|
||||
setNotice("");
|
||||
await load(activeGroupId, activeThreadId);
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "Could not create that item.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <Loading label="Opening chat" />;
|
||||
if (!data.active_group || !data.active_thread) {
|
||||
return <div className="error-panel">Join a group to start chatting.</div>;
|
||||
}
|
||||
|
||||
const messages = data.active_thread.messages ?? [];
|
||||
const renderedMessages = displayMessages(messages, foldNoise);
|
||||
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<aside className="chat-sidebar">
|
||||
<div className="chat-sidebar-header">
|
||||
<div>
|
||||
<span className="eyebrow">Chat</span>
|
||||
<h1>Messages</h1>
|
||||
</div>
|
||||
<Badge tone="good">{data.groups.length} groups</Badge>
|
||||
</div>
|
||||
<FormRow label="Group">
|
||||
<select className="input" value={data.active_group.id} onChange={(event) => chooseGroup(event.target.value)}>
|
||||
{data.groups.map(({ group }) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<form className="inline-form" onSubmit={createThread}>
|
||||
<TextInput value={newThreadTitle} onChange={(event) => setNewThreadTitle(event.target.value)} placeholder="New thread" />
|
||||
<Button type="submit" variant="secondary" aria-label="Create thread">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="chat-thread-list">
|
||||
{data.threads.map((thread) => (
|
||||
<button key={thread.id} type="button" className={thread.id === data.active_thread?.id ? "chat-thread active" : "chat-thread"} onClick={() => chooseThread(thread.id)}>
|
||||
<strong>{thread.title}</strong>
|
||||
<span>{thread.latest_message?.body ?? "No messages yet"}</span>
|
||||
<small>{thread.latest_message?.created_at ? formatDateTime(thread.latest_message.created_at) : thread.kind}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="chat-panel">
|
||||
<header className="chat-header">
|
||||
<div>
|
||||
<span className="eyebrow">{data.active_group.name}</span>
|
||||
<h2>{data.active_thread.title}</h2>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={foldNoise} onChange={(event) => setFoldNoise(event.target.checked)} />
|
||||
<span>Fold short replies</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div className="message-list">
|
||||
{renderedMessages.map((item, index) =>
|
||||
item.type === "fold" ? (
|
||||
<div className="folded-noise" key={`fold-${index}`}>
|
||||
<ChevronDown size={15} />
|
||||
{item.count} short replies folded from {item.names.join(", ")}
|
||||
</div>
|
||||
) : (
|
||||
<div key={item.message.id} className={item.message.mine ? "message-row mine" : "message-row"}>
|
||||
<div className={item.message.mine ? "bubble mine" : "bubble"}>
|
||||
<div className="bubble-meta">
|
||||
<strong>{item.message.author_name}</strong>
|
||||
<span>{formatDateTime(item.message.created_at)}</span>
|
||||
</div>
|
||||
<p>{item.message.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="chat-composer" onSubmit={submit}>
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
<div className="structure-strip">
|
||||
{structureOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = selectedKind === option.kind;
|
||||
return (
|
||||
<button key={option.kind} type="button" className={active ? "structure-chip active" : "structure-chip"} onClick={() => setSelectedKind(option.kind)}>
|
||||
<Icon size={15} />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{suggestedKind !== "message" && selectedKind === "message" ? (
|
||||
<div className="composer-hint">Looks like a {suggestedKind}. Send normally or create the structured item.</div>
|
||||
) : null}
|
||||
<div className="composer-row">
|
||||
<textarea className="input chat-input" value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Write a message, poll, event, task, invite, or feedback..." />
|
||||
<Button type="submit" aria-label="Send">
|
||||
<Send size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
frontend/src/routes/DetailPage.tsx
Normal file
102
frontend/src/routes/DetailPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Check, Server } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, patchJson, postJson } from "../api/client";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function DetailPage({ kind }: { kind: "events" | "announcements" | "tasks" | "polls" }) {
|
||||
const { id } = useParams();
|
||||
const [item, setItem] = useState<any | null>(null);
|
||||
|
||||
async function load() {
|
||||
if (!id) return;
|
||||
const data = await api<any>(`/api/${kind}/${id}`);
|
||||
const key = kind === "events" ? "event" : kind === "announcements" ? "announcement" : kind === "tasks" ? "task" : "poll";
|
||||
setItem(data[key]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id, kind]);
|
||||
|
||||
if (!item) return <Loading label="Loading detail" />;
|
||||
|
||||
async function rsvp(status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${item.id}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function completeTask() {
|
||||
await patchJson(`/api/tasks/${item.id}`, { status: "done" });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function vote(optionId: string) {
|
||||
await postJson(`/api/polls/${item.id}/vote`, { option_id: optionId });
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">{kind}</span>
|
||||
<h1>{item.title}</h1>
|
||||
<p>{item.description || item.body || item.group_name}</p>
|
||||
</header>
|
||||
<Section title="Details">
|
||||
<Card>
|
||||
<div className="card-topline">
|
||||
{item.group_name ? <Badge tone="neutral">{item.group_name}</Badge> : null}
|
||||
{item.source_type === "remote" ? (
|
||||
<Badge tone="remote">
|
||||
<Server size={13} /> Remote
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.priority ? <Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.priority}</Badge> : null}
|
||||
</div>
|
||||
{item.starts_at ? <p>{formatDateTime(item.starts_at)}</p> : null}
|
||||
{item.due_at ? <p>Due {formatDateTime(item.due_at)}</p> : null}
|
||||
{item.location_name ? <p>{item.location_name}</p> : null}
|
||||
{kind === "events" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp("yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp("maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp("no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{kind === "tasks" && item.status === "open" ? (
|
||||
<Button type="button" onClick={completeTask}>
|
||||
<Check size={16} /> Complete task
|
||||
</Button>
|
||||
) : null}
|
||||
{kind === "polls" ? (
|
||||
<div className="stack-tight">
|
||||
{item.options.map((option: any) => (
|
||||
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(option.id)}>
|
||||
{option.label} · {option.vote_count}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.group_id ? (
|
||||
<Link className="text-link" to={`/groups/${item.group_id}`}>
|
||||
Back to group
|
||||
</Link>
|
||||
) : null}
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
frontend/src/routes/FilesPage.tsx
Normal file
95
frontend/src/routes/FilesPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { FileAsset, Group, Member } from "../api/types";
|
||||
import { fileSize } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
export function FilesPage() {
|
||||
const [files, setFiles] = useState<FileAsset[] | null>(null);
|
||||
const [groups, setGroups] = useState<{ group: Group; member: Member }[]>([]);
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [upload, setUpload] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [fileData, groupData] = await Promise.all([api<{ files: FileAsset[] }>("/api/home/files"), api<{ groups: { group: Group; member: Member }[] }>("/api/groups")]);
|
||||
setFiles(fileData.files);
|
||||
setGroups(groupData.groups);
|
||||
setGroupId((current) => current || groupData.groups[0]?.group.id || "");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!upload || !groupId) return;
|
||||
const form = new FormData();
|
||||
form.append("upload", upload);
|
||||
form.append("description", description);
|
||||
await api(`/api/groups/${groupId}/files`, { method: "POST", body: form });
|
||||
setUpload(null);
|
||||
setDescription("");
|
||||
await load();
|
||||
}
|
||||
|
||||
if (!files) return <Loading label="Loading files" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Files</span>
|
||||
<h1>Shared documents</h1>
|
||||
<p>Global file list with source group and server visible.</p>
|
||||
</header>
|
||||
<Section title="Upload local file">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submit}>
|
||||
<FormRow label="Group">
|
||||
<select className="input" value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
{groups.map((item) => (
|
||||
<option key={item.group.id} value={item.group.id}>
|
||||
{item.group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Description">
|
||||
<TextInput value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What members should know" />
|
||||
</FormRow>
|
||||
<FormRow label="File">
|
||||
<input className="input" type="file" onChange={(event) => setUpload(event.target.files?.[0] ?? null)} />
|
||||
</FormRow>
|
||||
<Button type="submit" disabled={!upload || !groupId}>
|
||||
<Upload size={16} /> Upload
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</Section>
|
||||
<Section title="All files">
|
||||
<div className="list-panel">
|
||||
{files.map((file) => (
|
||||
<a className="list-row" href={file.download_url ?? "#"} key={`${file.source_type}-${file.id}`}>
|
||||
<div>
|
||||
<strong>{file.filename_original}</strong>
|
||||
<span>{file.description || file.group_name}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
<Badge tone={file.source_type === "remote" ? "remote" : "neutral"}>{file.group_name}</Badge>
|
||||
<span>{fileSize(file.size_bytes)}</span>
|
||||
<Download size={16} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
frontend/src/routes/GroupPage.tsx
Normal file
473
frontend/src/routes/GroupPage.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import { Bell, CalendarPlus, Check, Clipboard, FileUp, MessageSquare, Plus, Vote } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, patchJson, postJson } from "../api/client";
|
||||
import type { ActionItem, Announcement, EventItem, FileAsset, Group, Member, Poll, TaskItem, Thread } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { FormRow, TextArea, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type GroupResponse = {
|
||||
group: Group;
|
||||
member: Member;
|
||||
members: Member[];
|
||||
dashboard: {
|
||||
important_now: ActionItem[];
|
||||
upcoming: EventItem[];
|
||||
open_actions: ActionItem[];
|
||||
announcements: Announcement[];
|
||||
tasks: TaskItem[];
|
||||
polls: Poll[];
|
||||
files: FileAsset[];
|
||||
discussions: Thread[];
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialTab?: "dashboard" | "compose" | "discussions" | "admin";
|
||||
};
|
||||
|
||||
export function GroupPage({ initialTab = "dashboard" }: Props) {
|
||||
const { groupId } = useParams();
|
||||
const [data, setData] = useState<GroupResponse | null>(null);
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
if (!groupId) return;
|
||||
setData(await api<GroupResponse>(`/api/groups/${groupId}`));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTab(initialTab);
|
||||
}, [initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [groupId]);
|
||||
|
||||
if (!data) return <Loading label="Loading group" />;
|
||||
|
||||
const { group, member, dashboard, members } = data;
|
||||
const canAdmin = ["owner", "admin"].includes(member.role);
|
||||
const canModerate = ["owner", "admin", "moderator"].includes(member.role);
|
||||
|
||||
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${eventId}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function completeTask(taskId: string) {
|
||||
await patchJson(`/api/tasks/${taskId}`, { status: "done" });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function vote(pollId: string, optionId: string) {
|
||||
await postJson(`/api/polls/${pollId}/vote`, { option_id: optionId });
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Group</span>
|
||||
<h1>{group.name}</h1>
|
||||
<p>{group.description}</p>
|
||||
<div className="button-row">
|
||||
<Badge tone="good">{member.role}</Badge>
|
||||
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
|
||||
{group.transition_deadline ? <Badge tone="urgent">Transition {group.transition_deadline}</Badge> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="segmented" role="tablist">
|
||||
{["dashboard", "compose", "discussions", "admin"].map((item) => (
|
||||
<button key={item} type="button" className={tab === item ? "active" : ""} onClick={() => setTab(item as typeof tab)} disabled={item === "admin" && !canAdmin}>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
|
||||
{tab === "dashboard" ? (
|
||||
<>
|
||||
<Section title="Important now">
|
||||
<div className="card-grid">
|
||||
{dashboard.important_now.length === 0 ? <EmptyState title="Nothing urgent" /> : null}
|
||||
{dashboard.important_now.map((action) => (
|
||||
<Card key={action.id}>
|
||||
<Badge tone={action.priority === "urgent" ? "urgent" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
|
||||
<h3>{action.title}</h3>
|
||||
<p>{action.summary}</p>
|
||||
{action.object_type === "event" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(action.object_id, "yes")}>
|
||||
<Check size={16} /> Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(action.object_id, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="text-link" to={`/${action.object_type}s/${action.object_id}`}>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Upcoming events">
|
||||
<div className="list-panel">
|
||||
{dashboard.upcoming.map((event) => (
|
||||
<div className="list-row" key={event.id}>
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.location_name || event.description}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
<span>{formatDateTime(event.starts_at)}</span>
|
||||
{event.rsvp_required ? <Badge tone={event.rsvp_status === "yes" ? "good" : "urgent"}>RSVP {event.rsvp_status}</Badge> : null}
|
||||
</div>
|
||||
{event.rsvp_required ? (
|
||||
<div className="button-row compact">
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="two-column">
|
||||
<Section title="Official announcements">
|
||||
<div className="list-panel">
|
||||
{dashboard.announcements.map((item) => (
|
||||
<Link className="list-row" to={`/announcements/${item.id}`} key={item.id}>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.body}</span>
|
||||
</div>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Post"}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Files">
|
||||
<div className="list-panel">
|
||||
{dashboard.files.map((file) => (
|
||||
<a href={file.download_url} className="list-row" key={file.id}>
|
||||
<strong>{file.filename_original}</strong>
|
||||
<span>{file.description}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Section title="Tasks and polls">
|
||||
<div className="card-grid">
|
||||
{dashboard.tasks.map((task) => (
|
||||
<Card key={task.id}>
|
||||
<Badge tone={task.status === "open" ? "urgent" : "good"}>{task.status}</Badge>
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.description}</p>
|
||||
{task.due_at ? <p>{formatDateTime(task.due_at)}</p> : null}
|
||||
{task.status === "open" ? (
|
||||
<Button type="button" variant="secondary" onClick={() => completeTask(task.id)}>
|
||||
Complete
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
{dashboard.polls.map((poll) => (
|
||||
<Card key={poll.id}>
|
||||
<Badge tone="neutral">Poll</Badge>
|
||||
<h3>{poll.title}</h3>
|
||||
<p>{poll.description}</p>
|
||||
<div className="stack-tight">
|
||||
{poll.options.map((option) => (
|
||||
<Button key={option.id} type="button" variant="secondary" onClick={() => vote(poll.id, option.id)}>
|
||||
{option.label} · {option.vote_count}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "compose" ? (
|
||||
<ComposePanel group={group} members={members} canModerate={canModerate} onDone={load} setNotice={setNotice} />
|
||||
) : null}
|
||||
|
||||
{tab === "discussions" ? (
|
||||
<DiscussionsPanel group={group} threads={dashboard.discussions} onDone={load} />
|
||||
) : null}
|
||||
|
||||
{tab === "admin" && canAdmin ? <AdminPanel group={group} onDone={load} setNotice={setNotice} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposePanel({
|
||||
group,
|
||||
members,
|
||||
canModerate,
|
||||
onDone,
|
||||
setNotice
|
||||
}: {
|
||||
group: Group;
|
||||
members: Member[];
|
||||
canModerate: boolean;
|
||||
onDone: () => Promise<void>;
|
||||
setNotice: (value: string) => void;
|
||||
}) {
|
||||
const [announcementTitle, setAnnouncementTitle] = useState("");
|
||||
const [announcementBody, setAnnouncementBody] = useState("");
|
||||
const [eventTitle, setEventTitle] = useState("");
|
||||
const [taskTitle, setTaskTitle] = useState("");
|
||||
const [assignedTo, setAssignedTo] = useState("");
|
||||
const [pollTitle, setPollTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
async function submitAnnouncement(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/announcements`, { title: announcementTitle, body: announcementBody, priority: "normal", official: canModerate, requires_ack: false });
|
||||
setAnnouncementTitle("");
|
||||
setAnnouncementBody("");
|
||||
setNotice("Announcement created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitEvent(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const startsAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await postJson(`/api/groups/${group.id}/events`, { title: eventTitle, starts_at: startsAt, rsvp_required: true, location_name: "TBD" });
|
||||
setEventTitle("");
|
||||
setNotice("Event created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitTask(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/tasks`, { title: taskTitle, assigned_to_member_id: assignedTo || null });
|
||||
setTaskTitle("");
|
||||
setNotice("Task created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitPoll(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/polls`, { title: pollTitle, options: [{ label: "Yes" }, { label: "No" }] });
|
||||
setPollTitle("");
|
||||
setNotice("Poll created.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
async function submitFile(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append("upload", file);
|
||||
form.append("description", "Uploaded from group dashboard");
|
||||
await api(`/api/groups/${group.id}/files`, { method: "POST", body: form });
|
||||
setFile(null);
|
||||
setNotice("File uploaded.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Create structured content" eyebrow="Composer">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitAnnouncement}>
|
||||
<h3>
|
||||
<Bell size={17} /> Announcement
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={announcementTitle} onChange={(event) => setAnnouncementTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Body">
|
||||
<TextArea value={announcementBody} onChange={(event) => setAnnouncementBody(event.target.value)} />
|
||||
</FormRow>
|
||||
<Button type="submit">Post</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitEvent}>
|
||||
<h3>
|
||||
<CalendarPlus size={17} /> Event
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={eventTitle} onChange={(event) => setEventTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Create event</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitTask}>
|
||||
<h3>
|
||||
<Check size={17} /> Task
|
||||
</h3>
|
||||
<FormRow label="Title">
|
||||
<TextInput value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Assigned to">
|
||||
<select className="input" value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)}>
|
||||
<option value="">No one yet</option>
|
||||
{members.map((item) => (
|
||||
<option value={item.id} key={item.id}>
|
||||
{item.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<Button type="submit">Create task</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitPoll}>
|
||||
<h3>
|
||||
<Vote size={17} /> Poll
|
||||
</h3>
|
||||
<FormRow label="Question">
|
||||
<TextInput value={pollTitle} onChange={(event) => setPollTitle(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Create yes/no poll</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={submitFile}>
|
||||
<h3>
|
||||
<FileUp size={17} /> File
|
||||
</h3>
|
||||
<FormRow label="Upload">
|
||||
<input className="input" type="file" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</FormRow>
|
||||
<Button type="submit" disabled={!file}>Upload file</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscussionsPanel({ group, threads, onDone }: { group: Group; threads: Thread[]; onDone: () => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
async function createThread(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson(`/api/groups/${group.id}/threads`, { title, kind: "discussion" });
|
||||
setTitle("");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Discussions" eyebrow="Secondary">
|
||||
<Card>
|
||||
<form className="inline-form" onSubmit={createThread}>
|
||||
<TextInput value={title} onChange={(event) => setTitle(event.target.value)} required placeholder="New discussion title" />
|
||||
<Button type="submit">
|
||||
<MessageSquare size={16} /> Start
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<div className="list-panel">
|
||||
{threads.map((thread) => (
|
||||
<div className="list-row" key={thread.id}>
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span>{thread.kind === "archive" ? "Read-only archive" : "Discussion thread"}</span>
|
||||
</div>
|
||||
<Badge tone={thread.kind === "archive" ? "quiet" : "neutral"}>{thread.kind}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPanel({ group, onDone, setNotice }: { group: Group; onDone: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [migration, setMigration] = useState<any | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = useState("");
|
||||
const [copy, setCopy] = useState("");
|
||||
|
||||
async function loadMigration() {
|
||||
setMigration(await api(`/api/groups/${group.id}/migration`));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadMigration();
|
||||
}, [group.id]);
|
||||
|
||||
async function createInvite() {
|
||||
const result = await postJson<{ invite_url: string }>(`/api/groups/${group.id}/invites`, { label: "Member invite", max_uses: 100 });
|
||||
setInviteUrl(result.invite_url);
|
||||
}
|
||||
|
||||
async function reminder() {
|
||||
const result = await postJson<{ copy: string; invite_url: string }>(`/api/groups/${group.id}/migration/reminder-copy`, { frontend_origin: window.location.origin });
|
||||
setCopy(result.copy);
|
||||
setInviteUrl(result.invite_url);
|
||||
await loadMigration();
|
||||
}
|
||||
|
||||
async function markLegacy() {
|
||||
await patchJson(`/api/groups/${group.id}`, { legacy_channel_status: "legacy" });
|
||||
setNotice("Legacy channel status updated.");
|
||||
await onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Migration dashboard" eyebrow="Admin">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>Member migration</h3>
|
||||
{migration ? (
|
||||
<ul className="metric-list">
|
||||
{Object.entries(migration.stats).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<strong>{String(value)}</strong>
|
||||
<span>{key.replaceAll("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Loading label="Loading migration" />
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Invite and reminder</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={createInvite}>
|
||||
<Plus size={16} /> Invite link
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={reminder}>
|
||||
<Clipboard size={16} /> Reminder copy
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={markLegacy}>
|
||||
Mark legacy
|
||||
</Button>
|
||||
</div>
|
||||
{inviteUrl ? <pre className="copy-box">{inviteUrl}</pre> : null}
|
||||
{copy ? <pre className="copy-box">{copy}</pre> : null}
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
89
frontend/src/routes/GroupsPage.tsx
Normal file
89
frontend/src/routes/GroupsPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { Group, Member } from "../api/types";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextArea, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type GroupRow = {
|
||||
group: Group;
|
||||
member: Member;
|
||||
dashboard: { important_now: unknown[]; upcoming: unknown[]; files: unknown[] };
|
||||
};
|
||||
|
||||
export function GroupsPage() {
|
||||
const [groups, setGroups] = useState<GroupRow[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
async function load() {
|
||||
const data = await api<{ groups: GroupRow[] }>("/api/groups");
|
||||
setGroups(data.groups);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createGroup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/groups", { name, description, visibility: "private", owner_display_name: "Group admin" });
|
||||
setName("");
|
||||
setDescription("");
|
||||
await load();
|
||||
}
|
||||
|
||||
if (!groups) return <Loading label="Loading groups" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Groups</span>
|
||||
<h1>Structured group spaces</h1>
|
||||
<p>Dashboards, members, files, announcements, tasks, and discussions grouped by organization.</p>
|
||||
</header>
|
||||
|
||||
<Section title="My groups">
|
||||
<div className="card-grid">
|
||||
{groups.map(({ group, member, dashboard }) => (
|
||||
<Link to={`/groups/${group.id}`} className="card link-card" key={group.id}>
|
||||
<div className="card-topline">
|
||||
<Badge tone="good">{member.role}</Badge>
|
||||
<Badge tone="quiet">{group.legacy_channel_status}</Badge>
|
||||
</div>
|
||||
<h3>{group.name}</h3>
|
||||
<p>{group.description}</p>
|
||||
<ul className="mini-stats">
|
||||
<li>{dashboard.important_now.length} open actions</li>
|
||||
<li>{dashboard.upcoming.length} upcoming</li>
|
||||
<li>{dashboard.files.length} files</li>
|
||||
</ul>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Create a group" eyebrow="Admin">
|
||||
<Card>
|
||||
<form className="form-grid" onSubmit={createGroup}>
|
||||
<FormRow label="Group name">
|
||||
<TextInput value={name} onChange={(event) => setName(event.target.value)} required placeholder="Choir parents" />
|
||||
</FormRow>
|
||||
<FormRow label="Purpose">
|
||||
<TextArea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Announcements, events, files, and coordination." />
|
||||
</FormRow>
|
||||
<Button type="submit">
|
||||
<Plus size={16} /> Create group
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
146
frontend/src/routes/HomePage.tsx
Normal file
146
frontend/src/routes/HomePage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Check, ExternalLink } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { ActionItem, Announcement, EventItem } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type HomeResponse = {
|
||||
profile: { primary_display_name: string };
|
||||
sections: {
|
||||
needs_me: ActionItem[];
|
||||
today: EventItem[];
|
||||
changed: ActionItem[];
|
||||
official_updates: Announcement[];
|
||||
catch_up: { label: string; count: number }[];
|
||||
};
|
||||
connections: { id: string; server_name: string; status: string }[];
|
||||
};
|
||||
|
||||
export function HomePage() {
|
||||
const [data, setData] = useState<HomeResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setData(await api<HomeResponse>("/api/home"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load home.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function rsvp(action: ActionItem, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${action.object_id}/rsvp`, { status });
|
||||
await load();
|
||||
}
|
||||
|
||||
if (error) return <div className="error-panel">{error}</div>;
|
||||
if (!data) return <Loading label="Loading home" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Home</span>
|
||||
<h1>What needs attention</h1>
|
||||
<p>{data.profile.primary_display_name}'s groups, actions, and official updates in one place.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Section title="Needs me" eyebrow="Actionable">
|
||||
<div className="card-grid">
|
||||
{data.sections.needs_me.length === 0 ? <EmptyState title="No open actions" body="You are caught up across your groups." /> : null}
|
||||
{data.sections.needs_me.map((action) => (
|
||||
<Card key={action.id}>
|
||||
<div className="card-topline">
|
||||
<Badge tone={action.priority === "urgent" ? "urgent" : action.source_type === "remote" ? "remote" : "neutral"}>{action.type.replaceAll("_", " ")}</Badge>
|
||||
{action.source_type === "remote" ? <Badge tone="remote">Remote</Badge> : null}
|
||||
</div>
|
||||
<h3>{action.title}</h3>
|
||||
<p>{action.summary}</p>
|
||||
<div className="meta-row">
|
||||
<span>{action.source_group_name}</span>
|
||||
{action.due_at ? <span>{formatDateTime(action.due_at)}</span> : null}
|
||||
</div>
|
||||
{action.object_type === "event" && action.source_type !== "remote" ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(action, "yes")}>
|
||||
<Check size={16} /> Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(action, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(action, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="text-link" to={action.source_type === "remote" ? "/me/servers/connect" : `/groups/${action.source_group_id}`}>
|
||||
Open source <ExternalLink size={14} />
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Today / Upcoming" eyebrow="Agenda">
|
||||
<div className="list-panel">
|
||||
{data.sections.today.map((event) => (
|
||||
<Link key={`${event.source_type}-${event.id}`} to={event.source_type === "remote" ? "/me/servers/connect" : `/events/${event.id}`} className="list-row">
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
<span>{event.group_name}</span>
|
||||
</div>
|
||||
<div className="row-tail">
|
||||
{event.changed_at ? <Badge tone="urgent">Changed</Badge> : null}
|
||||
<span>{formatDateTime(event.starts_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="two-column">
|
||||
<Section title="Official updates" eyebrow="Not chatter">
|
||||
<div className="list-panel">
|
||||
{data.sections.official_updates.map((item) => (
|
||||
<Link key={item.id} to={`/announcements/${item.id}`} className="list-row">
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.group_name}</span>
|
||||
</div>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>{item.official ? "Official" : "Discussion"}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Catch up" eyebrow="Since last visit">
|
||||
<Card>
|
||||
<p>While you were away:</p>
|
||||
<ul className="metric-list">
|
||||
{data.sections.catch_up.map((item) => (
|
||||
<li key={item.label}>
|
||||
<strong>{item.count}</strong>
|
||||
<span>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
133
frontend/src/routes/JoinPage.tsx
Normal file
133
frontend/src/routes/JoinPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Check, Smartphone } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, postJson } from "../api/client";
|
||||
import type { Announcement, EventItem, Group } from "../api/types";
|
||||
import { formatDateTime } from "../app/format";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
|
||||
type Preview = {
|
||||
group: Group;
|
||||
invite: { label: string; role: string };
|
||||
preview: { announcements: Announcement[]; events: EventItem[] };
|
||||
};
|
||||
|
||||
export function JoinPage() {
|
||||
const { token } = useParams();
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api<Preview>(`/api/join/${token}/preview`)
|
||||
.then((data) => {
|
||||
setPreview(data);
|
||||
setDisplayName("");
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "This invite is not available."));
|
||||
}, [token]);
|
||||
|
||||
async function claim(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!token) return;
|
||||
await postJson(`/api/auth/invite/${token}/claim`, { display_name: displayName, device_label: "This browser" });
|
||||
setClaimed(true);
|
||||
}
|
||||
|
||||
async function rsvp(eventId: string, status: "yes" | "maybe" | "no") {
|
||||
await postJson(`/api/events/${eventId}/rsvp`, { status });
|
||||
setClaimed(true);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="join-shell">
|
||||
<Card>
|
||||
<h1>Invite unavailable</h1>
|
||||
<p>{error}</p>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (!preview) return <Loading label="Opening invite" />;
|
||||
|
||||
return (
|
||||
<main className="join-shell">
|
||||
<section className="join-card">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark">GH</span>
|
||||
<div>
|
||||
<strong>GroupHome</strong>
|
||||
<small>Open group</small>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="good">{preview.invite.role}</Badge>
|
||||
<h1>{preview.group.name}</h1>
|
||||
<p>{preview.group.description}</p>
|
||||
<p className="pitch">Get updates, RSVP, files, and decisions here without reading every group chat.</p>
|
||||
|
||||
{!claimed ? (
|
||||
<form className="form-grid" onSubmit={claim}>
|
||||
<FormRow label="Display name">
|
||||
<TextInput value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Anna Müller" required autoFocus />
|
||||
</FormRow>
|
||||
<Button type="submit">
|
||||
<Smartphone size={16} /> Join this group
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="notice">
|
||||
<Check size={17} /> You joined. You can act now and save access later.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="join-preview">
|
||||
<h2>Official updates</h2>
|
||||
<div className="card-grid">
|
||||
{preview.preview.announcements.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<Badge tone={item.priority === "urgent" ? "urgent" : "good"}>Official</Badge>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<h2>Upcoming</h2>
|
||||
<div className="card-grid">
|
||||
{preview.preview.events.map((event) => (
|
||||
<Card key={event.id}>
|
||||
<Badge tone="neutral">{formatDateTime(event.starts_at)}</Badge>
|
||||
<h3>{event.title}</h3>
|
||||
<p>{event.location_name || event.description}</p>
|
||||
{claimed && event.rsvp_required ? (
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={() => rsvp(event.id, "yes")}>
|
||||
Yes
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => rsvp(event.id, "maybe")}>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => rsvp(event.id, "no")}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{claimed ? (
|
||||
<Link className="button button-primary" to={`/groups/${preview.group.id}`}>
|
||||
Open group dashboard
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
362
frontend/src/routes/MePage.tsx
Normal file
362
frontend/src/routes/MePage.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { Bell, KeyRound, Link2, RefreshCw, Server, ShieldCheck, Smartphone, Trash2 } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, deleteJson, patchJson, postJson } from "../api/client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { FormRow, TextInput } from "../components/FormRow";
|
||||
import { Loading } from "../components/Loading";
|
||||
import { Section } from "../components/Section";
|
||||
|
||||
type MePageProps = {
|
||||
initialPanel?: "profile" | "devices" | "notifications" | "servers";
|
||||
};
|
||||
|
||||
export function MePage({ initialPanel = "profile" }: MePageProps) {
|
||||
const [panel, setPanel] = useState(initialPanel);
|
||||
const [me, setMe] = useState<any | null>(null);
|
||||
const [devices, setDevices] = useState<any[]>([]);
|
||||
const [prefs, setPrefs] = useState<Record<string, string>>({});
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
const [meData, deviceData, prefData, notificationData, serverData] = await Promise.all([
|
||||
api<any>("/api/me"),
|
||||
api<any>("/api/me/devices"),
|
||||
api<any>("/api/me/notification-preferences"),
|
||||
api<any>("/api/me/notifications"),
|
||||
api<any>("/api/remote/servers")
|
||||
]);
|
||||
setMe(meData);
|
||||
setDevices(deviceData.devices);
|
||||
setPrefs(prefData.preferences);
|
||||
setNotifications(notificationData.notifications);
|
||||
setServers(serverData.servers);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPanel(initialPanel);
|
||||
}, [initialPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (!me) return <Loading label="Loading profile" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<span className="eyebrow">Me</span>
|
||||
<h1>{me.profile?.primary_display_name ?? "Your access"}</h1>
|
||||
<p>Save access, link devices, tune notifications, and connect group servers.</p>
|
||||
</header>
|
||||
<div className="segmented" role="tablist">
|
||||
{[
|
||||
["profile", "Profile"],
|
||||
["devices", "Devices"],
|
||||
["notifications", "Notifications"],
|
||||
["servers", "Servers"]
|
||||
].map(([key, label]) => (
|
||||
<button key={key} type="button" className={panel === key ? "active" : ""} onClick={() => setPanel(key as typeof panel)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notice ? <div className="notice">{notice}</div> : null}
|
||||
|
||||
{panel === "profile" ? <ProfilePanel setNotice={setNotice} /> : null}
|
||||
{panel === "devices" ? <DevicesPanel devices={devices} reload={load} setNotice={setNotice} /> : null}
|
||||
{panel === "notifications" ? <NotificationsPanel prefs={prefs} notifications={notifications} reload={load} setNotice={setNotice} /> : null}
|
||||
{panel === "servers" ? <ServersPanel servers={servers} reload={load} setNotice={setNotice} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePanel({ setNotice }: { setNotice: (value: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [recoveryCode, setRecoveryCode] = useState("");
|
||||
const [devCode, setDevCode] = useState("");
|
||||
|
||||
async function requestRecovery(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const result = await postJson<any>("/api/auth/recovery/request", { email });
|
||||
setDevCode(result.dev_code ?? "");
|
||||
setNotice("Recovery access prepared.");
|
||||
}
|
||||
|
||||
async function consumeRecovery(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/recovery/consume", { recovery_code: recoveryCode, device_label: "Recovered browser" });
|
||||
setNotice("Access recovered on this browser.");
|
||||
}
|
||||
|
||||
async function passkeyReady() {
|
||||
await postJson("/api/auth/passkeys/register/options", { display_name: "GroupHome member" });
|
||||
await postJson("/api/auth/passkeys/register/verify", { development: true });
|
||||
setNotice("Passkey-ready protection is wired for development.");
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Save access" eyebrow="Progressive identity">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<ShieldCheck size={17} /> Recovery email
|
||||
</h3>
|
||||
<form className="form-grid" onSubmit={requestRecovery}>
|
||||
<FormRow label="Email">
|
||||
<TextInput type="email" value={email} onChange={(event) => setEmail(event.target.value)} required placeholder="anna@example.org" />
|
||||
</FormRow>
|
||||
<Button type="submit">Send recovery link</Button>
|
||||
</form>
|
||||
{devCode ? <pre className="copy-box">{devCode}</pre> : null}
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>
|
||||
<KeyRound size={17} /> Recover access
|
||||
</h3>
|
||||
<form className="form-grid" onSubmit={consumeRecovery}>
|
||||
<FormRow label="Recovery code">
|
||||
<TextInput value={recoveryCode} onChange={(event) => setRecoveryCode(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Recover access</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Protect access</h3>
|
||||
<p>Development passkey routes are available behind a pluggable provider.</p>
|
||||
<Button type="button" variant="secondary" onClick={passkeyReady}>
|
||||
<KeyRound size={16} /> Make passkey-ready
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesPanel({ devices, reload, setNotice }: { devices: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [startedCode, setStartedCode] = useState("");
|
||||
const [approveCode, setApproveCode] = useState("");
|
||||
const [completeCode, setCompleteCode] = useState("");
|
||||
|
||||
async function start() {
|
||||
const result = await postJson<any>("/api/auth/device-link/start", { device_label: "Second browser" });
|
||||
setStartedCode(result.code);
|
||||
setCompleteCode(result.code);
|
||||
}
|
||||
|
||||
async function approve(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/device-link/approve", { code: approveCode || startedCode });
|
||||
setNotice("Device link approved.");
|
||||
}
|
||||
|
||||
async function complete(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/auth/device-link/complete", { code: completeCode, device_label: "Linked browser" });
|
||||
setNotice("Device linked.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
await deleteJson(`/api/me/devices/${id}`);
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Devices" eyebrow="Link another device">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Smartphone size={17} /> Pairing
|
||||
</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={start}>
|
||||
<Link2 size={16} /> Start code
|
||||
</Button>
|
||||
</div>
|
||||
{startedCode ? <pre className="pairing-code">{startedCode}</pre> : null}
|
||||
<form className="form-grid" onSubmit={approve}>
|
||||
<FormRow label="Approve code">
|
||||
<TextInput value={approveCode} onChange={(event) => setApproveCode(event.target.value)} placeholder={startedCode || "ABC123"} />
|
||||
</FormRow>
|
||||
<Button type="submit" variant="secondary">Approve</Button>
|
||||
</form>
|
||||
<form className="form-grid" onSubmit={complete}>
|
||||
<FormRow label="Complete code">
|
||||
<TextInput value={completeCode} onChange={(event) => setCompleteCode(event.target.value)} placeholder="Code from new device" />
|
||||
</FormRow>
|
||||
<Button type="submit" variant="secondary">Complete on this browser</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Known devices</h3>
|
||||
<div className="list-panel flush">
|
||||
{devices.map((device) => (
|
||||
<div className="list-row" key={device.id}>
|
||||
<div>
|
||||
<strong>{device.label}</strong>
|
||||
<span>{device.trust_level}</span>
|
||||
</div>
|
||||
{device.current ? <Badge tone="good">Current</Badge> : null}
|
||||
{!device.revoked_at ? (
|
||||
<Button type="button" variant="ghost" onClick={() => revoke(device.id)} aria-label={`Revoke ${device.label}`}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
) : (
|
||||
<Badge tone="quiet">Revoked</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsPanel({
|
||||
prefs,
|
||||
notifications,
|
||||
reload,
|
||||
setNotice
|
||||
}: {
|
||||
prefs: Record<string, string>;
|
||||
notifications: any[];
|
||||
reload: () => Promise<void>;
|
||||
setNotice: (value: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(prefs);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(prefs);
|
||||
}, [prefs]);
|
||||
|
||||
async function save() {
|
||||
await patchJson("/api/me/notification-preferences", { preferences: draft });
|
||||
setNotice("Notification preferences saved.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
await patchJson(`/api/me/notifications/${id}/read`, {});
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Notifications" eyebrow="Mute the noise, not the group">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Bell size={17} /> Preferences
|
||||
</h3>
|
||||
<div className="form-grid">
|
||||
{Object.entries(draft).map(([category, delivery]) => (
|
||||
<FormRow key={category} label={category.replaceAll("_", " ")}>
|
||||
<select className="input" value={delivery} onChange={(event) => setDraft({ ...draft, [category]: event.target.value })}>
|
||||
<option value="immediate">Immediate</option>
|
||||
<option value="digest">Digest</option>
|
||||
<option value="muted">Muted</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
))}
|
||||
<Button type="button" onClick={save}>Save preferences</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Inbox</h3>
|
||||
<div className="list-panel flush">
|
||||
{notifications.map((item) => (
|
||||
<div className="list-row" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.body}</span>
|
||||
</div>
|
||||
{item.read_at ? <Badge tone="quiet">Read</Badge> : <Button type="button" variant="ghost" onClick={() => markRead(item.id)}>Read</Button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersPanel({ servers, reload, setNotice }: { servers: any[]; reload: () => Promise<void>; setNotice: (value: string) => void }) {
|
||||
const [serverUrl, setServerUrl] = useState("http://localhost:8000");
|
||||
const [connectionCode, setConnectionCode] = useState("");
|
||||
const [generatedCode, setGeneratedCode] = useState("");
|
||||
|
||||
async function createCode() {
|
||||
const result = await postJson<any>("/api/connection-tokens", { label: "Connect my home server", scopes: ["sync:read"] });
|
||||
setGeneratedCode(result.connection_code_display_once);
|
||||
setConnectionCode(result.connection_code_display_once);
|
||||
}
|
||||
|
||||
async function connect(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await postJson("/api/remote/servers/connect", { server_url: serverUrl, connection_code: connectionCode });
|
||||
setNotice("Connected server synced.");
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function sync(id: string) {
|
||||
await postJson(`/api/remote/servers/${id}/sync`, {});
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await deleteJson(`/api/remote/servers/${id}`);
|
||||
await reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Connected servers" eyebrow="No full federation">
|
||||
<div className="card-grid">
|
||||
<Card>
|
||||
<h3>
|
||||
<Server size={17} /> Connect another group server
|
||||
</h3>
|
||||
<div className="button-row">
|
||||
<Button type="button" variant="secondary" onClick={createCode}>
|
||||
Create connection code
|
||||
</Button>
|
||||
</div>
|
||||
{generatedCode ? <pre className="copy-box">{generatedCode}</pre> : null}
|
||||
<form className="form-grid" onSubmit={connect}>
|
||||
<FormRow label="Server URL">
|
||||
<TextInput value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} required />
|
||||
</FormRow>
|
||||
<FormRow label="Connection code">
|
||||
<TextInput value={connectionCode} onChange={(event) => setConnectionCode(event.target.value)} required />
|
||||
</FormRow>
|
||||
<Button type="submit">Connect and sync</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3>Servers</h3>
|
||||
<div className="list-panel flush">
|
||||
{servers.map((server) => (
|
||||
<div className="list-row" key={server.id}>
|
||||
<div>
|
||||
<strong>{server.server_name}</strong>
|
||||
<span>{server.server_origin}</span>
|
||||
</div>
|
||||
<Badge tone={server.status === "active" ? "good" : "urgent"}>{server.status}</Badge>
|
||||
<Button type="button" variant="ghost" onClick={() => sync(server.id)} aria-label={`Sync ${server.server_name}`}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => remove(server.id)} aria-label={`Remove ${server.server_name}`}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
907
frontend/src/styles/base.css
Normal file
907
frontend/src/styles/base.css
Normal file
@@ -0,0 +1,907 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f4ee;
|
||||
--surface: #fffdf8;
|
||||
--surface-strong: #ffffff;
|
||||
--ink: #20211f;
|
||||
--muted: #65675f;
|
||||
--line: #ded9cf;
|
||||
--teal: #0f766e;
|
||||
--teal-ink: #073b37;
|
||||
--coral: #c4513d;
|
||||
--amber: #8a5b00;
|
||||
--green: #2f7d32;
|
||||
--indigo: #3b4a8f;
|
||||
--shadow: 0 12px 28px rgba(38, 31, 22, 0.1);
|
||||
--radius: 8px;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 76px;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 18px 14px 28px;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-lockup small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
min-height: 68px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255, 253, 248, 0.96);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.bottom-item,
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bottom-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bottom-item.active,
|
||||
.nav-item.active {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.bottom-item.primary {
|
||||
position: relative;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.bottom-item.primary svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.28);
|
||||
}
|
||||
|
||||
.bottom-item.primary span {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.nav-item.primary {
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item.primary.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-stack,
|
||||
.standalone {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.standalone {
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--teal);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.link-card {
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.card-topline,
|
||||
.meta-row,
|
||||
.button-row,
|
||||
.inline-meta,
|
||||
.row-tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.meta-row,
|
||||
.row-tail {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.row-tail {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button-row.compact {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: fit-content;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f4f0e8;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-urgent {
|
||||
border-color: rgba(196, 81, 61, 0.35);
|
||||
background: #fff0ed;
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.badge-good {
|
||||
border-color: rgba(47, 125, 50, 0.28);
|
||||
background: #edf7ee;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badge-remote {
|
||||
border-color: rgba(59, 74, 143, 0.28);
|
||||
background: #eef1ff;
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.badge-quiet {
|
||||
background: #f6f1df;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 42px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background: #e7f4f2;
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
border-color: var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: var(--coral);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--teal);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.list-panel.flush {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-row strong,
|
||||
.list-row span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.list-row span {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
grid-template-columns: 8.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.date-block {
|
||||
color: var(--teal);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.form-row span {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 104px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--teal);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.segmented button:disabled {
|
||||
color: #b8afa2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-list,
|
||||
.mini-stats {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.metric-list li,
|
||||
.mini-stats li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-list strong {
|
||||
color: var(--ink);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stack-tight {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-box,
|
||||
.pairing-code {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--teal);
|
||||
border-radius: var(--radius);
|
||||
background: #f0faf8;
|
||||
color: var(--teal-ink);
|
||||
font-weight: 800;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.error-panel,
|
||||
.empty-state,
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
display: grid;
|
||||
max-width: 520px;
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 140px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: fit-content;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.skeleton-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--teal);
|
||||
animation: pulse 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.join-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: min(1040px, 100%);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.join-card,
|
||||
.join-preview {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.pitch {
|
||||
color: var(--teal-ink);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
min-height: calc(100vh - 116px);
|
||||
}
|
||||
|
||||
.chat-sidebar,
|
||||
.chat-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
align-content: start;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-sidebar-header,
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-sidebar h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.chat-thread-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 28vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-thread {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-thread.active {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background: #e7f4f2;
|
||||
}
|
||||
|
||||
.chat-thread span,
|
||||
.chat-thread small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(320px, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
linear-gradient(rgba(247, 244, 238, 0.86), rgba(247, 244, 238, 0.86)),
|
||||
repeating-linear-gradient(45deg, rgba(15, 118, 110, 0.04) 0 2px, transparent 2px 14px);
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-width: min(78%, 620px);
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
background: white;
|
||||
box-shadow: 0 8px 18px rgba(38, 31, 22, 0.07);
|
||||
}
|
||||
|
||||
.bubble.mine {
|
||||
border-color: rgba(15, 118, 110, 0.22);
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
background: #dff3ef;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bubble-meta strong {
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.folded-noise {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: 90%;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 248, 0.88);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.structure-strip {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.structure-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-chip.active {
|
||||
border-color: rgba(15, 118, 110, 0.35);
|
||||
background: #e7f4f2;
|
||||
color: var(--teal-ink);
|
||||
}
|
||||
|
||||
.composer-hint {
|
||||
width: fit-content;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f6f1df;
|
||||
color: var(--amber);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
min-height: 54px;
|
||||
max-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (min-width: 680px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.list-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-shell {
|
||||
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: start;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
height: 100vh;
|
||||
padding: 1.25rem;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.side-nav nav {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: flex-start;
|
||||
min-height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #e7f4f2;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
.chat-thread-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-column {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
|
||||
}
|
||||
}
|
||||
30
frontend/src/test/App.test.tsx
Normal file
30
frontend/src/test/App.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../app/App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the shell after dev bootstrap", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ authenticated: true, dev_mode: true }), { status: 200 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
profile: { primary_display_name: "Anna Müller" },
|
||||
sections: { needs_me: [], today: [], changed: [], official_updates: [], catch_up: [] },
|
||||
connections: []
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("What needs attention")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
2
frontend/src/test/setup.ts
Normal file
2
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2021"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/.well-known": "http://localhost:8000"
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
|
||||
84
scripts/launch-dev.sh
Normal file
84
scripts/launch-dev.sh
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GROUPHOME_ROOT:-/mnt/DATA/git/comiaunicaty}"
|
||||
PYTHON_PROBE="${PYTHON_PROBE:-python3}"
|
||||
BACKEND_URL="${GROUPHOME_BACKEND_URL:-http://127.0.0.1:8000}"
|
||||
FRONTEND_URL="${GROUPHOME_FRONTEND_URL:-http://127.0.0.1:5173}"
|
||||
OPEN_BROWSER="${OPEN_BROWSER:-1}"
|
||||
KEEP_RUNNING="${GROUPHOME_KEEP_RUNNING:-0}"
|
||||
|
||||
fail() {
|
||||
printf 'launch-dev: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_url() {
|
||||
"$PYTHON_PROBE" - "$1" <<'PY'
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
url = sys.argv[1]
|
||||
deadline = time.monotonic() + 180
|
||||
last_error = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=3) as response:
|
||||
if 200 <= response.status < 500:
|
||||
raise SystemExit(0)
|
||||
except Exception as exc: # noqa: BLE001 - printed only on timeout.
|
||||
last_error = exc
|
||||
time.sleep(2)
|
||||
print(f"Timed out waiting for {url}: {last_error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_RUNNING" != "1" ]; then
|
||||
(cd "$ROOT" && docker compose down) >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fail "docker not found. Install Docker with Compose support, then rerun this launcher."
|
||||
command -v "$PYTHON_PROBE" >/dev/null 2>&1 || fail "$PYTHON_PROBE not found. Set PYTHON_PROBE to a Python executable."
|
||||
[ -f "$ROOT/docker-compose.yml" ] || fail "docker-compose.yml not found at $ROOT"
|
||||
|
||||
printf 'Starting GroupHome with Docker Compose\n'
|
||||
(
|
||||
cd "$ROOT"
|
||||
docker compose up --build -d
|
||||
)
|
||||
|
||||
printf 'Waiting for %s/api/health\n' "$BACKEND_URL"
|
||||
wait_for_url "$BACKEND_URL/api/health" || {
|
||||
(cd "$ROOT" && docker compose logs --tail=120) >&2 || true
|
||||
fail "backend did not become healthy"
|
||||
}
|
||||
|
||||
printf 'Waiting for %s\n' "$FRONTEND_URL"
|
||||
wait_for_url "$FRONTEND_URL" || {
|
||||
(cd "$ROOT" && docker compose logs --tail=120) >&2 || true
|
||||
fail "frontend did not become reachable"
|
||||
}
|
||||
|
||||
if [ "$OPEN_BROWSER" = "1" ] && command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$FRONTEND_URL" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
GroupHome is running through Docker Compose.
|
||||
Web UI: $FRONTEND_URL
|
||||
API: $BACKEND_URL/api
|
||||
Health: $BACKEND_URL/api/health
|
||||
Demo invite: $FRONTEND_URL/join/demo-fc-invite
|
||||
|
||||
Compose logs are attached below.
|
||||
Press Ctrl+C to stop services. Set GROUPHOME_KEEP_RUNNING=1 to leave them running after this launcher exits.
|
||||
EOF
|
||||
|
||||
cd "$ROOT"
|
||||
docker compose logs -f
|
||||
Reference in New Issue
Block a user