diff --git a/backend documentation.md b/backend documentation.md new file mode 100644 index 0000000..4520e57 --- /dev/null +++ b/backend documentation.md @@ -0,0 +1,519 @@ +# 🏗 Complete Backend Architecture + +### Project Layout +``` +backend/ +├── main.py ← Entry point (runs app/main.py) +└── app/ + ├── main.py ← FastAPI app, lifespan, CORS, router registration + ├── config.py ← Pydantic settings from .env + ├── database.py ← AsyncEngine, AsyncSessionLocal, get_db() + ├── logging_config.py ← Logging setup + ├── models/ ← SQLAlchemy ORM tables + ├── schemas/ ← Pydantic request/response shapes + ├── routers/ ← FastAPI route handlers + └── services/ ← Business logic, LLM, Google APIs, WebSocket +``` + +--- + +## 🔄 Feature-by-Feature Flow: Flutter → Backend + +--- + +### 1️⃣ Auth Flow (FR-01) + +``` +Flutter (google_sign_in) + │ + ├─ Gets: id_token, access_token, refresh_token from Google + │ + ▼ +POST /auth/google + Body: ?id_token=...&access_token=...&refresh_token=... + │ + ├─ Backend verifies id_token with Google's public key + ├─ Finds or creates User in DB (stores tokens) + ├─ Issues own JWT (24h expiry) + │ + ▼ +Response → Flutter stores JWT in secure storage + │ + └─ All subsequent requests: Authorization: Bearer +``` + +**Request (query params):** +``` +POST /auth/google?id_token=eyJ...&access_token=ya29...&refresh_token=1//0g... +``` + +**Response JSON:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "tanisha@college.edu", + "display_name": "Tanisha Ray", + "avatar_url": "https://lh3.googleusercontent.com/...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T10:00:00Z" + } +} +``` + +**Switch Focus Mode:** +``` +PATCH /auth/me/focus-mode +Authorization: Bearer +Body: { "focus_mode": "clubs" } +``` + +--- + +### 2️⃣ Suggestion Dashboard Flow (FR-03, FR-04, FR-14, FR-15) + +``` +Flutter opens app + │ + ├─ GET /user/{user_id}/goals ← load career goals + ├─ GET /suggestions/{user_id}?mode=acads ← main dashboard call + └─ GET /suggestions/{user_id}/heatmap ← 90-day grid +``` + +**GET /suggestions/{user_id}?mode=acads** + +Backend flow: +``` +1. Fetch user's career goals from DB +2. Fetch upcoming 48h calendar events (user + global) +3. Count assignments in next 7 days → workload score +4. Assemble prompt → call Gemini Flash +5. Parse LLM JSON → Pydantic validate +6. Cache in suggestion_history table +7. Return response +``` + +**Response JSON:** +```json +{ + "mode": "acads", + "suggestions": [ + { + "title": "Complete OS Assignment 3", + "description": "Due tomorrow at 11:59 PM. High priority based on deadline urgency.", + "priority": 0.95, + "source": "deadline" + }, + { + "title": "Revise DBMS notes", + "description": "Mid-sem exam in 3 days — focus on normalization and indexing.", + "priority": 0.78, + "source": "habit" + }, + { + "title": "Work on ML project", + "description": "Aligned with your career goal: ML researcher. Spend 1 hour today.", + "priority": 0.65, + "source": "goal" + } + ], + "quote": "Every expert was once a beginner. Your future ML career starts with today's focus.", + "workload_score": 0.82 +} +``` + +**PUT /user/{user_id}/goals** — set career goals: +```json +[ + { "title": "Crack placement at Google", "category": "career" }, + { "title": "Become an ML researcher", "category": "career" } +] +``` + +**GET /suggestions/{user_id}/heatmap** — 90-day data: +```json +{ + "user_id": "550e8400-...", + "entries": [ + { "date": "2026-02-28", "focus_minutes": 75, "mode": "acads" }, + { "date": "2026-02-27", "focus_minutes": 50, "mode": "clubs" }, + { "date": "2026-02-26", "focus_minutes": 0, "mode": "acads" } + ] +} +``` + +**PATCH /suggestions/history/{id}** — mark as done/dismissed: +```json +{ "is_completed": true, "is_dismissed": false } +``` + +--- + +### 3️⃣ Calendar Flow (FR-05 → FR-08, FR-16, FR-25, FR-26) + +``` +Flutter Calendar Screen + │ + ├─ GET /calendar/{user_id}?start_date=2026-03-01&end_date=2026-03-31 + │ ← Returns merged: timetable + holidays + assignments + club events + │ + ├─ GET /timetable/{user_id} ← Load recurring weekly slots + ├─ POST /timetable ← Add a new slot + ├─ DELETE /timetable/{slot_id} ← Remove a slot + │ + ├─ POST /calendar/sync/classroom ← Pull Classroom deadlines + └─ POST /calendar/sync/gmail ← Parse Gmail club events via LLM +``` + +**POST /timetable — Request:** +```json +{ + "day_of_week": 1, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Operating Systems", + "location": "Room 301" +} +``` + +**GET /calendar/{user_id} — Response:** +```json +[ + { + "id": "abc123...", + "user_id": "550e84...", + "event_type": "assignment", + "title": "OS Assignment 3", + "description": "Submit on Classroom", + "event_date": "2026-03-01", + "event_time": "23:59:00", + "end_date": null, + "location": null, + "source": "classroom", + "moderation_status": "approved", + "created_at": "2026-02-28T10:00:00Z" + }, + { + "id": "def456...", + "user_id": null, + "event_type": "institute_holiday", + "title": "Holi", + "description": null, + "event_date": "2026-03-14", + "event_time": null, + "end_date": null, + "location": null, + "source": "admin", + "moderation_status": "approved", + "created_at": "2026-02-20T08:00:00Z" + }, + { + "id": "ghi789...", + "user_id": null, + "event_type": "club_event", + "title": "Robotics Club Workshop", + "event_date": "2026-03-05", + "event_time": "14:00:00", + "location": "Lab 2", + "source": "gmail", + "moderation_status": "approved", + "created_at": "2026-02-28T09:00:00Z" + } +] +``` + +**Admin CSV Upload — POST /admin/institute-calendar:** +``` +CSV format (multipart/form-data): +date,title,type,description +2026-03-14,Holi,institute_holiday,National holiday +2026-04-10,TechFest,fest,Annual college tech festival +``` +Response: `{ "inserted": 2 }` + +**Admin Event Panel — POST /admin/events:** +```json +{ + "title": "Robotics Club Workshop", + "description": "Hands-on session on ROS2", + "event_date": "2026-03-05", + "event_time": "14:00:00", + "location": "Lab 2" +} +``` +Response: Same as `CalendarEventOut` but `moderation_status: "pending"` + +**Admin Approve — PATCH /admin/events/{event_id}/approve:** +No body needed. Sets `moderation_status → "approved"`. + +--- + +### 4️⃣ Usage Tracker Flow (FR-09, FR-10, FR-18, FR-19) + +``` +Flutter WorkManager (every 30 min) + │ + ├─ Kotlin UsageStatsPlugin reads UsageStatsManager + ├─ Dart platform channel returns [{package_name, duration_ms, date}] + ├─ POST /usage ← batch upload + │ +Flutter Usage Screen + ├─ GET /usage/{user_id}/rolling-average?period_days=7 + └─ GET /usage/{user_id}/should-nudge ← check if notification needed +``` + +**POST /usage — Request:** +```json +{ + "entries": [ + { "package_name": "com.google.android.youtube", "duration_ms": 3600000, "date": "2026-02-28" }, + { "package_name": "com.google.android.apps.docs", "duration_ms": 1800000, "date": "2026-02-28" }, + { "package_name": "com.instagram.android", "duration_ms": 5400000, "date": "2026-02-28" } + ] +} +``` +Backend maps packages to categories via `app_category_mappings` table. +Response: `{ "inserted": 3 }` + +**GET /usage/{user_id}/rolling-average — Response:** +```json +{ + "user_id": "550e8400-...", + "period_days": 7, + "stats": [ + { + "category": "productive", + "user_avg_ms": 5400000, + "institute_avg_ms": 7200000, + "user_percentile": 0.38 + }, + { + "category": "distraction", + "user_avg_ms": 9000000, + "institute_avg_ms": 6000000, + "user_percentile": 0.72 + }, + { + "category": "neutral", + "user_avg_ms": 3600000, + "institute_avg_ms": 3600000, + "user_percentile": 0.50 + } + ] +} +``` + +**GET /usage/{user_id}/should-nudge — Response:** +```json +{ "should_nudge": true, "percentile": 0.38 } +``` +Flutter fires a **local push notification** if `should_nudge == true`. + +--- + +### 5️⃣ Group Pomodoro Flow (FR-11 → FR-13, FR-20 → FR-23) + +``` +User A creates session: + POST /pomodoro/sessions → gets invite_code: "FOCUS8XY" + +User B joins: + POST /pomodoro/sessions/join body: { "invite_code": "FOCUS8XY" } + +Both connect to WebSocket: + WS /ws/pomodoro/{session_id}?token= + +User A starts: + Client → server: { "type": "start" } + +Server runs timer loop: + Server → all: tick every second + +Session ends: + Server → all: session_end with XP results + Server awards badges via _evaluate_badges() + +Get leaderboard: + GET /pomodoro/sessions/{id}/leaderboard + +Get badges: + GET /pomodoro/badges/{user_id} +``` + +**POST /pomodoro/sessions — Request:** +```json +{ + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4 +} +``` + +**Response:** +```json +{ + "id": "aaaa-bbbb-...", + "invite_code": "FOCUS8XY", + "created_by": "550e8400-...", + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4, + "status": "waiting", + "current_interval": 0, + "started_at": null, + "ended_at": null, + "created_at": "2026-02-28T11:00:00Z", + "members": [ + { + "id": "...", + "user_id": "550e...", + "is_active": true, + "is_paused": false, + "focus_seconds": 0, + "xp_earned": 0, + "joined_at": "2026-02-28T11:00:00Z" + } + ] +} +``` + +**WebSocket Messages (Client → Server):** +```json +{ "type": "heartbeat" } +{ "type": "start" } +{ "type": "pause" } +{ "type": "resume" } +``` + +**WebSocket Messages (Server → Client):** +```json +// Every second during focus/break +{ "type": "tick", "data": { "status": "focus", "interval": 1, "remaining_seconds": 1487 } } + +// Phase change +{ "type": "state_change", "data": { "status": "break", "interval": 1 } } + +// Member connect/disconnect +{ "type": "member_update", "data": { "user_id": "abc...", "action": "connected", "member_count": 2 } } + +// Private nudge (only sent to the stale user) +{ "type": "nudge", "data": { "message": "You seem to have lost focus. Stay strong!" } } + +// Session complete — broadcast to all +{ + "type": "session_end", + "data": { + "members": [ + { "user_id": "abc...", "xp_earned": 400, "focus_seconds": 6000 }, + { "user_id": "def...", "xp_earned": 300, "focus_seconds": 4500 } + ] + } +} +``` + +**GET /pomodoro/sessions/{id}/leaderboard — Response:** +```json +{ + "session_id": "aaaa-bbbb-...", + "entries": [ + { "user_id": "abc...", "display_name": "Yug Dalwadi", "total_xp": 1200, "rank": 1 }, + { "user_id": "def...", "display_name": "Tanisha Ray", "total_xp": 900, "rank": 2 } + ] +} +``` + +**GET /pomodoro/badges/{user_id} — Response:** +```json +[ + { "id": "...", "user_id": "...", "badge_type": "streak_7", "awarded_at": "2026-02-28T..." }, + { "id": "...", "user_id": "...", "badge_type": "top_focus", "awarded_at": "2026-02-28T..." } +] +``` +Badge types: `"streak_7"` | `"streak_30"` | `"top_focus"` + +--- + +## 📋 All Schemas at a Glance + +| Schema | File | Fields | +|--------|------|--------| +| `TokenResponse` | user.py | `access_token`, `token_type`, `user: UserOut` | +| `UserOut` | user.py | `id`, `email`, `display_name`, `avatar_url`, `focus_mode`, `role`, `created_at` | +| `UserFocusModeUpdate` | user.py | `focus_mode` | +| `CareerGoalCreate` | career_goal.py | `title`, `category` | +| `CareerGoalOut` | career_goal.py | `id`, `user_id`, `title`, `category` | +| `TimetableSlotCreate` | calendar.py | `day_of_week`, `start_time`, `end_time`, `title`, `location` | +| `TimetableSlotOut` | calendar.py | + `id`, `user_id`, `created_at` | +| `CalendarEventCreate` | calendar.py | `event_type`, `title`, `description`, `event_date`, `event_time`, `end_date`, `location`, `source`, `source_id`, `metadata_json` | +| `CalendarEventOut` | calendar.py | + `id`, `user_id`, `moderation_status`, `created_at` | +| `AdminEventCreate` | calendar.py | `title`, `description`, `event_date`, `event_time`, `end_date`, `location` | +| `SuggestionItem` | suggestion.py | `title`, `description`, `priority`, `source` | +| `SuggestionResponse` | suggestion.py | `mode`, `suggestions[]`, `quote`, `workload_score` | +| `SuggestionHistoryOut` | suggestion.py | `id`, `mode`, `suggestions_json`, `quote`, `is_completed`, `is_dismissed`, `created_at` | +| `SuggestionStatusUpdate` | suggestion.py | `is_completed`, `is_dismissed` | +| `HeatmapEntry` | suggestion.py | `date`, `focus_minutes`, `mode` | +| `HeatmapResponse` | suggestion.py | `user_id`, `entries[]` | +| `AppUsageEntry` | usage.py | `package_name`, `duration_ms`, `date` | +| `AppUsageBatchCreate` | usage.py | `entries[]` | +| `AppUsageStats` | usage.py | `category`, `user_avg_ms`, `institute_avg_ms`, `user_percentile` | +| `RollingAverageResponse` | usage.py | `user_id`, `period_days`, `stats[]` | +| `PomodoroSessionCreate` | pomodoro.py | `focus_duration_min`, `break_duration_min`, `total_intervals` | +| `PomodoroSessionOut` | pomodoro.py | `id`, `invite_code`, `created_by`, timings, `status`, `current_interval`, `members[]` | +| `SessionJoin` | pomodoro.py | `invite_code` | +| `SessionMemberOut` | pomodoro.py | `id`, `user_id`, `is_active`, `is_paused`, `focus_seconds`, `xp_earned`, `joined_at` | +| `LeaderboardEntry` | pomodoro.py | `user_id`, `display_name`, `total_xp`, `rank` | +| `LeaderboardResponse` | pomodoro.py | `session_id`, `entries[]` | +| `BadgeOut` | pomodoro.py | `id`, `user_id`, `badge_type`, `awarded_at` | +| `WsMessage` | pomodoro.py | `type`, `data` | + +--- + +## 📡 Complete Endpoint Reference + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `GET` | `/health` | ❌ | Health check | +| `POST` | `/auth/google` | ❌ | Google login → JWT | +| `GET` | `/auth/me` | ✅ | Current user profile | +| `PATCH` | `/auth/me/focus-mode` | ✅ | Switch Acads/Clubs mode | +| `GET` | `/user/{id}/goals` | ✅ | Get career goals | +| `PUT` | `/user/{id}/goals` | ✅ | Set/replace career goals | +| `GET` | `/suggestions/{id}?mode=` | ✅ | LLM suggestions + quote | +| `GET` | `/suggestions/{id}/history` | ✅ | Past suggestions | +| `PATCH` | `/suggestions/history/{id}` | ✅ | Mark completed/dismissed | +| `GET` | `/suggestions/{id}/heatmap` | ✅ | 90-day focus heatmap | +| `GET` | `/timetable/{id}` | ✅ | Get weekly timetable | +| `POST` | `/timetable` | ✅ | Add timetable slot | +| `DELETE` | `/timetable/{slot_id}` | ✅ | Remove slot | +| `GET` | `/calendar/{id}` | ✅ | Unified calendar (merged) | +| `POST` | `/calendar/events` | ✅ | Create manual event | +| `POST` | `/calendar/sync/classroom` | ✅ | Sync Google Classroom | +| `POST` | `/calendar/sync/gmail` | ✅ | Parse Gmail club events | +| `POST` | `/admin/institute-calendar` | 🔐 Admin | Upload CSV | +| `POST` | `/admin/events` | 🔐 Admin | Post club event (pending) | +| `GET` | `/admin/events` | 🔐 Admin | List all admin events | +| `PATCH` | `/admin/events/{id}/approve` | 🔐 Admin | Approve event | +| `DELETE` | `/admin/events/{id}` | 🔐 Admin | Delete event | +| `POST` | `/usage` | ✅ | Batch upload usage data | +| `GET` | `/usage/{id}/rolling-average` | ✅ | 7-day avg + percentile | +| `GET` | `/usage/{id}/should-nudge` | ✅ | Check if nudge needed | +| `POST` | `/pomodoro/sessions` | ✅ | Create session | +| `POST` | `/pomodoro/sessions/join` | ✅ | Join via invite code | +| `GET` | `/pomodoro/sessions/{id}` | ✅ | Get session details | +| `GET` | `/pomodoro/sessions/{id}/leaderboard` | ✅ | Weekly XP leaderboard | +| `GET` | `/pomodoro/badges/{user_id}` | ✅ | User's earned badges | +| `WS` | `/ws/pomodoro/{session_id}?token=` | ✅ | Real-time timer sync | + +--- + +## ⚠️ Key Things to Note for Flutter Integration + +1. **JWT goes in every request header:** `Authorization: Bearer ` +2. **WebSocket auth** is via query param: `?token=` (headers not supported in Flutter's WebSocket) +3. **WorkManager** should call `POST /usage` + `GET /usage/{id}/should-nudge` every 30 min, fire local notification if `should_nudge == true` +4. **Classroom & Gmail sync** — store the Google `access_token` + `refresh_token` at login; backend uses them server-side to call Google APIs +5. **Offline cache** — Flutter should cache `GET /calendar` and `GET /timetable` results in `sqflite` for offline access (FR-17) +6. **Admin role** — set `user.role = "admin"` in DB for club coordinators; the `require_admin` dependency enforces it on admin endpoints diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..377491e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# ── Google OAuth ── +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8000/auth/callback + +# ── JWT ── +JWT_SECRET_KEY=change-me-to-a-random-secret +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=1440 + +# ── Database ── +DATABASE_URL=postgresql+asyncpg://focusforge:focusforge@localhost:5432/focusforge + +# ── LLM ── +GEMINI_API_KEY=your-gemini-api-key + +# ── App ── +APP_ENV=development diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8228be3 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +.env +venv/ +venv +.venv/ +test.txt +*.txt +*.csv \ No newline at end of file diff --git a/backend/API_REFERENCE.md b/backend/API_REFERENCE.md new file mode 100644 index 0000000..093f7b5 --- /dev/null +++ b/backend/API_REFERENCE.md @@ -0,0 +1,1211 @@ +# FocusForge API Reference + +> **Base URL:** `http://localhost:8000` +> +> **Auth:** Most endpoints require a JWT Bearer token in the `Authorization` header. +> Obtain one via `/auth/google` or `/auth/dev-login`. + +--- + +## Table of Contents + +- [Health](#health) +- [Authentication](#authentication) +- [Timetable](#timetable) +- [Calendar Events](#calendar-events) +- [Admin — Institute Calendar](#admin--institute-calendar) +- [Admin — Event Panel](#admin--event-panel) +- [Google Sync](#google-sync) +- [Career Goals](#career-goals) +- [Suggestions](#suggestions) +- [Suggestion History](#suggestion-history) +- [Focus Heatmap](#focus-heatmap) +- [App Usage](#app-usage) +- [Group Pomodoro](#group-pomodoro) +- [Pomodoro Leaderboard](#pomodoro-leaderboard) +- [Badges](#badges) +- [WebSocket — Pomodoro Timer](#websocket--pomodoro-timer) +- [Schemas Reference](#schemas-reference) + +--- + +## Health + +### `GET /health` + +Health check endpoint. No authentication required. + +**Response:** + +```json +{ + "status": "ok", + "service": "focusforge-api" +} +``` + +--- + +## Authentication + +### `POST /auth/google` + +Authenticate with a Google OAuth ID token. Returns a JWT for subsequent requests. + +**Query Parameters:** + +| Param | Type | Required | Description | +|-----------------|----------|----------|-------------------------------------| +| `id_token_str` | `string` | Yes | Google ID token from Flutter client | +| `access_token` | `string` | No | Google OAuth access token | +| `refresh_token` | `string` | No | Google OAuth refresh token | + +**Response:** [`TokenResponse`](#tokenresponse) + +```json +{ + "access_token": "eyJhbGciOi...", + "token_type": "bearer", + "user": { + "id": "uuid", + "email": "user@example.com", + "display_name": "John Doe", + "avatar_url": "https://...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T12:00:00" + } +} +``` + +--- + +### `POST /auth/dev-login` + +**DEV ONLY** — Bypass Google OAuth for testing. Only available when `APP_ENV=development` or `APP_ENV=testing`. + +**Query Parameters:** + +| Param | Type | Default | Description | +|----------------|----------|--------------------------|--------------------| +| `email` | `string` | `testuser@example.com` | User email | +| `display_name` | `string` | `Test User` | Display name | +| `role` | `string` | `student` | `student` or `admin` | + +**Response:** [`TokenResponse`](#tokenresponse) + +--- + +### `GET /auth/me` + +Get authenticated user's profile. + +**Headers:** `Authorization: Bearer ` + +**Response:** [`UserOut`](#userout) + +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "John Doe", + "avatar_url": "https://...", + "focus_mode": "acads", + "role": "student", + "created_at": "2026-02-28T12:00:00" +} +``` + +--- + +### `PATCH /auth/me/focus-mode` + +Switch between **Acads Mode** and **Clubs & Projects Mode**. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`UserFocusModeUpdate`](#userfocusmodeupdate) + +```json +{ + "focus_mode": "acads" +} +``` + +| Field | Type | Allowed Values | +|--------------|----------|----------------------| +| `focus_mode` | `string` | `"acads"`, `"clubs"` | + +**Response:** [`UserOut`](#userout) + +--- + +## Timetable + +### `GET /timetable/{user_id}` + +Get all timetable slots for a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`TimetableSlotOut`](#timetableslotout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "day_of_week": 0, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Data Structures", + "location": "Room 301", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `POST /timetable` + +Create a new timetable slot. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`TimetableSlotCreate`](#timetableslotcreate) + +```json +{ + "day_of_week": 0, + "start_time": "09:00:00", + "end_time": "10:00:00", + "title": "Data Structures", + "location": "Room 301" +} +``` + +**Response:** `201` [`TimetableSlotOut`](#timetableslotout) + +--- + +### `DELETE /timetable/{slot_id}` + +Delete a timetable slot. Only the owner can delete. + +**Path Parameters:** `slot_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `204 No Content` + +--- + +## Calendar Events + +### `GET /calendar/{user_id}` + +Unified calendar view — returns user-specific events + global events (holidays, fests). + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Required | Description | +|--------------|----------|----------|--------------------------------------------------------------------| +| `start_date` | `date` | No | Filter events on or after this date (`YYYY-MM-DD`) | +| `end_date` | `date` | No | Filter events on or before this date | +| `event_type` | `string` | No | Filter by type: `assignment`, `institute_holiday`, `fest`, `club_event`, `gmail_event` | + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`CalendarEventOut`](#calendareventout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "event_type": "assignment", + "title": "ML Assignment 3", + "description": "Submit on Moodle", + "event_date": "2026-03-05", + "event_time": "23:59:00", + "end_date": null, + "location": null, + "source": "classroom", + "moderation_status": "approved", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `POST /calendar/events` + +Create a personal calendar event. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`CalendarEventCreate`](#calendareventcreate) + +```json +{ + "event_type": "club_event", + "title": "Hackathon Prep", + "description": "Team meeting", + "event_date": "2026-03-10", + "event_time": "18:00:00", + "end_date": null, + "location": "Lab 5", + "source": null, + "source_id": null, + "metadata_json": null +} +``` + +**Response:** `201` [`CalendarEventOut`](#calendareventout) + +--- + +## Admin — Institute Calendar + +### `POST /admin/institute-calendar` + +Upload a CSV of institute holidays/fests. **Admin only.** + +**Headers:** `Authorization: Bearer ` (admin role) + +**Body:** `multipart/form-data` with a `.csv` file. + +**CSV Columns:** + +| Column | Required | Description | +|---------------|----------|------------------------------------------| +| `date` | Yes | Event date (`YYYY-MM-DD`) | +| `title` | Yes | Event title | +| `type` | No | `institute_holiday` or `fest` (default: `institute_holiday`) | +| `description` | No | Optional description | + +**Response:** `201` + +```json +{ + "inserted": 15 +} +``` + +--- + +## Admin — Event Panel + +### `POST /admin/events` + +Club secretary/coordinator posts a club event. Starts as `pending` moderation. **Admin only.** + +**Headers:** `Authorization: Bearer ` (admin role) + +**Request Body:** [`AdminEventCreate`](#admineventcreate) + +```json +{ + "title": "Robotics Workshop", + "description": "Intro to Arduino", + "event_date": "2026-03-15", + "event_time": "14:00:00", + "end_date": "2026-03-15", + "location": "Auditorium" +} +``` + +**Response:** `201` [`CalendarEventOut`](#calendareventout) + +--- + +### `GET /admin/events` + +List admin-created events with optional moderation filter. **Admin only.** + +**Query Parameters:** + +| Param | Type | Required | Description | +|---------------------|----------|----------|---------------------------------------| +| `moderation_status` | `string` | No | `pending`, `approved`, or `rejected` | + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** `list[`[`CalendarEventOut`](#calendareventout)`]` + +--- + +### `PATCH /admin/events/{event_id}/approve` + +Approve a pending event. **Admin only.** + +**Path Parameters:** `event_id` (UUID) + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** [`CalendarEventOut`](#calendareventout) + +--- + +### `DELETE /admin/events/{event_id}` + +Delete an admin event. **Admin only.** + +**Path Parameters:** `event_id` (UUID) + +**Headers:** `Authorization: Bearer ` (admin role) + +**Response:** `204 No Content` + +--- + +## Google Sync + +### `POST /calendar/sync/classroom` + +Pull assignment deadlines from Google Classroom. Requires Google OAuth tokens. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "synced_assignments": 12 +} +``` + +--- + +### `POST /calendar/sync/gmail` + +Parse Gmail for club activity emails and extract events via LLM. Requires Google OAuth tokens. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "parsed_events": 5 +} +``` + +--- + +## Career Goals + +### `GET /user/{user_id}/goals` + +Get all career goals for a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`CareerGoalOut`](#careergoalout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "title": "GATE CS 2027", + "description": "Target AIR < 100", + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `PUT /user/{user_id}/goals` + +Replace all career goals for a user (deletes existing, inserts new). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Request Body:** `list[`[`CareerGoalCreate`](#careergoalcreate)`]` + +```json +[ + { + "title": "GATE CS 2027", + "description": "Target AIR < 100" + }, + { + "title": "GSoC 2026", + "description": null + } +] +``` + +**Response:** `list[`[`CareerGoalOut`](#careergoalout)`]` + +--- + +## Suggestions + +### `GET /suggestions/{user_id}` + +LLM-scored suggestions based on focus mode, career goals, upcoming calendar events, and workload. + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Default | Description | +|--------|----------|----------|-------------------------------| +| `mode` | `string` | `acads` | `"acads"` or `"clubs"` | + +**Headers:** `Authorization: Bearer ` + +**Response:** [`SuggestionResponse`](#suggestionresponse) + +```json +{ + "mode": "acads", + "suggestions": [ + { + "title": "Revise DBMS Normalization", + "description": "Mid-sem in 3 days — cover 3NF and BCNF", + "priority": 0.95, + "source": "deadline" + }, + { + "title": "LeetCode — Graph problems", + "description": "Aligned with GATE CS goal. Try BFS/DFS set.", + "priority": 0.8, + "source": "goal" + } + ], + "quote": "Focus is the art of knowing what to ignore.", + "workload_score": 7.2 +} +``` + +--- + +## Suggestion History + +### `GET /suggestions/{user_id}/history` + +Get past suggestion batches (most recent 50). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`SuggestionHistoryOut`](#suggestionhistoryout)`]` + +```json +[ + { + "id": "uuid", + "mode": "acads", + "suggestions_json": [ + { + "title": "Revise DBMS", + "description": "...", + "priority": 0.95, + "source": "deadline" + } + ], + "quote": "Focus is the art of knowing what to ignore.", + "is_completed": false, + "is_dismissed": false, + "created_at": "2026-02-28T12:00:00" + } +] +``` + +--- + +### `PATCH /suggestions/history/{suggestion_id}` + +Update completion/dismissal status of a suggestion. + +**Path Parameters:** `suggestion_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`SuggestionStatusUpdate`](#suggestionstatusupdate) + +```json +{ + "is_completed": true, + "is_dismissed": false +} +``` + +**Response:** [`SuggestionHistoryOut`](#suggestionhistoryout) + +--- + +## Focus Heatmap + +### `GET /focus-log/{user_id}/heatmap` + +90-day GitHub-style focus heatmap — daily Pomodoro + active-session minutes. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`HeatmapResponse`](#heatmapresponse) + +```json +{ + "user_id": "uuid", + "entries": [ + { + "date": "2026-02-28", + "focus_minutes": 120, + "mode": "acads" + }, + { + "date": "2026-02-27", + "focus_minutes": 45, + "mode": "clubs" + } + ] +} +``` + +--- + +## App Usage + +### `POST /usage` + +Client posts app usage data (called every 30 minutes by the Flutter client). + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`AppUsageBatchCreate`](#appusagebatchcreate) + +```json +{ + "entries": [ + { + "package_name": "com.leetcode", + "duration_ms": 1800000, + "date": "2026-02-28" + }, + { + "package_name": "com.instagram.android", + "duration_ms": 600000, + "date": "2026-02-28" + } + ] +} +``` + +**Response:** `201` + +```json +{ + "inserted": 2 +} +``` + +--- + +### `GET /usage/{user_id}/rolling-average` + +Rolling average per category vs institute average, plus percentile rank. + +**Path Parameters:** `user_id` (UUID) + +**Query Parameters:** + +| Param | Type | Default | Description | +|---------------|-------|---------|--------------------------| +| `period_days` | `int` | `7` | Rolling window in days | + +**Headers:** `Authorization: Bearer ` + +**Response:** [`RollingAverageResponse`](#rollingaverageresponse) + +```json +{ + "user_id": "uuid", + "period_days": 7, + "stats": [ + { + "category": "productive", + "user_avg_ms": 3600000.0, + "institute_avg_ms": 2400000.0, + "user_percentile": 0.75 + }, + { + "category": "distracting", + "user_avg_ms": 900000.0, + "institute_avg_ms": 1500000.0, + "user_percentile": 0.30 + } + ] +} +``` + +--- + +### `GET /usage/{user_id}/should-nudge` + +Check if user's productive usage is below the 50th percentile (triggers push notification). + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +{ + "should_nudge": true, + "percentile": 35.2 +} +``` + +--- + +### `GET /usage/categories` + +List all known app-to-category mappings. + +**Headers:** `Authorization: Bearer ` + +**Response:** + +```json +[ + { + "package_name": "com.leetcode", + "category": "productive", + "display_name": "LeetCode" + }, + { + "package_name": "com.instagram.android", + "category": "distracting", + "display_name": "Instagram" + } +] +``` + +--- + +## Group Pomodoro + +### `POST /pomodoro/sessions` + +Create a new group Pomodoro session. The creator auto-joins. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`PomodoroSessionCreate`](#pomodorosessioncreate) + +```json +{ + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4 +} +``` + +**Response:** `201` [`PomodoroSessionOut`](#pomodorosessionout) + +```json +{ + "id": "uuid", + "invite_code": "A1B2C3D4", + "created_by": "uuid", + "focus_duration_min": 25, + "break_duration_min": 5, + "total_intervals": 4, + "status": "waiting", + "current_interval": 0, + "started_at": null, + "ended_at": null, + "created_at": "2026-02-28T12:00:00", + "members": [ + { + "id": "uuid", + "user_id": "uuid", + "is_active": true, + "is_paused": false, + "focus_seconds": 0, + "xp_earned": 0, + "joined_at": "2026-02-28T12:00:00" + } + ] +} +``` + +--- + +### `POST /pomodoro/sessions/join` + +Join an existing session via invite code. Max 8 members. Session must be in `waiting` status. + +**Headers:** `Authorization: Bearer ` + +**Request Body:** [`SessionJoin`](#sessionjoin) + +```json +{ + "invite_code": "A1B2C3D4" +} +``` + +**Response:** [`PomodoroSessionOut`](#pomodorosessionout) + +--- + +### `GET /pomodoro/sessions/{session_id}` + +Get session details including all members. + +**Path Parameters:** `session_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`PomodoroSessionOut`](#pomodorosessionout) + +--- + +## Pomodoro Leaderboard + +### `GET /pomodoro/sessions/{session_id}/leaderboard` + +Weekly XP leaderboard for a session group. + +**Path Parameters:** `session_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** [`LeaderboardResponse`](#leaderboardresponse) + +```json +{ + "session_id": "uuid", + "entries": [ + { + "user_id": "uuid", + "display_name": "Alice", + "total_xp": 400, + "rank": 1 + }, + { + "user_id": "uuid", + "display_name": "Bob", + "total_xp": 300, + "rank": 2 + } + ] +} +``` + +--- + +## Badges + +### `GET /pomodoro/badges/{user_id}` + +Get all badges earned by a user. + +**Path Parameters:** `user_id` (UUID) + +**Headers:** `Authorization: Bearer ` + +**Response:** `list[`[`BadgeOut`](#badgeout)`]` + +```json +[ + { + "id": "uuid", + "user_id": "uuid", + "badge_type": "streak_7", + "awarded_at": "2026-02-28T12:00:00" + } +] +``` + +**Badge Types:** `top_focus`, `streak_7`, `streak_30` + +--- + +## WebSocket — Pomodoro Timer + +### `WS /ws/pomodoro/{session_id}?token=` + +Real-time timer sync for group Pomodoro sessions. + +**Connection:** `ws://localhost:8000/ws/pomodoro/{session_id}?token=` + +#### Client → Server Messages + +| Type | Description | Payload | +|-------------|--------------------------------|--------------------------| +| `start` | Start the session (creator only) | `{"type": "start"}` | +| `heartbeat` | Prove user is still focused | `{"type": "heartbeat"}` | +| `pause` | Voluntary pause (private) | `{"type": "pause"}` | +| `resume` | Resume after pause | `{"type": "resume"}` | + +#### Server → Client Messages + +| Type | Description | Example Data | +|-----------------|---------------------------------|--------------------------------------------------------------------------| +| `state_change` | Session phase changed | `{"status": "focus", "interval": 1}` | +| `tick` | Timer tick (every second) | `{"status": "focus", "interval": 1, "remaining_seconds": 1499}` | +| `member_update` | Member connected/disconnected | `{"user_id": "...", "action": "connected", "member_count": 3}` | +| `nudge` | Focus nudge (private to user) | `{"message": "You seem to have lost focus. Stay strong!"}` | +| `session_end` | Session completed | `{"members": [{"user_id": "...", "xp_earned": 400, "focus_seconds": 6000}]}` | + +--- + +## Schemas Reference + +### UserOut + +```json +{ + "id": "uuid", + "email": "string", + "display_name": "string", + "avatar_url": "string | null", + "focus_mode": "string", + "role": "string", + "created_at": "datetime" +} +``` + +### UserFocusModeUpdate + +```json +{ + "focus_mode": "string" // "acads" | "clubs" +} +``` + +### TokenResponse + +```json +{ + "access_token": "string", + "token_type": "bearer", + "user": "UserOut" +} +``` + +--- + +### TimetableSlotCreate + +```json +{ + "day_of_week": "int", // 0=Monday .. 6=Sunday + "start_time": "time", // "HH:MM:SS" + "end_time": "time", // "HH:MM:SS" + "title": "string", + "location": "string | null" +} +``` + +### TimetableSlotOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "day_of_week": "int", + "start_time": "time", + "end_time": "time", + "title": "string", + "location": "string | null", + "created_at": "datetime" +} +``` + +--- + +### CalendarEventCreate + +```json +{ + "event_type": "string", // "assignment" | "institute_holiday" | "fest" | "club_event" + "title": "string", + "description": "string | null", + "event_date": "date", // "YYYY-MM-DD" + "event_time": "time | null", // "HH:MM:SS" + "end_date": "date | null", + "location": "string | null", + "source": "string | null", + "source_id": "string | null", + "metadata_json": "dict | null" +} +``` + +### CalendarEventOut + +```json +{ + "id": "uuid", + "user_id": "uuid | null", + "event_type": "string", + "title": "string", + "description": "string | null", + "event_date": "date", + "event_time": "time | null", + "end_date": "date | null", + "location": "string | null", + "source": "string | null", + "moderation_status": "string", + "created_at": "datetime" +} +``` + +### AdminEventCreate + +```json +{ + "title": "string", + "description": "string | null", + "event_date": "date", + "event_time": "time | null", + "end_date": "date | null", + "location": "string | null" +} +``` + +--- + +### CareerGoalCreate + +```json +{ + "title": "string", + "description": "string | null" +} +``` + +### CareerGoalOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "title": "string", + "description": "string | null", + "created_at": "datetime" +} +``` + +--- + +### SuggestionItem + +```json +{ + "title": "string", + "description": "string", + "priority": "float", // 0.0 — 1.0 + "source": "string" // "deadline" | "habit" | "goal" +} +``` + +### SuggestionResponse + +```json +{ + "mode": "string", + "suggestions": ["SuggestionItem"], + "quote": "string", + "workload_score": "float" // 0.0 — 10.0 +} +``` + +### SuggestionHistoryOut + +```json +{ + "id": "uuid", + "mode": "string", + "suggestions_json": "list | dict", + "quote": "string | null", + "is_completed": "bool", + "is_dismissed": "bool", + "created_at": "datetime" +} +``` + +### SuggestionStatusUpdate + +```json +{ + "is_completed": "bool | null", + "is_dismissed": "bool | null" +} +``` + +--- + +### HeatmapEntry + +```json +{ + "date": "string", // ISO date "YYYY-MM-DD" + "focus_minutes": "int", + "mode": "string" +} +``` + +### HeatmapResponse + +```json +{ + "user_id": "uuid", + "entries": ["HeatmapEntry"] +} +``` + +--- + +### AppUsageEntry + +```json +{ + "package_name": "string", + "duration_ms": "int", + "date": "date" // "YYYY-MM-DD" +} +``` + +### AppUsageBatchCreate + +```json +{ + "entries": ["AppUsageEntry"] +} +``` + +### AppUsageStats + +```json +{ + "category": "string", + "user_avg_ms": "float", + "institute_avg_ms": "float", + "user_percentile": "float | null" +} +``` + +### RollingAverageResponse + +```json +{ + "user_id": "uuid", + "period_days": "int", + "stats": ["AppUsageStats"] +} +``` + +--- + +### PomodoroSessionCreate + +```json +{ + "focus_duration_min": 25, // default 25 + "break_duration_min": 5, // default 5 + "total_intervals": 4 // default 4 +} +``` + +### PomodoroSessionOut + +```json +{ + "id": "uuid", + "invite_code": "string", + "created_by": "uuid", + "focus_duration_min": "int", + "break_duration_min": "int", + "total_intervals": "int", + "status": "string", // "waiting" | "focus" | "break" | "completed" + "current_interval": "int", + "started_at": "datetime | null", + "ended_at": "datetime | null", + "created_at": "datetime", + "members": ["SessionMemberOut"] +} +``` + +### SessionJoin + +```json +{ + "invite_code": "string" +} +``` + +### SessionMemberOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "is_active": "bool", + "is_paused": "bool", + "focus_seconds": "int", + "xp_earned": "int", + "joined_at": "datetime" +} +``` + +### LeaderboardEntry + +```json +{ + "user_id": "uuid", + "display_name": "string", + "total_xp": "int", + "rank": "int" +} +``` + +### LeaderboardResponse + +```json +{ + "session_id": "uuid", + "entries": ["LeaderboardEntry"] +} +``` + +### BadgeOut + +```json +{ + "id": "uuid", + "user_id": "uuid", + "badge_type": "string", // "top_focus" | "streak_7" | "streak_30" + "awarded_at": "datetime" +} +``` + +### WsMessage + +```json +{ + "type": "string", // "tick" | "state_change" | "member_update" | "session_end" | "nudge" + "data": "dict" +} +``` diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..271b2d1 Binary files /dev/null and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/frontend/dummy.txt b/backend/app/__init__.py similarity index 100% rename from frontend/dummy.txt rename to backend/app/__init__.py diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..93af878 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..c4f64ce Binary files /dev/null and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..0dd1728 Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/__pycache__/logging_config.cpython-312.pyc b/backend/app/__pycache__/logging_config.cpython-312.pyc new file mode 100644 index 0000000..aa5d715 Binary files /dev/null and b/backend/app/__pycache__/logging_config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..5d864e9 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e3076a6 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Google OAuth + google_client_id: str = "" + google_client_secret: str = "" + google_redirect_uri: str = "http://localhost:8000/auth/callback" + + # JWT + jwt_secret_key: str = "change-me-to-a-random-secret" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 1440 # 24 hours + + # Database + database_url: str = "postgresql+asyncpg://focusforge:focusforge@localhost:5432/focusforge" + + # LLM + gemini_api_key: str = "" + + # App + app_env: str = "development" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..6e36d3d --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +import logging + +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import DeclarativeBase +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=settings.app_env == "development", + pool_size=20, + max_overflow=10, +) + +AsyncSessionLocal = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + logger.exception("Database session error — rolling back") + await session.rollback() + raise diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..acecfea --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,33 @@ +"""Centralized logging configuration for FocusForge backend.""" + +import logging +import sys + + +LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d — %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def setup_logging(level: str = "INFO") -> None: + """ + Configure the root logger with a consistent format across all modules. + Call once at app startup (in lifespan or main.py). + """ + numeric_level = getattr(logging, level.upper(), logging.INFO) + + logging.basicConfig( + level=numeric_level, + format=LOG_FORMAT, + datefmt=LOG_DATE_FORMAT, + stream=sys.stdout, + force=True, # override any prior basicConfig + ) + + # Quieten noisy third-party loggers + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel( + logging.INFO if numeric_level <= logging.DEBUG else logging.WARNING + ) + logging.getLogger("google").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7294596 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,75 @@ +from app.routers import pomodoro as pomodoro_router +from app.routers import usage as usage_router +from app.routers import suggestions as suggestions_router +from app.routers import calendar as calendar_router +from app.routers import auth as auth_router +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import get_settings +from app.database import engine, Base +from app.logging_config import setup_logging + +logger = logging.getLogger(__name__) + +# Import all models so they're registered with Base.metadata +from app.models.user import User # noqa: F401 +from app.models.career_goal import CareerGoal # noqa: F401 +from app.models.calendar import CalendarEvent, TimetableSlot # noqa: F401 +from app.models.usage import AppUsageLog, AppCategoryMapping # noqa: F401 +from app.models.focus_log import FocusLog # noqa: F401 +from app.models.pomodoro import PomodoroSession, SessionMember, UserXP, Badge # noqa: F401 +from app.models.suggestion import SuggestionHistory # noqa: F401 + + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + log_level = "DEBUG" if settings.app_env == "development" else "INFO" + setup_logging(level=log_level) + logger.info("Starting FocusForge API (env=%s)", settings.app_env) + + # Create tables (dev convenience; use Alembic in production) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables verified / created") + + yield + + logger.info("Shutting down FocusForge API") + await engine.dispose() + logger.info("Database engine disposed") + + +app = FastAPI( + title="FocusForge API", + description="Backend for FocusForge — Attention Economy on Campus", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health_check(): + return {"status": "ok", "service": "focusforge-api"} + + +# ── Routers ── +app.include_router(auth_router.router) +app.include_router(calendar_router.router) +app.include_router(suggestions_router.router) +app.include_router(usage_router.router) +app.include_router(pomodoro_router.router) \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..596ca1b Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/calendar.cpython-312.pyc b/backend/app/models/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000..fb1fe34 Binary files /dev/null and b/backend/app/models/__pycache__/calendar.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/career_goal.cpython-312.pyc b/backend/app/models/__pycache__/career_goal.cpython-312.pyc new file mode 100644 index 0000000..d043564 Binary files /dev/null and b/backend/app/models/__pycache__/career_goal.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/focus_log.cpython-312.pyc b/backend/app/models/__pycache__/focus_log.cpython-312.pyc new file mode 100644 index 0000000..fb6a70a Binary files /dev/null and b/backend/app/models/__pycache__/focus_log.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/pomodoro.cpython-312.pyc b/backend/app/models/__pycache__/pomodoro.cpython-312.pyc new file mode 100644 index 0000000..a141aac Binary files /dev/null and b/backend/app/models/__pycache__/pomodoro.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/suggestion.cpython-312.pyc b/backend/app/models/__pycache__/suggestion.cpython-312.pyc new file mode 100644 index 0000000..d919d93 Binary files /dev/null and b/backend/app/models/__pycache__/suggestion.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/usage.cpython-312.pyc b/backend/app/models/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000..2e1c212 Binary files /dev/null and b/backend/app/models/__pycache__/usage.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/user.cpython-312.pyc b/backend/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..ed1a037 Binary files /dev/null and b/backend/app/models/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py new file mode 100644 index 0000000..8fa22da --- /dev/null +++ b/backend/app/models/calendar.py @@ -0,0 +1,73 @@ +import uuid +from datetime import datetime, date, time + +from sqlalchemy import String, Text, DateTime, Date, Time, ForeignKey, Integer, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class CalendarEvent(Base): + """Unified calendar events from all sources.""" + + __tablename__ = "calendar_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + nullable=True, index=True, + ) + # "assignment" | "institute_holiday" | "fest" | "club_event" | "gmail_event" + event_type: Mapped[str] = mapped_column(String(50), index=True) + title: Mapped[str] = mapped_column(String(500)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + event_date: Mapped[date] = mapped_column(Date, index=True) + event_time: Mapped[time | None] = mapped_column(Time, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Source reference (e.g. Classroom course ID, Gmail message ID) + source_id: Mapped[str | None] = mapped_column( + String(500), nullable=True, index=True) + # "classroom" | "gmail" | "admin" | "manual" + source: Mapped[str | None] = mapped_column(String(50), nullable=True) + + # Extra metadata as JSONB + metadata_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + # Admin moderation status: "pending" | "approved" | "rejected" + moderation_status: Mapped[str] = mapped_column( + String(20), default="approved") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class TimetableSlot(Base): + """Recurring weekly timetable slots entered by the user.""" + + __tablename__ = "timetable_slots" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + day_of_week: Mapped[int] = mapped_column(Integer) # 0=Monday .. 6=Sunday + start_time: Mapped[time] = mapped_column(Time) + end_time: Mapped[time] = mapped_column(Time) + title: Mapped[str] = mapped_column(String(255)) + location: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="timetable_slots") diff --git a/backend/app/models/career_goal.py b/backend/app/models/career_goal.py new file mode 100644 index 0000000..5325ee6 --- /dev/null +++ b/backend/app/models/career_goal.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class CareerGoal(Base): + __tablename__ = "career_goals" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + user = relationship("User", back_populates="career_goals") diff --git a/backend/app/models/focus_log.py b/backend/app/models/focus_log.py new file mode 100644 index 0000000..1c01567 --- /dev/null +++ b/backend/app/models/focus_log.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime, date + +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class FocusLog(Base): + """Daily focus minutes, used for the 90-day heatmap.""" + + __tablename__ = "focus_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + log_date: Mapped[date] = mapped_column(Date, index=True) + focus_minutes: Mapped[int] = mapped_column(Integer, default=0) + mode: Mapped[str] = mapped_column(String(20)) # "acads" | "clubs" + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="focus_logs") diff --git a/backend/app/models/pomodoro.py b/backend/app/models/pomodoro.py new file mode 100644 index 0000000..c8b0155 --- /dev/null +++ b/backend/app/models/pomodoro.py @@ -0,0 +1,119 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class PomodoroSession(Base): + """A group Pomodoro session (2-8 members).""" + + __tablename__ = "pomodoro_sessions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + invite_code: Mapped[str] = mapped_column(String(20), unique=True, index=True) + created_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE") + ) + # Session configuration + focus_duration_min: Mapped[int] = mapped_column(Integer, default=25) + break_duration_min: Mapped[int] = mapped_column(Integer, default=5) + total_intervals: Mapped[int] = mapped_column(Integer, default=4) + + # State: "waiting" | "focus" | "break" | "completed" + status: Mapped[str] = mapped_column(String(20), default="waiting") + current_interval: Mapped[int] = mapped_column(Integer, default=0) + + started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + ended_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + members = relationship( + "SessionMember", back_populates="session", cascade="all, delete-orphan" + ) + + +class SessionMember(Base): + """A participant in a Pomodoro session.""" + + __tablename__ = "session_members" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("pomodoro_sessions.id", ondelete="CASCADE"), + index=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_paused: Mapped[bool] = mapped_column(Boolean, default=False) + focus_seconds: Mapped[int] = mapped_column(Integer, default=0) + xp_earned: Mapped[int] = mapped_column(Integer, default=0) + last_heartbeat: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + session = relationship("PomodoroSession", back_populates="members") + + +class UserXP(Base): + """Lifetime and weekly XP tracking per user per group.""" + + __tablename__ = "user_xp" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("pomodoro_sessions.id", ondelete="CASCADE"), + index=True, + ) + xp: Mapped[int] = mapped_column(Integer, default=0) + awarded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="user_xp") + + +class Badge(Base): + """Badge definitions and awards.""" + + __tablename__ = "badges" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + badge_type: Mapped[str] = mapped_column( + String(50) + ) # "streak_7", "streak_30", "top_focus" + awarded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/app/models/suggestion.py b/backend/app/models/suggestion.py new file mode 100644 index 0000000..367c900 --- /dev/null +++ b/backend/app/models/suggestion.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class SuggestionHistory(Base): + """Log of past suggestions shown to the user.""" + + __tablename__ = "suggestion_history" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + mode: Mapped[str] = mapped_column(String(20)) # "acads" | "clubs" + suggestions_json: Mapped[dict] = mapped_column(JSONB) + quote: Mapped[str | None] = mapped_column(Text, nullable=True) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False) + is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="suggestion_history") diff --git a/backend/app/models/usage.py b/backend/app/models/usage.py new file mode 100644 index 0000000..1770cfe --- /dev/null +++ b/backend/app/models/usage.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime, date + +from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, BigInteger, func, Index +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class AppUsageLog(Base): + """Per-app usage snapshots posted by the client every 30 minutes.""" + + __tablename__ = "app_usage_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), + ) + package_name: Mapped[str] = mapped_column(String(500)) + category: Mapped[str | None] = mapped_column( + String(50), nullable=True + ) # "productive" | "neutral" | "distraction" + duration_ms: Mapped[int] = mapped_column(BigInteger) + log_date: Mapped[date] = mapped_column(Date, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user = relationship("User", back_populates="app_usage_logs") + + __table_args__ = ( + Index("ix_usage_user_date", "user_id", "log_date"), + ) + + +class AppCategoryMapping(Base): + """Config table mapping package names to usage categories.""" + + __tablename__ = "app_category_mappings" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + package_name: Mapped[str] = mapped_column(String(500), unique=True, index=True) + category: Mapped[str] = mapped_column( + String(50) + ) # "productive" | "neutral" | "distraction" + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..d17a763 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, Text, DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + google_id: Mapped[str] = mapped_column(String(255), unique=True, index=True) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + display_name: Mapped[str] = mapped_column(String(255)) + avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) + focus_mode: Mapped[str] = mapped_column( + String(20), default="acads" + ) # "acads" | "clubs" + + # Google tokens for Classroom / Gmail API + google_access_token: Mapped[str | None] = mapped_column(Text, nullable=True) + google_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Role: "student" | "admin" (club secretary / coordinator) + role: Mapped[str] = mapped_column(String(20), default="student") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + # Relationships + career_goals = relationship("CareerGoal", back_populates="user", cascade="all, delete-orphan") + timetable_slots = relationship("TimetableSlot", back_populates="user", cascade="all, delete-orphan") + app_usage_logs = relationship("AppUsageLog", back_populates="user", cascade="all, delete-orphan") + focus_logs = relationship("FocusLog", back_populates="user", cascade="all, delete-orphan") + user_xp = relationship("UserXP", back_populates="user", cascade="all, delete-orphan") + suggestion_history = relationship("SuggestionHistory", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/__pycache__/__init__.cpython-312.pyc b/backend/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e546a31 Binary files /dev/null and b/backend/app/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..29348a6 Binary files /dev/null and b/backend/app/routers/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/calendar.cpython-312.pyc b/backend/app/routers/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000..649510c Binary files /dev/null and b/backend/app/routers/__pycache__/calendar.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/pomodoro.cpython-312.pyc b/backend/app/routers/__pycache__/pomodoro.cpython-312.pyc new file mode 100644 index 0000000..2c2127c Binary files /dev/null and b/backend/app/routers/__pycache__/pomodoro.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/suggestions.cpython-312.pyc b/backend/app/routers/__pycache__/suggestions.cpython-312.pyc new file mode 100644 index 0000000..694c6a5 Binary files /dev/null and b/backend/app/routers/__pycache__/suggestions.cpython-312.pyc differ diff --git a/backend/app/routers/__pycache__/usage.cpython-312.pyc b/backend/app/routers/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000..24e1bfa Binary files /dev/null and b/backend/app/routers/__pycache__/usage.cpython-312.pyc differ diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..d76a97e --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,153 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from google.oauth2 import id_token +from google.auth.transport import requests as google_requests + +from app.config import get_settings +from app.database import get_db +from app.models.user import User +from app.schemas.user import TokenResponse, UserOut, UserFocusModeUpdate +from app.services.auth import create_access_token, get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["auth"]) +settings = get_settings() + + +class GoogleTokenRequest: + """Accept Google ID token from the Flutter client.""" + + def __init__(self, id_token: str, access_token: str | None = None, refresh_token: str | None = None): + self.id_token = id_token + self.access_token = access_token + self.refresh_token = refresh_token + + +@router.post("/google", response_model=TokenResponse) +async def google_auth( + id_token_str: str, + access_token: str | None = None, + refresh_token: str | None = None, + db: AsyncSession = Depends(get_db), +): + """ + Authenticate with Google. The Flutter client sends the Google ID token + obtained from Google Sign-In. Backend verifies it and returns a JWT. + """ + try: + idinfo = id_token.verify_oauth2_token( + id_token_str, + google_requests.Request(), + settings.google_client_id, + ) + except ValueError: + logger.warning("Invalid Google ID token received") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google ID token", + ) + + google_id = idinfo["sub"] + email = idinfo.get("email", "") + name = idinfo.get("name", "") + picture = idinfo.get("picture") + + # Find or create user + result = await db.execute(select(User).where(User.google_id == google_id)) + user = result.scalar_one_or_none() + + if user is None: + user = User( + google_id=google_id, + email=email, + display_name=name, + avatar_url=picture, + google_access_token=access_token, + google_refresh_token=refresh_token, + ) + db.add(user) + await db.flush() + logger.info("New user created: email=%s, google_id=%s", + email, google_id) + else: + # Update tokens on each login + if access_token: + user.google_access_token = access_token + if refresh_token: + user.google_refresh_token = refresh_token + user.display_name = name + user.avatar_url = picture + logger.info("Existing user logged in: %s (%s)", user.email, user.id) + + jwt_token = create_access_token(str(user.id)) + + return TokenResponse( + access_token=jwt_token, + user=UserOut.model_validate(user), + ) + + +@router.post("/dev-login", response_model=TokenResponse) +async def dev_login( + email: str = "testuser@example.com", + display_name: str = "Test User", + role: str = "student", + db: AsyncSession = Depends(get_db), +): + """ + DEV ONLY — bypass Google OAuth. Creates or finds a user by email + and returns a JWT. Remove this endpoint before deploying to production. + """ + if settings.app_env not in ("development", "testing"): + raise HTTPException(status_code=404, detail="Not found") + + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user is None: + user = User( + google_id=f"dev-{email}", + email=email, + display_name=display_name, + role=role, + focus_mode="acads", + ) + db.add(user) + await db.flush() + logger.info("Dev user created: %s (role=%s)", email, role) + else: + logger.info("Dev user logged in: %s (%s)", email, user.id) + + jwt_token = create_access_token(str(user.id)) + return TokenResponse( + access_token=jwt_token, + user=UserOut.model_validate(user), + ) + + +@router.get("/me", response_model=UserOut) +async def get_me(user: User = Depends(get_current_user)): + """Get current authenticated user profile.""" + return UserOut.model_validate(user) + + +@router.patch("/me/focus-mode", response_model=UserOut) +async def update_focus_mode( + body: UserFocusModeUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Switch between Acads Mode and Clubs & Projects Mode.""" + if body.focus_mode not in ("acads", "clubs"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="focus_mode must be 'acads' or 'clubs'", + ) + user.focus_mode = body.focus_mode + await db.flush() + logger.info("User %s switched focus mode to '%s'", + user.id, body.focus_mode) + return UserOut.model_validate(user) diff --git a/backend/app/routers/calendar.py b/backend/app/routers/calendar.py new file mode 100644 index 0000000..de9ee02 --- /dev/null +++ b/backend/app/routers/calendar.py @@ -0,0 +1,295 @@ +import csv +import io +import logging +import uuid +from datetime import date, datetime + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status +from sqlalchemy import select, and_, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.calendar import CalendarEvent, TimetableSlot +from app.schemas.calendar import ( + TimetableSlotCreate, + TimetableSlotOut, + CalendarEventCreate, + CalendarEventOut, + AdminEventCreate, +) +from app.services.auth import get_current_user, require_admin + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["calendar"]) + +# ═══════════════════════════════════════════════════════════════ +# TIMETABLE SLOTS (FR-05) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/timetable/{user_id}", response_model=list[TimetableSlotOut]) +async def get_timetable( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(TimetableSlot).where(TimetableSlot.user_id == user_id) + ) + return [TimetableSlotOut.model_validate(s) for s in result.scalars().all()] + + +@router.post("/timetable", response_model=TimetableSlotOut, status_code=201) +async def create_timetable_slot( + body: TimetableSlotCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + slot = TimetableSlot(user_id=user.id, **body.model_dump()) + db.add(slot) + await db.flush() + return TimetableSlotOut.model_validate(slot) + + +@router.delete("/timetable/{slot_id}", status_code=204) +async def delete_timetable_slot( + slot_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(TimetableSlot).where( + and_(TimetableSlot.id == slot_id, TimetableSlot.user_id == user.id) + ) + ) + slot = result.scalar_one_or_none() + if not slot: + raise HTTPException(status_code=404, detail="Slot not found") + await db.delete(slot) + + +# ═══════════════════════════════════════════════════════════════ +# CALENDAR EVENTS (FR-06, FR-07, FR-08) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/calendar/{user_id}", response_model=list[CalendarEventOut]) +async def get_calendar_events( + user_id: uuid.UUID, + start_date: date | None = Query(None), + end_date: date | None = Query(None), + event_type: str | None = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Unified calendar view: returns user-specific events + global events + (institute holidays, fests) merged into one list. + """ + filters = [ + (CalendarEvent.user_id == user_id) | (CalendarEvent.user_id.is_(None)), + CalendarEvent.moderation_status == "approved", + ] + if start_date: + filters.append(CalendarEvent.event_date >= start_date) + if end_date: + filters.append(CalendarEvent.event_date <= end_date) + if event_type: + filters.append(CalendarEvent.event_type == event_type) + + result = await db.execute(select(CalendarEvent).where(and_(*filters))) + return [CalendarEventOut.model_validate(e) for e in result.scalars().all()] + + +@router.post("/calendar/events", response_model=CalendarEventOut, status_code=201) +async def create_calendar_event( + body: CalendarEventCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + event = CalendarEvent(user_id=user.id, **body.model_dump()) + db.add(event) + await db.flush() + return CalendarEventOut.model_validate(event) + + +# ═══════════════════════════════════════════════════════════════ +# ADMIN: INSTITUTE CALENDAR CSV UPLOAD (FR-16) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/admin/institute-calendar", status_code=201) +async def upload_institute_calendar( + file: UploadFile = File(...), + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + Admin uploads CSV with columns: date, title, type (holiday/fest), description (optional). + Inserts as global events (user_id=NULL). + """ + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only CSV files accepted") + + content = await file.read() + reader = csv.DictReader(io.StringIO(content.decode("utf-8"))) + logger.info("Admin %s uploading institute calendar CSV: %s", + admin.id, file.filename) + + inserted = 0 + for row in reader: + try: + event_date = datetime.strptime( + row["date"].strip(), "%Y-%m-%d").date() + except (KeyError, ValueError): + continue # skip malformed rows + + event_type = row.get("type", "institute_holiday").strip() + if event_type not in ("institute_holiday", "fest"): + event_type = "institute_holiday" + + event = CalendarEvent( + user_id=None, + event_type=event_type, + title=row.get("title", "").strip(), + description=row.get("description", "").strip() or None, + event_date=event_date, + source="admin", + moderation_status="approved", + ) + db.add(event) + inserted += 1 + + logger.info( + "Institute calendar upload: %d events inserted by admin %s", inserted, admin.id) + return {"inserted": inserted} + + +# ═══════════════════════════════════════════════════════════════ +# ADMIN EVENT PANEL (FR-25, FR-26) +# Club secretaries / coordinators post events manually +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/admin/events", response_model=CalendarEventOut, status_code=201) +async def admin_create_event( + body: AdminEventCreate, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Club secretary posts a club event — starts as 'pending' moderation.""" + event = CalendarEvent( + user_id=None, + event_type="club_event", + title=body.title, + description=body.description, + event_date=body.event_date, + event_time=body.event_time, + end_date=body.end_date, + location=body.location, + source="admin", + moderation_status="pending", + ) + db.add(event) + await db.flush() + logger.info( + "Admin %s created club event '%s' (pending moderation)", admin.id, body.title) + return CalendarEventOut.model_validate(event) + + +@router.get("/admin/events", response_model=list[CalendarEventOut]) +async def admin_list_events( + moderation_status: str | None = Query(None), + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + filters = [CalendarEvent.source == "admin"] + if moderation_status: + filters.append(CalendarEvent.moderation_status == moderation_status) + result = await db.execute(select(CalendarEvent).where(and_(*filters))) + return [CalendarEventOut.model_validate(e) for e in result.scalars().all()] + + +@router.patch("/admin/events/{event_id}/approve", response_model=CalendarEventOut) +async def admin_approve_event( + event_id: uuid.UUID, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + event.moderation_status = "approved" + await db.flush() + logger.info("Event %s approved by admin %s", event_id, admin.id) + return CalendarEventOut.model_validate(event) + + +@router.delete("/admin/events/{event_id}", status_code=204) +async def admin_delete_event( + event_id: uuid.UUID, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + await db.delete(event) + + +# ═══════════════════════════════════════════════════════════════ +# GOOGLE CLASSROOM SYNC (FR-06) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/calendar/sync/classroom") +async def sync_classroom( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Pull assignment deadlines from all enrolled Google Classroom courses. + Upserts into calendar_events with type='assignment'. + """ + if not user.google_access_token: + raise HTTPException( + status_code=400, + detail="Google tokens not available. Re-authenticate with Classroom scope.", + ) + + from app.services.classroom_sync import sync_classroom_assignments + + count = await sync_classroom_assignments(user, db) + logger.info( + "Classroom sync for user %s: %d assignments synced", user.id, count) + return {"synced_assignments": count} + + +# ═══════════════════════════════════════════════════════════════ +# GMAIL CLUB EVENT PARSING (FR-07) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/calendar/sync/gmail") +async def sync_gmail_events( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Parse Gmail for club activity emails, extract events via LLM. + Upserts into calendar_events with type='gmail_event'. + """ + if not user.google_access_token: + raise HTTPException( + status_code=400, + detail="Google tokens not available. Re-authenticate with Gmail scope.", + ) + + from app.services.gmail_parser import parse_gmail_club_events + + count = await parse_gmail_club_events(user, db) + logger.info("Gmail sync for user %s: %d events parsed", user.id, count) + return {"parsed_events": count} diff --git a/backend/app/routers/pomodoro.py b/backend/app/routers/pomodoro.py new file mode 100644 index 0000000..4ffd265 --- /dev/null +++ b/backend/app/routers/pomodoro.py @@ -0,0 +1,576 @@ +import uuid +import logging +import secrets +from datetime import datetime, timezone, timedelta, date + +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db, AsyncSessionLocal +from app.models.user import User +from app.models.pomodoro import PomodoroSession, SessionMember, UserXP, Badge +from app.models.focus_log import FocusLog +from app.schemas.pomodoro import ( + PomodoroSessionCreate, + PomodoroSessionOut, + SessionJoin, + SessionMemberOut, + LeaderboardResponse, + LeaderboardEntry, + BadgeOut, +) +from app.services.auth import get_current_user, decode_access_token +from app.services.ws_manager import manager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["pomodoro"]) + +XP_PER_INTERVAL = 100 # XP awarded per completed focus interval + + +# ═══════════════════════════════════════════════════════════════ +# SESSION CRUD (FR-11) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/pomodoro/sessions", response_model=PomodoroSessionOut, status_code=201) +async def create_session( + body: PomodoroSessionCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + invite_code = secrets.token_urlsafe(6)[:8].upper() + session = PomodoroSession( + invite_code=invite_code, + created_by=user.id, + focus_duration_min=body.focus_duration_min, + break_duration_min=body.break_duration_min, + total_intervals=body.total_intervals, + ) + db.add(session) + await db.flush() + + # Creator auto-joins + member = SessionMember(session_id=session.id, user_id=user.id) + db.add(member) + await db.flush() + + # Eagerly load members for serialization + result = await db.execute( + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session.id) + ) + session = result.scalar_one() + + logger.info("Pomodoro session created: id=%s, invite=%s, by user %s", + session.id, invite_code, user.id) + return PomodoroSessionOut.model_validate(session) + + +@router.post("/pomodoro/sessions/join", response_model=PomodoroSessionOut) +async def join_session( + body: SessionJoin, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PomodoroSession).where( + PomodoroSession.invite_code == body.invite_code) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.status not in ("waiting",): + raise HTTPException( + status_code=400, detail="Session already started or completed") + + # Check member count (2-8) + member_count = await db.execute( + select(func.count()).where(SessionMember.session_id == session.id) + ) + if member_count.scalar() >= 8: + raise HTTPException( + status_code=400, detail="Session is full (max 8 members)") + + # Check if already joined + existing = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session.id, + SessionMember.user_id == user.id, + ) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=400, detail="Already joined this session") + + member = SessionMember(session_id=session.id, user_id=user.id) + db.add(member) + await db.flush() + + # Eagerly load members for serialization + result = await db.execute( + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session.id) + ) + session = result.scalar_one() + logger.info("User %s joined pomodoro session %s via invite %s", + user.id, session.id, body.invite_code) + return PomodoroSessionOut.model_validate(session) + + +@router.get("/pomodoro/sessions/{session_id}", response_model=PomodoroSessionOut) +async def get_session( + session_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PomodoroSession) + .options(selectinload(PomodoroSession.members)) + .where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return PomodoroSessionOut.model_validate(session) + + +# ═══════════════════════════════════════════════════════════════ +# LEADERBOARD (FR-21) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/pomodoro/sessions/{session_id}/leaderboard", response_model=LeaderboardResponse) +async def get_leaderboard( + session_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Weekly XP leaderboard within a group.""" + week_start = date.today() - timedelta(days=date.today().weekday()) + + result = await db.execute( + select( + UserXP.user_id, + func.sum(UserXP.xp).label("total_xp"), + ) + .where( + and_( + UserXP.session_id == session_id, + UserXP.awarded_at >= datetime.combine( + week_start, datetime.min.time()), + ) + ) + .group_by(UserXP.user_id) + .order_by(func.sum(UserXP.xp).desc()) + ) + rows = result.all() + + entries = [] + for rank, row in enumerate(rows, 1): + # Fetch display name + user_result = await db.execute(select(User).where(User.id == row.user_id)) + u = user_result.scalar_one_or_none() + entries.append( + LeaderboardEntry( + user_id=row.user_id, + display_name=u.display_name if u else "Unknown", + total_xp=int(row.total_xp), + rank=rank, + ) + ) + + return LeaderboardResponse(session_id=session_id, entries=entries) + + +# ═══════════════════════════════════════════════════════════════ +# BADGES (FR-23) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/pomodoro/badges/{user_id}", response_model=list[BadgeOut]) +async def get_badges( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Badge).where(Badge.user_id == user_id).order_by( + Badge.awarded_at.desc()) + ) + return [BadgeOut.model_validate(b) for b in result.scalars().all()] + + +# ═══════════════════════════════════════════════════════════════ +# WEBSOCKET (FR-12, FR-13, FR-22) +# Real-time timer sync at /ws/pomodoro/{session_id} +# ═══════════════════════════════════════════════════════════════ + + +@router.websocket("/ws/pomodoro/{session_id}") +async def pomodoro_websocket( + websocket: WebSocket, + session_id: uuid.UUID, + token: str = Query(...), +): + """ + WebSocket endpoint for Pomodoro session sync. + Client sends: {"type": "heartbeat"} or {"type": "pause"} or {"type": "resume"} or {"type": "start"} + Server sends: tick events every second, state changes, nudges. + """ + # Authenticate via token query param + try: + user_id = decode_access_token(token) + except Exception: + logger.warning( + "WS auth failed for session %s — invalid token", session_id) + await websocket.close(code=4001, reason="Invalid token") + return + + session_id_str = str(session_id) + await manager.connect(session_id_str, user_id, websocket) + + try: + # Notify others of new connection + await manager.broadcast(session_id_str, { + "type": "member_update", + "data": { + "user_id": user_id, + "action": "connected", + "member_count": manager.get_member_count(session_id_str), + }, + }) + + while True: + data = await websocket.receive_json() + msg_type = data.get("type") + + if msg_type == "heartbeat": + manager.record_heartbeat(session_id_str, user_id) + + elif msg_type == "start": + # Only session creator can start + await _handle_start(session_id, user_id, session_id_str) + + elif msg_type == "pause": + # Voluntary pause — local only, no disclosure (FR-13) + async with AsyncSessionLocal() as db: + result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID(user_id), + ) + ) + ) + member = result.scalar_one_or_none() + if member: + member.is_paused = True + await db.commit() + + elif msg_type == "resume": + async with AsyncSessionLocal() as db: + result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID(user_id), + ) + ) + ) + member = result.scalar_one_or_none() + if member: + member.is_paused = False + await db.commit() + manager.record_heartbeat(session_id_str, user_id) + + except WebSocketDisconnect: + manager.disconnect(session_id_str, user_id) + await manager.broadcast(session_id_str, { + "type": "member_update", + "data": { + "user_id": user_id, + "action": "disconnected", + "member_count": manager.get_member_count(session_id_str), + }, + }) + + +async def _handle_start(session_id: uuid.UUID, user_id: str, session_id_str: str): + """Start the Pomodoro session and begin the timer loop.""" + import asyncio + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session or str(session.created_by) != user_id: + return + if session.status != "waiting": + return + + session.status = "focus" + session.current_interval = 1 + session.started_at = datetime.now(timezone.utc) + await db.commit() + + logger.info("Pomodoro session %s started by user %s", session_id, user_id) + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "focus", "interval": 1}, + }) + + # Run the timer loop in background + asyncio.create_task(_timer_loop(session_id, session_id_str)) + + +async def _timer_loop(session_id: uuid.UUID, session_id_str: str): + """Server-side timer loop — broadcasts ticks and manages intervals.""" + import asyncio + + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + return + + focus_secs = session.focus_duration_min * 60 + break_secs = session.break_duration_min * 60 + total_intervals = session.total_intervals + + for interval in range(1, total_intervals + 1): + # ── Focus phase ── + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + s = result.scalar_one_or_none() + if s: + s.status = "focus" + s.current_interval = interval + await db.commit() + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "focus", "interval": interval}, + }) + + for second in range(focus_secs, 0, -1): + if manager.get_member_count(session_id_str) == 0: + logger.warning( + "Timer loop aborted — no members in session %s", session_id_str) + return # No one connected, stop + + await manager.broadcast(session_id_str, { + "type": "tick", + "data": { + "status": "focus", + "interval": interval, + "remaining_seconds": second, + }, + }) + + # Check for stale members every 15 seconds (accountability nudge FR-22) + if second % 15 == 0: + stale = manager.get_stale_members(session_id_str) + for stale_uid in stale: + logger.info( + "Nudge sent to user %s in session %s (stale heartbeat)", stale_uid, session_id_str) + await manager.send_to_user(session_id_str, stale_uid, { + "type": "nudge", + "data": {"message": "You seem to have lost focus. Stay strong!"}, + }) + # Mark as low-focus internally (invisible to others) + async with AsyncSessionLocal() as db: + member_result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.user_id == uuid.UUID( + stale_uid), + ) + ) + ) + member = member_result.scalar_one_or_none() + if member: + member.is_paused = True + await db.commit() + + await asyncio.sleep(1) + + # Award XP for completed focus interval (server-verified, FR-20) + async with AsyncSessionLocal() as db: + members_result = await db.execute( + select(SessionMember).where( + and_( + SessionMember.session_id == session_id, + SessionMember.is_active == True, + ) + ) + ) + for member in members_result.scalars().all(): + if not member.is_paused: + member.focus_seconds += focus_secs + member.xp_earned += XP_PER_INTERVAL + xp_record = UserXP( + user_id=member.user_id, + session_id=session_id, + xp=XP_PER_INTERVAL, + ) + db.add(xp_record) + + # Update focus log for heatmap + today = date.today() + focus_log_result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == member.user_id, + FocusLog.log_date == today, + ) + ) + ) + focus_log = focus_log_result.scalar_one_or_none() + if focus_log: + focus_log.focus_minutes += focus_secs // 60 + else: + db.add(FocusLog( + user_id=member.user_id, + log_date=today, + focus_minutes=focus_secs // 60, + mode="acads", # could be dynamic + )) + await db.commit() + + # ── Break phase (skip after last interval) ── + if interval < total_intervals: + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where( + PomodoroSession.id == session_id) + ) + s = result.scalar_one_or_none() + if s: + s.status = "break" + await db.commit() + + await manager.broadcast(session_id_str, { + "type": "state_change", + "data": {"status": "break", "interval": interval}, + }) + + for second in range(break_secs, 0, -1): + if manager.get_member_count(session_id_str) == 0: + return + await manager.broadcast(session_id_str, { + "type": "tick", + "data": { + "status": "break", + "interval": interval, + "remaining_seconds": second, + }, + }) + await asyncio.sleep(1) + + # ── Session complete ── + async with AsyncSessionLocal() as db: + result = await db.execute( + select(PomodoroSession).where(PomodoroSession.id == session_id) + ) + session = result.scalar_one_or_none() + if session: + session.status = "completed" + session.ended_at = datetime.now(timezone.utc) + await db.commit() + logger.info("Pomodoro session %s completed", session_id) + + # Evaluate badges (FR-23) + await _evaluate_badges(session_id, db) + + # Broadcast final results + async with AsyncSessionLocal() as db: + members_result = await db.execute( + select(SessionMember).where(SessionMember.session_id == session_id) + ) + final_members = [ + { + "user_id": str(m.user_id), + "xp_earned": m.xp_earned, + "focus_seconds": m.focus_seconds, + } + for m in members_result.scalars().all() + ] + + await manager.broadcast(session_id_str, { + "type": "session_end", + "data": {"members": final_members}, + }) + + +async def _evaluate_badges(session_id: uuid.UUID, db: AsyncSession): + """Check and award badges: streaks (7-day, 30-day) and top-focus-member.""" + members_result = await db.execute( + select(SessionMember).where(SessionMember.session_id == session_id) + ) + members = members_result.scalars().all() + if not members: + return + + # Top focus member badge + top = max(members, key=lambda m: m.focus_seconds) + existing = await db.execute( + select(Badge).where( + and_( + Badge.user_id == top.user_id, + Badge.badge_type == "top_focus", + ) + ) + ) + if not existing.scalar_one_or_none(): + db.add(Badge(user_id=top.user_id, badge_type="top_focus")) + + # Streak badges — check focus_logs for consecutive days + for member in members: + today = date.today() + for streak_days, badge_type in [(7, "streak_7"), (30, "streak_30")]: + # Check if they have focus logs for the last N consecutive days + consecutive = 0 + for d in range(streak_days): + check_date = today - timedelta(days=d) + log_result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == member.user_id, + FocusLog.log_date == check_date, + FocusLog.focus_minutes > 0, + ) + ) + ) + if log_result.scalar_one_or_none(): + consecutive += 1 + else: + break + + if consecutive >= streak_days: + existing_badge = await db.execute( + select(Badge).where( + and_( + Badge.user_id == member.user_id, + Badge.badge_type == badge_type, + ) + ) + ) + if not existing_badge.scalar_one_or_none(): + db.add(Badge(user_id=member.user_id, badge_type=badge_type)) + + await db.commit() diff --git a/backend/app/routers/suggestions.py b/backend/app/routers/suggestions.py new file mode 100644 index 0000000..55dc1d2 --- /dev/null +++ b/backend/app/routers/suggestions.py @@ -0,0 +1,173 @@ +import logging +import uuid +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, and_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.career_goal import CareerGoal +from app.models.focus_log import FocusLog +from app.schemas.career_goal import CareerGoalCreate, CareerGoalUpdate, CareerGoalOut +from app.schemas.suggestion import ( + SuggestionResponse, + HeatmapResponse, + HeatmapEntry, + SuggestionHistoryOut, + SuggestionStatusUpdate, +) +from app.services.auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["suggestions"]) + + +# ═══════════════════════════════════════════════════════════════ +# CAREER GOALS (FR-15) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/user/{user_id}/goals", response_model=list[CareerGoalOut]) +async def get_career_goals( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + return [CareerGoalOut.model_validate(g) for g in result.scalars().all()] + + +@router.put("/user/{user_id}/goals", response_model=list[CareerGoalOut]) +async def set_career_goals( + user_id: uuid.UUID, + goals: list[CareerGoalCreate], + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Replace all career goals for a user.""" + # Delete existing + existing = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + for g in existing.scalars().all(): + await db.delete(g) + + # Insert new + new_goals = [] + for goal_data in goals: + goal = CareerGoal(user_id=user_id, **goal_data.model_dump()) + db.add(goal) + new_goals.append(goal) + + await db.flush() + return [CareerGoalOut.model_validate(g) for g in new_goals] + + +# ═══════════════════════════════════════════════════════════════ +# SUGGESTIONS (FR-03, FR-04) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/suggestions/{user_id}", response_model=SuggestionResponse) +async def get_suggestions( + user_id: uuid.UUID, + mode: str = Query("acads", regex="^(acads|clubs)$"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + LLM-scored suggestions based on focus mode, career goals, + upcoming 48h calendar events, and workload score. + """ + from app.services.suggestion_engine import generate_suggestions + + logger.info("Fetching suggestions for user %s, mode=%s", user_id, mode) + result = await generate_suggestions(user_id, mode, db) + return result + + +# ═══════════════════════════════════════════════════════════════ +# SUGGESTION HISTORY (FR-24) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/suggestions/{user_id}/history", response_model=list[SuggestionHistoryOut]) +async def get_suggestion_history( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.models.suggestion import SuggestionHistory + + result = await db.execute( + select(SuggestionHistory) + .where(SuggestionHistory.user_id == user_id) + .order_by(SuggestionHistory.created_at.desc()) + .limit(50) + ) + return [SuggestionHistoryOut.model_validate(s) for s in result.scalars().all()] + + +@router.patch("/suggestions/history/{suggestion_id}", response_model=SuggestionHistoryOut) +async def update_suggestion_status( + suggestion_id: uuid.UUID, + body: SuggestionStatusUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.models.suggestion import SuggestionHistory + + result = await db.execute( + select(SuggestionHistory).where(SuggestionHistory.id == suggestion_id) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + + if body.is_completed is not None: + suggestion.is_completed = body.is_completed + if body.is_dismissed is not None: + suggestion.is_dismissed = body.is_dismissed + + await db.flush() + return SuggestionHistoryOut.model_validate(suggestion) + + +# ═══════════════════════════════════════════════════════════════ +# FOCUS HEATMAP (FR-14) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/focus-log/{user_id}/heatmap", response_model=HeatmapResponse) +async def get_focus_heatmap( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """90-day GitHub-style focus heatmap — daily Pomodoro + active-session minutes.""" + start = date.today() - timedelta(days=90) + + result = await db.execute( + select(FocusLog).where( + and_( + FocusLog.user_id == user_id, + FocusLog.log_date >= start, + ) + ) + ) + logs = result.scalars().all() + + entries = [ + HeatmapEntry( + date=log.log_date.isoformat(), + focus_minutes=log.focus_minutes, + mode=log.mode, + ) + for log in logs + ] + + return HeatmapResponse(user_id=user_id, entries=entries) diff --git a/backend/app/routers/usage.py b/backend/app/routers/usage.py new file mode 100644 index 0000000..5a721a2 --- /dev/null +++ b/backend/app/routers/usage.py @@ -0,0 +1,230 @@ +import logging +import uuid +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, and_, func, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User +from app.models.usage import AppUsageLog, AppCategoryMapping +from app.schemas.usage import AppUsageBatchCreate, RollingAverageResponse, AppUsageStats +from app.services.auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["usage"]) + + +# ═══════════════════════════════════════════════════════════════ +# BATCH POST USAGE DATA (FR-09) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/usage", status_code=201) +async def post_usage_data( + body: AppUsageBatchCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Client POSTs [{package_name, duration_ms, date}] every 30 minutes. + Backend maps package names to categories via config table. + """ + # Pre-load category mappings + mappings_result = await db.execute(select(AppCategoryMapping)) + category_map = { + m.package_name: m.category for m in mappings_result.scalars().all()} + + inserted = 0 + for entry in body.entries: + category = category_map.get(entry.package_name, "neutral") + + log = AppUsageLog( + user_id=user.id, + package_name=entry.package_name, + category=category, + duration_ms=entry.duration_ms, + log_date=entry.date, + ) + db.add(log) + inserted += 1 + + logger.info("Usage data ingested: %d entries for user %s", + inserted, user.id) + return {"inserted": inserted} + + +# ═══════════════════════════════════════════════════════════════ +# ROLLING AVERAGES & PERCENTILE (FR-18, FR-10) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/{user_id}/rolling-average", response_model=RollingAverageResponse) +async def get_rolling_average( + user_id: uuid.UUID, + period_days: int = 7, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Returns user's rolling average per category vs institute average, + plus percentile rank via PERCENT_RANK() window function. + Uses anonymized aggregation — raw package names never exposed (NFR-01). + """ + start_date = date.today() - timedelta(days=period_days) + + # User's average per category + user_avg_query = ( + select( + AppUsageLog.category, + func.avg(AppUsageLog.duration_ms).label("user_avg_ms"), + ) + .where( + and_( + AppUsageLog.user_id == user_id, + AppUsageLog.log_date >= start_date, + AppUsageLog.category.isnot(None), + ) + ) + .group_by(AppUsageLog.category) + ) + user_avgs = await db.execute(user_avg_query) + user_data = {row.category: float(row.user_avg_ms) for row in user_avgs} + + # Institute average per category (all opted-in users) + institute_avg_query = ( + select( + AppUsageLog.category, + func.avg(AppUsageLog.duration_ms).label("inst_avg_ms"), + ) + .where( + and_( + AppUsageLog.log_date >= start_date, + AppUsageLog.category.isnot(None), + ) + ) + .group_by(AppUsageLog.category) + ) + inst_avgs = await db.execute(institute_avg_query) + inst_data = {row.category: float(row.inst_avg_ms) for row in inst_avgs} + + # Percentile rank for the user's productive usage + # Uses PERCENT_RANK() window function over all users + percentile_query = text(""" + WITH user_totals AS ( + SELECT + user_id, + category, + AVG(duration_ms) AS avg_ms + FROM app_usage_logs + WHERE log_date >= :start_date AND category IS NOT NULL + GROUP BY user_id, category + ), + ranked AS ( + SELECT + user_id, + category, + avg_ms, + PERCENT_RANK() OVER (PARTITION BY category ORDER BY avg_ms) AS pct_rank + FROM user_totals + ) + SELECT category, pct_rank + FROM ranked + WHERE user_id = :user_id + """) + percentile_result = await db.execute( + percentile_query, {"start_date": start_date, "user_id": user_id} + ) + percentiles = {row.category: float(row.pct_rank) + for row in percentile_result} + + # Assemble response + all_categories = set(user_data.keys()) | set(inst_data.keys()) + stats = [] + for cat in sorted(all_categories): + stats.append( + AppUsageStats( + category=cat, + user_avg_ms=user_data.get(cat, 0.0), + institute_avg_ms=inst_data.get(cat, 0.0), + user_percentile=percentiles.get(cat), + ) + ) + + return RollingAverageResponse( + user_id=user_id, + period_days=period_days, + stats=stats, + ) + + +# ═══════════════════════════════════════════════════════════════ +# NUDGE CHECK (FR-10) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/{user_id}/should-nudge") +async def check_nudge( + user_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Returns whether the user's productive usage is below the 50th percentile + of institute peers. The client uses this to fire a local push notification. + """ + start_date = date.today() - timedelta(days=7) + + percentile_query = text(""" + WITH user_totals AS ( + SELECT + user_id, + AVG(duration_ms) AS avg_ms + FROM app_usage_logs + WHERE log_date >= :start_date AND category = 'productive' + GROUP BY user_id + ), + ranked AS ( + SELECT + user_id, + avg_ms, + PERCENT_RANK() OVER (ORDER BY avg_ms) AS pct_rank + FROM user_totals + ) + SELECT pct_rank + FROM ranked + WHERE user_id = :user_id + """) + result = await db.execute( + percentile_query, {"start_date": start_date, "user_id": user_id} + ) + row = result.first() + + if row is None: + return {"should_nudge": False, "percentile": None} + + should_nudge = row.pct_rank < 0.5 + return {"should_nudge": should_nudge, "percentile": round(row.pct_rank * 100, 1)} + + +# ═══════════════════════════════════════════════════════════════ +# APP CATEGORY MAPPINGS (NFR-09 — DB config, not hardcoded) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/usage/categories") +async def list_categories( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List all known app-to-category mappings.""" + result = await db.execute(select(AppCategoryMapping)) + return [ + { + "package_name": m.package_name, + "category": m.category, + "display_name": m.display_name, + } + for m in result.scalars().all() + ] diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/__init__.cpython-312.pyc b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2734e1d Binary files /dev/null and b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/calendar.cpython-312.pyc b/backend/app/schemas/__pycache__/calendar.cpython-312.pyc new file mode 100644 index 0000000..8635a1b Binary files /dev/null and b/backend/app/schemas/__pycache__/calendar.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/career_goal.cpython-312.pyc b/backend/app/schemas/__pycache__/career_goal.cpython-312.pyc new file mode 100644 index 0000000..0a83c24 Binary files /dev/null and b/backend/app/schemas/__pycache__/career_goal.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc b/backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc new file mode 100644 index 0000000..8043b6a Binary files /dev/null and b/backend/app/schemas/__pycache__/pomodoro.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc b/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc new file mode 100644 index 0000000..f86e620 Binary files /dev/null and b/backend/app/schemas/__pycache__/suggestion.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/usage.cpython-312.pyc b/backend/app/schemas/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000..22b1a26 Binary files /dev/null and b/backend/app/schemas/__pycache__/usage.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/user.cpython-312.pyc b/backend/app/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..dfd8aaf Binary files /dev/null and b/backend/app/schemas/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py new file mode 100644 index 0000000..cec5e00 --- /dev/null +++ b/backend/app/schemas/calendar.py @@ -0,0 +1,70 @@ +import uuid +from datetime import date, time, datetime +from pydantic import BaseModel + + +# ── Timetable Slots ── + + +class TimetableSlotCreate(BaseModel): + day_of_week: int # 0=Monday .. 6=Sunday + start_time: time + end_time: time + title: str + location: str | None = None + + +class TimetableSlotOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + day_of_week: int + start_time: time + end_time: time + title: str + location: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Calendar Events ── + + +class CalendarEventCreate(BaseModel): + event_type: str # "assignment" | "institute_holiday" | "fest" | "club_event" + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None + source: str | None = None + source_id: str | None = None + metadata_json: dict | None = None + + +class CalendarEventOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID | None = None + event_type: str + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None + source: str | None = None + moderation_status: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminEventCreate(BaseModel): + """For club secretaries / coordinators posting events via Admin Panel.""" + title: str + description: str | None = None + event_date: date + event_time: time | None = None + end_date: date | None = None + location: str | None = None diff --git a/backend/app/schemas/career_goal.py b/backend/app/schemas/career_goal.py new file mode 100644 index 0000000..9c312dc --- /dev/null +++ b/backend/app/schemas/career_goal.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +class CareerGoalCreate(BaseModel): + title: str + description: str | None = None + + +class CareerGoalUpdate(BaseModel): + title: str | None = None + description: str | None = None + + +class CareerGoalOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + title: str + description: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/pomodoro.py b/backend/app/schemas/pomodoro.py new file mode 100644 index 0000000..bb56b6c --- /dev/null +++ b/backend/app/schemas/pomodoro.py @@ -0,0 +1,83 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +# ── Session ── + + +class PomodoroSessionCreate(BaseModel): + focus_duration_min: int = 25 + break_duration_min: int = 5 + total_intervals: int = 4 + + +class PomodoroSessionOut(BaseModel): + id: uuid.UUID + invite_code: str + created_by: uuid.UUID + focus_duration_min: int + break_duration_min: int + total_intervals: int + status: str + current_interval: int + started_at: datetime | None = None + ended_at: datetime | None = None + created_at: datetime + members: list["SessionMemberOut"] = [] + + model_config = {"from_attributes": True} + + +class SessionJoin(BaseModel): + invite_code: str + + +# ── Members ── + + +class SessionMemberOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + is_active: bool + is_paused: bool + focus_seconds: int + xp_earned: int + joined_at: datetime + + model_config = {"from_attributes": True} + + +# ── XP & Leaderboard ── + + +class LeaderboardEntry(BaseModel): + user_id: uuid.UUID + display_name: str + total_xp: int + rank: int + + +class LeaderboardResponse(BaseModel): + session_id: uuid.UUID + entries: list[LeaderboardEntry] + + +# ── Badges ── + + +class BadgeOut(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + badge_type: str + awarded_at: datetime + + model_config = {"from_attributes": True} + + +# ── WebSocket Messages ── + + +class WsMessage(BaseModel): + type: str # "tick" | "state_change" | "member_update" | "session_end" | "nudge" + data: dict diff --git a/backend/app/schemas/suggestion.py b/backend/app/schemas/suggestion.py new file mode 100644 index 0000000..4481689 --- /dev/null +++ b/backend/app/schemas/suggestion.py @@ -0,0 +1,51 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +# ── Suggestions ── + + +class SuggestionItem(BaseModel): + title: str + description: str + priority: float + source: str # "deadline" | "habit" | "goal" + + +class SuggestionResponse(BaseModel): + mode: str + suggestions: list[SuggestionItem] + quote: str + workload_score: float + + +class SuggestionHistoryOut(BaseModel): + id: uuid.UUID + mode: str + suggestions_json: list | dict + quote: str | None = None + is_completed: bool + is_dismissed: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class SuggestionStatusUpdate(BaseModel): + is_completed: bool | None = None + is_dismissed: bool | None = None + + +# ── Heatmap ── + + +class HeatmapEntry(BaseModel): + date: str # ISO date + focus_minutes: int + mode: str + + +class HeatmapResponse(BaseModel): + user_id: uuid.UUID + entries: list[HeatmapEntry] diff --git a/backend/app/schemas/usage.py b/backend/app/schemas/usage.py new file mode 100644 index 0000000..bd9f554 --- /dev/null +++ b/backend/app/schemas/usage.py @@ -0,0 +1,26 @@ +import uuid +from datetime import date, datetime +from pydantic import BaseModel + + +class AppUsageEntry(BaseModel): + package_name: str + duration_ms: int + date: date + + +class AppUsageBatchCreate(BaseModel): + entries: list[AppUsageEntry] + + +class AppUsageStats(BaseModel): + category: str + user_avg_ms: float + institute_avg_ms: float + user_percentile: float | None = None + + +class RollingAverageResponse(BaseModel): + user_id: uuid.UUID + period_days: int + stats: list[AppUsageStats] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..c3b178d --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, EmailStr + + +class UserOut(BaseModel): + id: uuid.UUID + email: str + display_name: str + avatar_url: str | None = None + focus_mode: str + role: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class UserFocusModeUpdate(BaseModel): + focus_mode: str # "acads" | "clubs" + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__pycache__/__init__.cpython-312.pyc b/backend/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..30fc3d2 Binary files /dev/null and b/backend/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/auth.cpython-312.pyc b/backend/app/services/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..e9bbed9 Binary files /dev/null and b/backend/app/services/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/classroom_sync.cpython-312.pyc b/backend/app/services/__pycache__/classroom_sync.cpython-312.pyc new file mode 100644 index 0000000..b90dffd Binary files /dev/null and b/backend/app/services/__pycache__/classroom_sync.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/gmail_parser.cpython-312.pyc b/backend/app/services/__pycache__/gmail_parser.cpython-312.pyc new file mode 100644 index 0000000..215bbbe Binary files /dev/null and b/backend/app/services/__pycache__/gmail_parser.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/llm.cpython-312.pyc b/backend/app/services/__pycache__/llm.cpython-312.pyc new file mode 100644 index 0000000..321d947 Binary files /dev/null and b/backend/app/services/__pycache__/llm.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc b/backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc new file mode 100644 index 0000000..477ef92 Binary files /dev/null and b/backend/app/services/__pycache__/suggestion_engine.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/ws_manager.cpython-312.pyc b/backend/app/services/__pycache__/ws_manager.cpython-312.pyc new file mode 100644 index 0000000..53bad26 Binary files /dev/null and b/backend/app/services/__pycache__/ws_manager.cpython-312.pyc differ diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..637c0d3 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,69 @@ +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.database import get_db +from app.models.user import User + +logger = logging.getLogger(__name__) +settings = get_settings() +security = HTTPBearer() + + +def create_access_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + \ + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": user_id, "exp": expire} + logger.debug("Creating JWT for user_id=%s, expires=%s", user_id, expire) + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str: + try: + payload = jwt.decode( + token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] + ) + user_id: str = payload.get("sub") + if user_id is None: + logger.warning("JWT decode succeeded but 'sub' claim missing") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + return user_id + except JWTError as exc: + logger.warning("JWT decode failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + user_id = decode_access_token(credentials.credentials) + result = await db.execute(select(User).where(User.id == uuid.UUID(user_id))) + user = result.scalar_one_or_none() + if user is None: + logger.warning("Authenticated user_id=%s not found in DB", user_id) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) + logger.debug("Authenticated user: %s (%s)", user.display_name, user.id) + return user + + +async def require_admin(user: User = Depends(get_current_user)) -> User: + if user.role != "admin": + logger.warning("Non-admin user %s attempted admin action", user.id) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" + ) + return user diff --git a/backend/app/services/classroom_sync.py b/backend/app/services/classroom_sync.py new file mode 100644 index 0000000..0d20f33 --- /dev/null +++ b/backend/app/services/classroom_sync.py @@ -0,0 +1,97 @@ +"""Google Classroom sync — pulls assignment deadlines via Classroom API.""" + +import logging +from datetime import date + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models.user import User +from app.models.calendar import CalendarEvent + +logger = logging.getLogger(__name__) +settings = get_settings() + + +async def sync_classroom_assignments(user: User, db: AsyncSession) -> int: + """ + Fetch assignment deadlines from Google Classroom and upsert + into calendar_events as type='assignment'. + Returns the number of synced assignments. + """ + creds = Credentials( + token=user.google_access_token, + refresh_token=user.google_refresh_token, + client_id=settings.google_client_id, + client_secret=settings.google_client_secret, + token_uri="https://oauth2.googleapis.com/token", + ) + + service = build("classroom", "v1", credentials=creds) + + # List enrolled courses + courses_resp = service.courses().list( + studentId="me", courseStates=["ACTIVE"]).execute() + courses = courses_resp.get("courses", []) + logger.info("Classroom sync for user %s: found %d active courses", + user.id, len(courses)) + + count = 0 + for course in courses: + course_id = course["id"] + course_name = course.get("name", "Unknown Course") + + # List coursework + cw_resp = ( + service.courses() + .courseWork() + .list(courseId=course_id, orderBy="dueDate asc") + .execute() + ) + coursework_list = cw_resp.get("courseWork", []) + + for cw in coursework_list: + due = cw.get("dueDate") + if not due: + continue + + due_date = date( + year=due.get("year", 2026), + month=due.get("month", 1), + day=due.get("day", 1), + ) + + source_id = f"classroom:{course_id}:{cw['id']}" + + # Check if already exists (upsert logic) + existing = await db.execute( + select(CalendarEvent).where( + CalendarEvent.source_id == source_id) + ) + if existing.scalar_one_or_none(): + continue + + event = CalendarEvent( + user_id=user.id, + event_type="assignment", + title=f"[{course_name}] {cw.get('title', 'Assignment')}", + description=cw.get("description"), + event_date=due_date, + source="classroom", + source_id=source_id, + metadata_json={ + "course_id": course_id, + "coursework_id": cw["id"], + "max_points": cw.get("maxPoints"), + }, + moderation_status="approved", + ) + db.add(event) + count += 1 + + logger.info( + "Classroom sync complete for user %s: %d new assignments", user.id, count) + return count diff --git a/backend/app/services/gmail_parser.py b/backend/app/services/gmail_parser.py new file mode 100644 index 0000000..9038680 --- /dev/null +++ b/backend/app/services/gmail_parser.py @@ -0,0 +1,174 @@ +"""Gmail club event extraction — fetches emails, parses with LLM, inserts events.""" + +import json +import logging +from datetime import datetime + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models.user import User +from app.models.calendar import CalendarEvent +from app.services.llm import call_llm + +logger = logging.getLogger(__name__) +settings = get_settings() + +GMAIL_EXTRACTION_PROMPT = """You are an event extractor. Given the following email body from a college club or organization, extract any event information. + +Return a JSON object with these exact fields (use null if not found): +{ + "event_name": "string", + "date": "YYYY-MM-DD", + "time": "HH:MM" (24-hour) or null, + "location": "string or null", + "type": "club_event" +} + +If this email does not contain an event, return: {"event_name": null} + +Email body: +--- +%s +---""" + +# Known club sender patterns (configurable — FR-07 mentions "known club sender patterns") +CLUB_SENDER_PATTERNS = [ + "club@", + "committee@", + "council@", + "fest@", + "hackathon@", + "ieee@", + "acm@", + "gdsc@", + "coding@", + "cultural@", + "sports@", + "tech@", +] + + +async def parse_gmail_club_events(user: User, db: AsyncSession) -> int: + """ + Fetch last 100 emails, filter by club senders, LLM-extract events, + validate with Pydantic, and upsert into calendar_events. + Returns the count of newly inserted events. + """ + creds = Credentials( + token=user.google_access_token, + refresh_token=user.google_refresh_token, + client_id=settings.google_client_id, + client_secret=settings.google_client_secret, + token_uri="https://oauth2.googleapis.com/token", + ) + + service = build("gmail", "v1", credentials=creds) + + # Build query for club-related emails + query_parts = [f"from:{pattern}" for pattern in CLUB_SENDER_PATTERNS] + query = " OR ".join(query_parts) + + messages_resp = ( + service.users() + .messages() + .list(userId="me", q=query, maxResults=100) + .execute() + ) + messages = messages_resp.get("messages", []) + logger.info("Gmail sync for user %s: found %d candidate messages", + user.id, len(messages)) + + count = 0 + llm_calls = 0 + MAX_LLM_CALLS = 10 # NFR-10: cap at 10 LLM calls/user/day for Gmail parsing + + for msg_meta in messages: + if llm_calls >= MAX_LLM_CALLS: + break + + msg = ( + service.users() + .messages() + .get(userId="me", id=msg_meta["id"], format="full") + .execute() + ) + + # Extract plain-text body + body = _extract_body(msg) + if not body: + continue + + source_id = f"gmail:{msg_meta['id']}" + + # Skip if already processed + existing = await db.execute( + select(CalendarEvent).where(CalendarEvent.source_id == source_id) + ) + if existing.scalar_one_or_none(): + continue + + # LLM extraction + prompt = GMAIL_EXTRACTION_PROMPT % body[:3000] # cap body length + llm_calls += 1 + + try: + raw = await call_llm(prompt) + parsed = json.loads(raw) + except (json.JSONDecodeError, Exception) as exc: + logger.warning( + "LLM extraction failed for gmail msg %s: %s", msg_meta["id"], exc) + continue # NFR-05: discard invalid LLM output + + if not parsed.get("event_name"): + continue + + try: + event_date = datetime.strptime(parsed["date"], "%Y-%m-%d").date() + except (ValueError, KeyError, TypeError): + continue + + event = CalendarEvent( + user_id=user.id, + event_type="gmail_event", + title=parsed["event_name"], + event_date=event_date, + event_time=( + datetime.strptime(parsed["time"], "%H:%M").time() + if parsed.get("time") + else None + ), + location=parsed.get("location"), + source="gmail", + source_id=source_id, + moderation_status="approved", + ) + db.add(event) + count += 1 + + logger.info("Gmail sync complete for user %s: %d events inserted, %d LLM calls used", + user.id, count, llm_calls) + return count + + +def _extract_body(msg: dict) -> str | None: + """Extract plain-text body from a Gmail message.""" + import base64 + + payload = msg.get("payload", {}) + + # Simple message + if payload.get("mimeType") == "text/plain": + data = payload.get("body", {}).get("data", "") + return base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore") + + # Multipart + for part in payload.get("parts", []): + if part.get("mimeType") == "text/plain": + data = part.get("body", {}).get("data", "") + return base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore") + + return None diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py new file mode 100644 index 0000000..4b37419 --- /dev/null +++ b/backend/app/services/llm.py @@ -0,0 +1,37 @@ +"""Shared LLM service — wraps Gemini (primary) with error handling.""" + +import logging + +import google.generativeai as genai + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +# Configure Gemini +genai.configure(api_key=settings.gemini_api_key) +model = genai.GenerativeModel("gemini-2.5-flash-lite") + + +async def call_llm(prompt: str, max_tokens: int = 1024) -> str: + """ + Send a prompt to the LLM and return the text response. + Raises on failure — callers should handle exceptions (NFR-05). + """ + logger.info("LLM request: prompt_len=%d, max_tokens=%d", + len(prompt), max_tokens) + try: + response = model.generate_content( + prompt, + generation_config=genai.types.GenerationConfig( + max_output_tokens=max_tokens, + temperature=0.7, + ), + ) + logger.info("LLM response received: response_len=%d", + len(response.text)) + return response.text + except Exception: + logger.exception("LLM call failed") + raise diff --git a/backend/app/services/suggestion_engine.py b/backend/app/services/suggestion_engine.py new file mode 100644 index 0000000..33de2c8 --- /dev/null +++ b/backend/app/services/suggestion_engine.py @@ -0,0 +1,138 @@ +"""Suggestion scoring engine — assembles context, calls LLM, returns ranked items.""" + +import json +import logging +import uuid +from datetime import date, timedelta + +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.career_goal import CareerGoal +from app.models.calendar import CalendarEvent +from app.models.suggestion import SuggestionHistory +from app.schemas.suggestion import SuggestionResponse, SuggestionItem +from app.services.llm import call_llm + +logger = logging.getLogger(__name__) + +SUGGESTION_PROMPT = """You are a smart campus focus assistant called FocusForge. +Given the user's context below, return a JSON object with: +1. "suggestions" — an array of 3-5 ranked action items the user should focus on RIGHT NOW. + Each item: {"title": "...", "description": "...", "priority": 0.0-1.0, "source": "deadline|habit|goal"} +2. "quote" — a short motivational quote personalised to their goals and workload. +3. "workload_score" — a float 0.0-1.0 indicating workload intensity. + +Focus Mode: %s +Career Goals: %s +Upcoming 48h Events: %s +Assignment Count (next 7 days): %d +Current Date: %s + +Respond ONLY with valid JSON, no markdown fences.""" + + +async def generate_suggestions( + user_id: uuid.UUID, mode: str, db: AsyncSession +) -> SuggestionResponse: + """ + Build context payload, call LLM, parse and cache result. + Falls back to a static response on LLM failure (NFR-05). + """ + logger.info("Generating suggestions for user %s, mode=%s", user_id, mode) + today = date.today() + window_end = today + timedelta(days=2) + week_end = today + timedelta(days=7) + + # Gather career goals + goals_result = await db.execute( + select(CareerGoal).where(CareerGoal.user_id == user_id) + ) + goals = [g.title for g in goals_result.scalars().all()] + + # Gather upcoming 48h events + events_result = await db.execute( + select(CalendarEvent).where( + and_( + (CalendarEvent.user_id == user_id) | ( + CalendarEvent.user_id.is_(None)), + CalendarEvent.event_date >= today, + CalendarEvent.event_date <= window_end, + CalendarEvent.moderation_status == "approved", + ) + ) + ) + events = [ + {"title": e.title, "type": e.event_type, "date": e.event_date.isoformat()} + for e in events_result.scalars().all() + ] + + # Count assignments in next 7 days (workload density) + assignment_count_result = await db.execute( + select(func.count()).where( + and_( + CalendarEvent.user_id == user_id, + CalendarEvent.event_type == "assignment", + CalendarEvent.event_date >= today, + CalendarEvent.event_date <= week_end, + ) + ) + ) + assignment_count = assignment_count_result.scalar() or 0 + + # Build prompt + prompt = SUGGESTION_PROMPT % ( + mode, + json.dumps(goals) if goals else "None set", + json.dumps(events) if events else "None upcoming", + assignment_count, + today.isoformat(), + ) + + try: + raw = await call_llm(prompt) + # Strip markdown fences if present + cleaned = raw.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] + if cleaned.endswith("```"): + cleaned = cleaned.rsplit("```", 1)[0] + data = json.loads(cleaned) + logger.info("LLM returned %d suggestions for user %s", + len(data.get("suggestions", [])), user_id) + except (json.JSONDecodeError, Exception) as exc: + # NFR-05: Fallback on LLM failure + logger.warning( + "Suggestion LLM call failed for user %s: %s — using fallback", user_id, exc) + data = { + "suggestions": [ + { + "title": "Review upcoming deadlines", + "description": "Check your calendar for any assignments due this week.", + "priority": 0.8, + "source": "deadline", + } + ], + "quote": "The secret of getting ahead is getting started. — Mark Twain", + "workload_score": 0.5, + } + + suggestions = [SuggestionItem(**s) for s in data.get("suggestions", [])] + quote = data.get("quote", "Stay focused!") + workload_score = float(data.get("workload_score", 0.5)) + + # Cache in suggestion_history + history = SuggestionHistory( + user_id=user_id, + mode=mode, + suggestions_json=data.get("suggestions", []), + quote=quote, + ) + db.add(history) + + return SuggestionResponse( + mode=mode, + suggestions=suggestions, + quote=quote, + workload_score=workload_score, + ) diff --git a/backend/app/services/ws_manager.py b/backend/app/services/ws_manager.py new file mode 100644 index 0000000..6d25233 --- /dev/null +++ b/backend/app/services/ws_manager.py @@ -0,0 +1,88 @@ +"""WebSocket connection manager for Group Pomodoro sessions.""" + +import uuid +import asyncio +import json +import logging +from datetime import datetime, timezone +from collections import defaultdict + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class PomodoroConnectionManager: + """ + Manages WebSocket connections per Pomodoro session. + Broadcasts tick events, tracks heartbeats for low-focus detection. + """ + + def __init__(self): + # session_id -> {user_id: WebSocket} + self.active_connections: dict[str, + dict[str, WebSocket]] = defaultdict(dict) + # session_id -> {user_id: last_heartbeat_time} + self.heartbeats: dict[str, dict[str, datetime]] = defaultdict(dict) + + async def connect(self, session_id: str, user_id: str, websocket: WebSocket): + await websocket.accept() + self.active_connections[session_id][user_id] = websocket + self.heartbeats[session_id][user_id] = datetime.now(timezone.utc) + logger.info("WS connected: session=%s user=%s (total=%d)", + session_id, user_id, self.get_member_count(session_id)) + + def disconnect(self, session_id: str, user_id: str): + self.active_connections[session_id].pop(user_id, None) + self.heartbeats[session_id].pop(user_id, None) + if not self.active_connections[session_id]: + del self.active_connections[session_id] + self.heartbeats.pop(session_id, None) + logger.info("WS disconnected: session=%s user=%s", session_id, user_id) + + def record_heartbeat(self, session_id: str, user_id: str): + self.heartbeats[session_id][user_id] = datetime.now(timezone.utc) + + def get_stale_members(self, session_id: str, grace_seconds: int = 30) -> list[str]: + """Return user IDs with heartbeats older than grace window (low-focus).""" + now = datetime.now(timezone.utc) + stale = [] + for uid, last_hb in self.heartbeats.get(session_id, {}).items(): + delta = (now - last_hb).total_seconds() + if delta > grace_seconds: + stale.append(uid) + if stale: + logger.debug("Stale members in session %s: %s", session_id, stale) + return stale + + async def broadcast(self, session_id: str, message: dict): + """Send a message to all connected members in a session.""" + connections = self.active_connections.get(session_id, {}) + payload = json.dumps(message) + disconnected = [] + for user_id, ws in connections.items(): + try: + await ws.send_text(payload) + except Exception: + disconnected.append(user_id) + for uid in disconnected: + self.disconnect(session_id, uid) + if disconnected: + logger.warning("Broadcast: %d members disconnected from session %s", len( + disconnected), session_id) + + async def send_to_user(self, session_id: str, user_id: str, message: dict): + """Send a private message to one member (e.g. accountability nudge).""" + ws = self.active_connections.get(session_id, {}).get(user_id) + if ws: + try: + await ws.send_text(json.dumps(message)) + except Exception: + self.disconnect(session_id, user_id) + + def get_member_count(self, session_id: str) -> int: + return len(self.active_connections.get(session_id, {})) + + +# Singleton instance +manager = PomodoroConnectionManager() diff --git a/backend/main.py b/backend/main.py index e69de29..d907aec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -0,0 +1,5 @@ +import uvicorn + +if __name__ == "__main__": + print("[FocusForge] Starting uvicorn server on 0.0.0.0:8000") + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cef513c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy[asyncio]==2.0.35 +asyncpg==0.29.0 +alembic==1.13.2 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +httpx==0.27.2 +google-auth==2.35.0 +google-auth-oauthlib==1.2.1 +google-api-python-client==2.149.0 +google-generativeai==0.8.3 +python-multipart==0.0.12 +python-dotenv==1.0.1 +websockets==13.1