inital commit, very early alpha stage

This commit is contained in:
2026-06-30 13:38:24 +02:00
parent f5530ad336
commit 70cf1a84ca
72 changed files with 14074 additions and 2 deletions

15
.env.example Normal file
View 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
View File

@@ -3,6 +3,9 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.db
*.sqlite
*.sqlite3
# C extensions # C extensions
*.so *.so
@@ -23,6 +26,7 @@ var/
wheels/ wheels/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.eggs/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
@@ -217,6 +221,7 @@ build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
backend/storage/
jspm_packages/ jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Snowpack dependency directory (https://snowpack.dev/)
@@ -325,4 +330,3 @@ dist
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix

171
ACCEPTANCE_TESTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
"""GroupHome backend package."""

View File

@@ -0,0 +1,2 @@
"""Core configuration and security helpers."""

View 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

View 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"

View File

@@ -0,0 +1,2 @@
"""Database helpers."""

31
backend/app/db/base.py Normal file
View 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
View 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
View 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)

View 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)

View File

@@ -0,0 +1,2 @@
"""API routers."""

321
backend/app/routers/auth.py Normal file
View 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
View 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)}

View 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
View 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}

View 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}

View 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)

View File

@@ -0,0 +1,2 @@
"""Application services."""

View 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 {},
)
)

View 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,
}

View 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()

View 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

View 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

View 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),
}

View 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
View 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

File diff suppressed because it is too large Load Diff

34
docker-compose.yml Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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`;
}

View 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>;
}

View 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} />;
}

View 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>;
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}
}

View 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();
});
});

View File

@@ -0,0 +1,2 @@
import "@testing-library/jest-dom/vitest";

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View 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
View 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
View 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