From 13c405aaa44633573bbfdb77b1b46cd95438fb84 Mon Sep 17 00:00:00 2001 From: Eeshitha Gone <193770087+eesha264@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:45:05 +0530 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20add=2031-team-crud-and-invites=20+?= =?UTF-8?q?=2032-cloudinary-upload-service=20=E2=80=94=20team=20roles/invi?= =?UTF-8?q?tes/activity=20logs,=20avatar=20upload=20with=20face-crop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/31-team-crud-and-invites.md | 258 ++++++++++++++++++ docs/features/32-cloudinary-upload-service.md | 203 ++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 docs/features/31-team-crud-and-invites.md create mode 100644 docs/features/32-cloudinary-upload-service.md diff --git a/docs/features/31-team-crud-and-invites.md b/docs/features/31-team-crud-and-invites.md new file mode 100644 index 00000000..3c4b7846 --- /dev/null +++ b/docs/features/31-team-crud-and-invites.md @@ -0,0 +1,258 @@ +# 31 — Team CRUD & Invites + +**NEW document** — Team creation, invite codes, join/leave, ownership transfer, admin roles, member management, activity logs + +--- + +## Feature Summary + +Teams are collaborative groups in Zync. A team has an owner, admins, and members. Teams can be created with initial invites, joined via invite code, and managed through role-based permissions. Features include ownership transfer (requires security PIN), member promotion/demotion, activity logging, and team deletion. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ TeamsView.tsx │ +│ ├─ "My Teams" list (GET /api/teams/mine) │ +│ ├─ "Owned Teams" list (GET /api/teams/owned) │ +│ ├─ Create Team dialog (POST /api/teams/create) │ +│ └─ Join Team dialog (POST /api/teams/join) │ +│ │ +│ TeamDetail.tsx │ +│ ├─ Member list with roles (owner/admin/member) │ +│ ├─ Invite members (POST /api/teams/invite) │ +│ ├─ Remove member (DELETE /:teamId/members/:uid) │ +│ ├─ Promote/Demote admin │ +│ ├─ Transfer ownership (requires PIN) │ +│ ├─ Leave team (POST /:teamId/leave) │ +│ ├─ Delete team (DELETE /:teamId, requires PIN) │ +│ ├─ Rename team (PATCH /:teamId/name) │ +│ └─ Activity log (GET /:teamId/activity) │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ROUTES ──────────────────────┐ +│ │ +│ backend/routes/teamRoutes.js │ +│ │ +│ GET /owned → owned teams │ +│ GET /mine → all user teams │ +│ POST /create → create team │ +│ POST /join → join via invite code │ +│ DELETE /:teamId → delete team (PIN) │ +│ DELETE /:teamId/members/:uid → remove member │ +│ POST /invite → send invite email │ +│ POST /:teamId/leave → leave team │ +│ GET /:teamId/details → team details │ +│ POST /:teamId/transfer-ownership → transfer (PIN) │ +│ PATCH /:teamId/name → rename team │ +│ POST /:teamId/accept-member → accept join request │ +│ POST /:teamId/reject-member → reject join request │ +│ POST /:teamId/promote-admin → promote to admin │ +│ POST /:teamId/demote-admin → demote to member │ +│ GET /:teamId/activity → activity logs │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/teamRoutes.js` + +### GET /owned (lines 137-163) +- **Auth:** required +- **Logic:** `Team.find({ ownerUid: uid }).lean()` +- **Response:** Array of teams owned by user + +### GET /mine (lines 166-192) +- **Auth:** required +- **Logic:** `Team.find({ $or: [{ ownerUid: uid }, { members: uid }, { admins: uid }] }).lean()` +- **Response:** All teams user is part of (owner, admin, or member) + +### POST /create (lines 195-290) +- **Auth:** required +- **Input:** `{ name, type?, initialInvites?: string[] }` +- **Logic:** + 1. Generate invite code: `Math.random().toString(36).substring(2, 10).toUpperCase()` + 2. Create Team: `{ name, ownerUid: uid, members: [uid], inviteCode, type }` + 3. If `initialInvites`: send invite emails to each + 4. Create Activity log: "Team created" +- **Response:** Created team with invite code + +### POST /join (lines 293-351) +- **Auth:** required +- **Input:** `{ inviteCode }` +- **Logic:** + 1. `Team.findOne({ inviteCode })` + 2. If not found: 404 + 3. If already member: 400 "Already a member" + 4. If team requires approval: add to `pendingMembers` array + 5. If open: add to `members` array + 6. Create Activity log: "User joined" +- **Response:** Updated team + +### DELETE /:teamId (lines 354-435) +- **Auth:** required +- **Input:** `{ pin }` — security PIN for deletion +- **Logic:** + 1. Find team, verify ownership + 2. Verify PIN: `team.pin === pin` (hashed comparison) + 3. Delete team + 4. Create Activity log: "Team deleted" +- **Response:** `{ message: "Team deleted" }` + +### DELETE /:teamId/members/:memberUid (lines 438-513) +- **Auth:** required +- **Logic:** + 1. Find team, verify owner or admin + 2. Cannot remove owner + 3. Remove from `members` and `admins` arrays + 4. Create Activity log: "Member removed" +- **Response:** Updated team + +### POST /invite (lines 516-576) +- **Auth:** required +- **Input:** `{ email }` +- **Logic:** + 1. Find team (from body or query) + 2. Send invite email with join link: `${FRONTEND_URL}/teams/join?code=${inviteCode}` + 3. Use `sendZyncEmail()` from mailer service +- **Response:** `{ message: "Invitation sent" }` + +### POST /:teamId/leave (lines 579-654) +- **Auth:** required +- **Logic:** + 1. Find team + 2. If owner: cannot leave (must transfer ownership first) + 3. Remove from `members` and `admins` + 4. Create Activity log: "User left" +- **Response:** `{ message: "Left team" }` + +### GET /:teamId/details (lines 657-731) +- **Auth:** required +- **Logic:** + 1. Find team + 2. Verify membership + 3. Populate member details (names, avatars from User model) + 4. Return full team details +- **Response:** Team with populated member info + +### POST /:teamId/transfer-ownership (lines 734-777) +- **Auth:** required +- **Input:** `{ newOwnerId, pin }` +- **Logic:** + 1. Verify current ownership + 2. Verify PIN + 3. Verify `newOwnerId` is current member + 4. Set `ownerUid = newOwnerId` + 5. Demote old owner to admin + 6. Create Activity log: "Ownership transferred" +- **Response:** Updated team + +### PATCH /:teamId/name (lines 780-843) +- **Auth:** required +- **Input:** `{ name }` +- **Logic:** + 1. Verify ownership or admin + 2. `Team.findByIdAndUpdate(teamId, { name })` + 3. Create Activity log: "Team renamed" +- **Response:** Updated team + +### POST /:teamId/accept-member (lines 846-884) +- **Auth:** required (owner/admin) +- **Input:** `{ userId }` +- **Logic:** Move from `pendingMembers` to `members` +- **Response:** Updated team + +### POST /:teamId/reject-member (lines 887-924) +- **Auth:** required (owner/admin) +- **Input:** `{ userId }` +- **Logic:** Remove from `pendingMembers` +- **Response:** Updated team + +### POST /:teamId/promote-admin (lines 927-955) +- **Auth:** required (owner) +- **Input:** `{ userId }` +- **Logic:** Add to `admins` array +- **Response:** Updated team + +### POST /:teamId/demote-admin (lines 958-982) +- **Auth:** required (owner) +- **Input:** `{ userId }` +- **Logic:** Remove from `admins` array +- **Response:** Updated team + +### GET /:teamId/activity (lines 985+) +- **Auth:** required +- **Logic:** `Activity.find({ teamId }).sort({ createdAt: -1 }).limit(50).lean()` +- **Response:** Array of activity log entries + +--- + +## Database Layer + +### Team Model +**File:** `backend/models/Team.js` + +| Field | Type | Required | Index | Notes | +|---|---|---|---|---| +| `name` | String | yes | — | Team name | +| `ownerUid` | String | yes | yes | Firebase UID of owner | +| `admins` | String[] | no | — | Array of admin UIDs | +| `members` | String[] | yes | — | Array of member UIDs (includes owner) | +| `pendingMembers` | String[] | no | — | Users awaiting approval | +| `inviteCode` | String | yes | unique | 8-char random code | +| `type` | String | no | — | Team category | +| `pin` | String | no | — | Hashed security PIN | +| `createdAt` | Date | auto | — | | + +### Activity Model +**File:** `backend/models/Activity.js` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `teamId` | ObjectId | yes | Ref: Team | +| `action` | String | yes | e.g., "member_joined", "team_created" | +| `actorUid` | String | yes | Who performed the action | +| `targetUid` | String | no | Who was affected | +| `metadata` | Mixed | no | Additional context | +| `createdAt` | Date | auto | | + +--- + +## Role Hierarchy + +| Role | Permissions | +|---|---| +| **Owner** | Everything: delete, transfer, promote/demote, remove members, rename | +| **Admin** | Remove members, accept/reject join requests, rename | +| **Member** | View team, leave team | + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| Team not found | 404 | `{ error: "Team not found" }` | +| Invalid invite code | 404 | `{ error: "Invalid invite code" }` | +| Already a member | 400 | `{ error: "Already a member" }` | +| Invalid PIN | 403 | `{ error: "Invalid PIN" }` | +| Not owner/admin | 403 | `{ error: "Unauthorized" }` | +| Owner cannot leave | 400 | `{ error: "Transfer ownership first" }` | +| Server error | 500 | `{ error: "Server error" }` | + +--- + +## Cross-References + +- [14-project-crud.md](./14-project-crud.md) — Projects can have team members +- [28-email-service-notifications.md](./28-email-service-notifications.md) — Team invite emails +- [05-database-schema-and-models.md](./05-database-schema-and-models.md) — Team + Activity models diff --git a/docs/features/32-cloudinary-upload-service.md b/docs/features/32-cloudinary-upload-service.md new file mode 100644 index 00000000..3a633490 --- /dev/null +++ b/docs/features/32-cloudinary-upload-service.md @@ -0,0 +1,203 @@ +# 32 — Cloudinary Upload Service + +**NEW document** — Avatar upload, image optimization, buffer streaming, public ID extraction, asset deletion + +--- + +## Feature Summary + +Cloudinary is used for profile photo uploads and image management. The service handles uploading from file paths or memory buffers, automatic face-cropping for avatars (400x400), extracting public IDs from URLs for deletion, and cleaning up old assets when users update their photos. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ SettingsView.tsx → Profile tab │ +│ ├─ Avatar upload (file input) │ +│ │ └─ POST /api/users/me/avatar (multipart/form-data) │ +│ ├─ Image preview before upload │ +│ └─ Old avatar deleted on new upload │ +│ │ +│ Chat file attachments: │ +│ └─ POST /api/chat/upload (multipart) │ +│ └─ Uses uploadImageBuffer for chat files │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/services/cloudinaryService.js (227 lines) │ +│ │ +│ Functions: │ +│ ├─ uploadProfilePhoto(filePath, uid) │ +│ │ ├─ Folder: zync-profiles │ +│ │ ├─ Public ID: profile_{uid}_{timestamp} │ +│ │ ├─ Transform: 400x400, crop=fill, gravity=face │ +│ │ └─ Returns: { secure_url, public_id, ... } │ +│ │ │ +│ ├─ uploadImageBuffer(buffer, folder, publicId) │ +│ │ ├─ Uses upload_stream (no temp file needed) │ +│ │ ├─ overwrite: true │ +│ │ └─ Returns: { secure_url, public_id, ... } │ +│ │ │ +│ ├─ extractPublicId(url) │ +│ │ ├─ Parses Cloudinary URL to get public_id │ +│ │ ├─ Handles version prefixes (v123456) │ +│ │ └─ Strips file extension │ +│ │ │ +│ └─ deleteCloudinaryAsset(url) │ +│ ├─ Extract public_id from URL │ +│ └─ cloudinary.uploader.destroy(publicId) │ +│ │ +│ Callers: │ +│ ├─ userRoutes.js → /me/avatar (uploadProfilePhoto) │ +│ ├─ userRoutes.js → /me/avatar (deleteCloudinaryAsset) │ +│ └─ chatRoutes.js → /upload (uploadImageBuffer) │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/services/cloudinaryService.js` (227 lines) + +### Configuration (lines 86-93) +```js +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); +``` + +### extractPublicId(url) (lines 101-140) +Parses a Cloudinary URL to extract the `public_id` needed for deletion. + +**URL format:** +``` +https://res.cloudinary.com/{cloud_name}/image/upload/v{version}/{folder}/{public_id}.{ext} +``` + +**Algorithm:** +1. Validate URL contains `cloudinary.com` +2. Split URL by `/` +3. Find `upload` segment index +4. Skip version prefix if present (`v1234567890`) +5. Rejoin remaining parts (handles nested folders) +6. Strip file extension (last `.`) +7. Return `public_id` + +**Example:** +``` +Input: https://res.cloudinary.com/zync/image/upload/v1700000000/zync-profiles/profile_abc123_1700000000.jpg +Output: zync-profiles/profile_abc123_1700000000 +``` + +### deleteCloudinaryAsset(url) (lines 148-163) +```js +const deleteCloudinaryAsset = async (url) => { + const publicId = extractPublicId(url); + if (!publicId) return null; + try { + console.log(`Deleting Cloudinary asset: ${publicId}`); + return await cloudinary.uploader.destroy(publicId); + } catch (error) { + console.error('Cloudinary deletion failed:', error); + throw error; + } +}; +``` +- **Used when:** User uploads new avatar → old avatar URL is deleted +- **Non-blocking:** If extraction fails (null), returns null (no crash) + +### uploadProfilePhoto(filePath, uid) (lines 172-191) +```js +const uploadProfilePhoto = async (filePath, uid) => { + const publicId = `profile_${uid}_${Date.now()}`; + return await cloudinary.uploader.upload(filePath, { + folder: 'zync-profiles', + public_id: publicId, + transformation: [ + { width: 400, height: 400, crop: 'fill', gravity: 'face' }, + ], + }); +}; +``` +- **Unique public_id:** `profile_{uid}_{timestamp}` — prevents cache issues +- **Transformation:** 400x400, fill crop, face gravity (auto-centers on face) +- **Folder:** `zync-profiles` — organized in Cloudinary dashboard + +### uploadImageBuffer(buffer, folder, publicId) (lines 200-218) +```js +const uploadImageBuffer = (buffer, folder, publicId) => { + return new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + { folder, public_id: publicId, overwrite: true }, + (error, result) => { + if (error) return reject(error); + resolve(result); + } + ); + uploadStream.end(buffer); + }); +}; +``` +- **Stream-based:** No temp file needed — uploads directly from buffer +- **overwrite: true:** If same public_id exists, replaces it +- **Used for:** Chat file attachments, optimized image uploads + +--- + +## Avatar Upload Flow + +``` +1. User selects image file in Settings +2. Frontend: POST /api/users/me/avatar (multipart/form-data) +3. Backend (userRoutes.js): + a. multer receives file → temp path + b. uploadProfilePhoto(filePath, uid) + → Cloudinary upload with face-crop transformation + → Returns { secure_url, public_id } + c. If user had old avatar: + → deleteCloudinaryAsset(oldAvatarUrl) + → Old image removed from Cloudinary + d. Update User.photoURL = new secure_url + e. Delete temp file (fs.unlink) + f. Return { photoURL: new secure_url } +``` + +--- + +## Error Paths + +| Scenario | Handling | +|---|---| +| Cloudinary not configured | Upload fails, error thrown to caller | +| Invalid URL (not Cloudinary) | `extractPublicId` returns `null` | +| Upload fails | Error thrown, caller returns 500 | +| Deletion fails | Error thrown, logged, non-blocking | +| Buffer upload stream error | Promise rejects, caller catches | + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `CLOUDINARY_CLOUD_NAME` | Yes | Cloudinary cloud name | +| `CLOUDINARY_API_KEY` | Yes | Cloudinary API key | +| `CLOUDINARY_API_SECRET` | Yes | Cloudinary API secret | + +--- + +## Cross-References + +- [09-user-profile-management.md](./09-user-profile-management.md) — Avatar upload endpoint +- [23-instant-chat-system.md](./23-instant-chat-system.md) — Chat file attachments +- [04-service-inventory.md](./04-service-inventory.md) — Cloudinary service listing From 1eb1d439215b7ee59539f51cb89a3546368ebf59 Mon Sep 17 00:00:00 2001 From: Eeshitha Gone <193770087+eesha264@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:45:44 +0530 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20add=2033-redis-cache-layer=20+=2034?= =?UTF-8?q?-location-detection-geoip=20=E2=80=94=20fail-open=20JSON=20cach?= =?UTF-8?q?e,=20IP-based=20geolocation=20with=20timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/33-redis-cache-layer.md | 181 +++++++++++++++++++ docs/features/34-location-detection-geoip.md | 179 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 docs/features/33-redis-cache-layer.md create mode 100644 docs/features/34-location-detection-geoip.md diff --git a/docs/features/33-redis-cache-layer.md b/docs/features/33-redis-cache-layer.md new file mode 100644 index 00000000..2e96dd54 --- /dev/null +++ b/docs/features/33-redis-cache-layer.md @@ -0,0 +1,181 @@ +# 33 — Redis Cache Layer + +**NEW document** — Cache utility, JSON get/set with TTL, invalidation by key and pattern, fail-open design + +--- + +## Feature Summary + +The Redis cache layer provides a simple JSON get/set/invalidate API on top of the Redis client. All operations are fail-open — if Redis is down, cache misses return null and writes return false without crashing the app. Used for project lists, user profiles, GitHub repos, and architecture analysis caching. + +--- + +## Architecture Diagram + +``` +┌─────────────────── BACKEND SERVICES ────────────────────┐ +│ │ +│ backend/utils/cache.js (131 lines) │ +│ │ +│ API: │ +│ ├─ getJson(key) → object | null │ +│ ├─ setJson(key, value, ttlSeconds) → true | false │ +│ ├─ invalidate(...keys) → void │ +│ └─ delByPattern(pattern) → count │ +│ │ +│ Fail-Open Design: │ +│ ├─ Redis down → getJson returns null (cache miss) │ +│ ├─ Redis down → setJson returns false (no crash) │ +│ ├─ Redis down → invalidate is no-op │ +│ └─ Redis down → delByPattern returns 0 │ +│ │ +│ Consumers: │ +│ ├─ projectRoutes.js → projects:{uid} (300s TTL) │ +│ ├─ userRoutes.js → user:{uid} (300s TTL) │ +│ ├─ github.js → github:repos:{uid} (60s TTL) │ +│ ├─ usageService.js → quota counters (7d/24h TTL) │ +│ └─ architectureAnalysisCache → in-memory Map (6h TTL) │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/utils/cache.js` (131 lines) + +### getJson(key) (lines 78-89) +```js +async function getJson(key) { + if (!isAvailable()) return null; + try { + const raw = await getRedisClient().get(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (err) { + console.warn(`[Cache] getJson failed for "${key}":`, err.message); + return null; + } +} +``` +- **Fail-open:** Redis down → `null` (treated as cache miss) +- **JSON parsing:** Automatically parses stringified JSON +- **Error handling:** Parse errors return null (no crash) + +### setJson(key, value, ttlSeconds) (lines 91-101) +```js +async function setJson(key, value, ttlSeconds) { + if (!isAvailable()) return false; + try { + await getRedisClient().set(key, JSON.stringify(value), { EX: ttlSeconds }); + return true; + } catch (err) { + console.warn(`[Cache] setJson failed for "${key}":`, err.message); + return false; + } +} +``` +- **TTL:** `EX: ttlSeconds` — auto-expiry, no cron needed +- **Fail-open:** Redis down → `false` (caller proceeds without cache) + +### invalidate(...keys) (lines 103-111) +```js +async function invalidate(...keys) { + if (!isAvailable() || keys.length === 0) return; + try { + await getRedisClient().del(...keys); + } catch (err) { + console.warn(`[Cache] invalidate failed:`, err.message); + } +} +``` +- **Batch delete:** Accepts multiple keys in one call +- **Used by:** `invalidateProjectCache()` — deletes `projects:{uid}` for owner + team + +### delByPattern(pattern) (lines 113-131) +```js +async function delByPattern(pattern) { + if (!isAvailable()) return 0; + try { + const client = getRedisClient(); + let deleted = 0; + for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) { + await client.del(key); + deleted++; + } + return deleted; + } catch (err) { + console.warn(`[Cache] delByPattern failed:`, err.message); + return 0; + } +} +``` +- **Uses SCAN (not KEYS):** Non-blocking, doesn't freeze Redis +- **Batch size:** 100 keys per SCAN iteration +- **Pattern examples:** `projects:*`, `user:abc*`, `github:repos:*` + +--- + +## Cache Key Conventions + +| Key Pattern | TTL | Consumer | Purpose | +|---|---|---|---| +| `projects:{uid}` | 300s | projectRoutes.js | User's project list | +| `user:{uid}` | 300s | userRoutes.js | User profile | +| `github:repos:{uid}` | 60s | github.js | GitHub repo list | +| `zync:kilo:user:{uid}:gens:wk:{week}` | 7d | usageService.js | Weekly quota counter | +| `zync:kilo:day:gens:{date}` | 24h | usageService.js | Daily global counter | +| `zync:kilo:user:{uid}:chat:last:{date}` | 24h | usageService.js | Chat throttle | + +--- + +## Invalidation Strategy + +### Write-Through Invalidation +When data changes, cache is invalidated immediately: +``` +1. Update database (MongoDB) +2. invalidate(cacheKey) +3. Next read: cache miss → fetch from DB → repopulate cache +``` + +### Project Cache Invalidation +```js +async function invalidateProjectCache(project, additionalUids = []) { + const uids = [...new Set([project.ownerUid, ...(project.team || []), ...additionalUids].filter(Boolean))]; + const keys = uids.map((uid) => `projects:${uid}`); + await cache.invalidate(...keys); +} +``` +- Invalidates cache for owner AND all team members +- Called after: create, update, delete project, task changes + +--- + +## Fail-Open Philosophy + +| Scenario | getJson | setJson | invalidate | +|---|---|---|---| +| Redis connected | Returns cached data | Stores in Redis | Deletes keys | +| Redis down | Returns `null` (miss) | Returns `false` | No-op | +| Redis error | Returns `null` (miss) | Returns `false` | No-op | + +**Result:** Application works normally without Redis. Performance degrades (more DB queries) but functionality is preserved. + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `REDIS_URL` | Yes (prod) | Redis connection URL | + +--- + +## Cross-References + +- [03-performance-caching-strategy.md](./03-performance-caching-strategy.md) — Caching overview +- [14-project-crud.md](./14-project-crud.md) — Project cache invalidation +- [27-usage-service-quota.md](./27-usage-service-quota.md) — Redis quota counters +- [21-github-oauth-integration.md](./21-github-oauth-integration.md) — GitHub repo caching diff --git a/docs/features/34-location-detection-geoip.md b/docs/features/34-location-detection-geoip.md new file mode 100644 index 00000000..c0345ea2 --- /dev/null +++ b/docs/features/34-location-detection-geoip.md @@ -0,0 +1,179 @@ +# 34 — Location Detection & Geo-IP + +**NEW document** — IP-based geolocation, user location storage, timezone detection, privacy considerations + +--- + +## Feature Summary + +Zync detects user location from their IP address for profile enrichment and timezone-aware features. The backend uses a geo-IP API to convert the client IP to city/country/coordinates, stores it on the User model, and returns it to the frontend for display. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ SettingsView.tsx → Profile tab │ +│ ├─ Location display (city, country) │ +│ ├─ "Detect my location" button │ +│ │ └─ POST /api/users/detect-location │ +│ └─ Manual location override │ +│ │ +│ DashboardHome.tsx │ +│ └─ Shows user timezone for session scheduling │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ userRoutes.js → POST /detect-location │ +│ │ +│ 1. Extract client IP from request: │ +│ ├─ req.headers['x-forwarded-for'] (proxy) │ +│ └─ req.socket.remoteAddress (direct) │ +│ │ +│ 2. Call geo-IP API: │ +│ ├─ GET https://ipapi.co/{ip}/json/ │ +│ │ Returns: { city, country, latitude, longitude, │ +│ │ timezone, ... } │ +│ └─ Fallback: if API fails, use generic location │ +│ │ +│ 3. Store on User model: │ +│ ├─ User.location = { city, country, lat, lng } │ +│ ├─ User.timezone = timezone string │ +│ └─ User.locationDetectedAt = new Date() │ +│ │ +│ 4. Return location data to frontend │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/userRoutes.js` + +### POST /detect-location +- **Auth:** required +- **Logic:** + 1. **Extract client IP:** + ```js + const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() + || req.socket.remoteAddress; + ``` + - Handles proxy headers (Render, Vercel, Cloudflare) + - Falls back to direct socket address + 2. **Skip for localhost:** + ```js + if (ip === '::1' || ip === '127.0.0.1') { + return res.json({ city: 'Local', country: 'Unknown', timezone: 'UTC' }); + } + ``` + 3. **Call geo-IP API:** + ```js + const response = await axios.get(`https://ipapi.co/${ip}/json/`); + const { city, country_name, latitude, longitude, timezone } = response.data; + ``` + 4. **Store on User:** + ```js + await User.findOneAndUpdate( + { uid: req.user.uid }, + { + $set: { + 'location.city': city, + 'location.country': country_name, + 'location.lat': latitude, + 'location.lng': longitude, + 'location.timezone': timezone, + 'locationDetectedAt': new Date(), + } + } + ); + ``` + 5. **Return location:** + ```js + res.json({ city, country: country_name, lat: latitude, lng: longitude, timezone }); + ``` + +### Error Handling +- **Geo-IP API rate limited:** Return generic location, log warning +- **Geo-IP API down:** Return `{ city: 'Unknown', country: 'Unknown' }` +- **Invalid IP:** Return generic location +- **Network error:** Return 500 with error message + +--- + +## Frontend Trace + +### Location Detection Flow +1. User clicks "Detect my location" in Settings +2. Frontend calls `POST /api/users/detect-location` +3. Backend extracts IP, calls geo-IP API, stores result +4. Frontend receives location data +5. Updates `useMe` query (TanStack Query) +6. Displays: "San Francisco, United States (PST)" + +### Manual Override +- User can manually enter their city/country +- Stored in `User.location` (same fields as detected) +- `User.locationDetectedAt` set to null for manual entries + +--- + +## Database Layer + +### User.location (Mongoose Mixed field) +```js +{ + city: String, + country: String, + lat: Number, + lng: Number, + timezone: String, // e.g., "America/Los_Angeles" + manual: Boolean // true if user entered manually +} +``` + +### User.locationDetectedAt +- Date of last auto-detection +- Used to determine if location is stale (>30 days = re-detect) + +--- + +## Privacy Considerations + +- **IP stored temporarily:** Only used for geo-IP lookup, not persisted +- **Location is optional:** User can disable location detection +- **Manual override:** User can set any location regardless of IP +- **No tracking:** Location is detected once, not continuously tracked +- **Precision:** City-level only (no street-level tracking) + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| Localhost IP | 200 | `{ city: 'Local', country: 'Unknown' }` | +| Geo-IP API fails | 200 | `{ city: 'Unknown', country: 'Unknown' }` | +| Server error | 500 | `{ error: error.message }` | + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `GEO_IP_API_URL` | No | Default: `https://ipapi.co` | + +--- + +## Cross-References + +- [09-user-profile-management.md](./09-user-profile-management.md) — Profile update endpoint +- [05-database-schema-and-models.md](./05-database-schema-and-models.md) — User model location fields From 6e35fade3a6ecf100b063f55e02d55797b4e5cba Mon Sep 17 00:00:00 2001 From: Eeshitha Gone <193770087+eesha264@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:49:29 +0530 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20add=2035-40=20=E2=80=94=20architect?= =?UTF-8?q?ure=20agent=20chat,=20project=20generation,=20calendar/holidays?= =?UTF-8?q?,=20file=20upload,=20user=20search,=20Google=20OAuth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/35-architecture-agent-chat.md | 128 ++++++++++ docs/features/36-project-generation.md | 122 ++++++++++ docs/features/37-calendar-and-holidays.md | 96 ++++++++ docs/features/38-file-upload-service.md | 161 +++++++++++++ docs/features/39-user-search-and-discovery.md | 173 ++++++++++++++ docs/features/40-google-oauth-integration.md | 226 ++++++++++++++++++ 6 files changed, 906 insertions(+) create mode 100644 docs/features/35-architecture-agent-chat.md create mode 100644 docs/features/36-project-generation.md create mode 100644 docs/features/37-calendar-and-holidays.md create mode 100644 docs/features/38-file-upload-service.md create mode 100644 docs/features/39-user-search-and-discovery.md create mode 100644 docs/features/40-google-oauth-integration.md diff --git a/docs/features/35-architecture-agent-chat.md b/docs/features/35-architecture-agent-chat.md new file mode 100644 index 00000000..0ade4a61 --- /dev/null +++ b/docs/features/35-architecture-agent-chat.md @@ -0,0 +1,128 @@ +# 35 — Architecture Agent Chat + +**NEW document** — AI chat endpoint for architecture questions, quota integration, streaming responses, context-aware prompts + +--- + +## Feature Summary + +The architecture agent is an AI chatbot that answers questions about a project's architecture. It uses the Kilo Code Gateway to process user questions with project context (architecture analysis, repo info) and returns AI-generated responses. Includes quota enforcement via the usage service. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ ProjectWorkspace.tsx → AI Chat tab │ +│ ├─ ChatInterface.tsx │ +│ │ ├─ Message list (user + AI messages) │ +│ │ ├─ Input box with send button │ +│ │ └─ Quota indicator ("2/4 used this week") │ +│ ├─ Context: current project architecture analysis │ +│ └─ Suggestions: pre-built question chips │ +│ │ +│ Hooks: │ +│ ├─ useArchitectureChat.ts — chat mutation │ +│ └─ useQuota.ts — GET /api/architecture-agent/quota │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/routes/architectureAgentRoutes.js │ +│ │ +│ POST /chat → AI chat with architecture context │ +│ GET /quota → user's remaining generations │ +│ │ +│ Chat flow: │ +│ 1. Verify Kilo Gateway configured (503 if not) │ +│ 2. chatThrottle(uid) → enforce min gap (2s) │ +│ 3. Build prompt: user question + project context │ +│ 4. Call Kilo Code Gateway /v1/chat/completions │ +│ 5. Return AI response │ +│ │ +│ Quota: │ +│ ├─ chatThrottle: soft pace limit (2s between calls) │ +│ └─ getUserQuota: weekly generation count │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/architectureAgentRoutes.js` + +### POST /chat (lines 18-100) +- **Auth:** required +- **Input:** `{ message, projectId?, architectureContext? }` +- **Logic:** + 1. **Check gateway configured:** + ```js + if (!KILO_CODE_GATEWAY_URL || !KILO_CODE_GATEWAY_API_KEY) { + return res.status(503).json({ error: 'Architecture agent is not configured.' }); + } + ``` + 2. **Chat throttle:** + ```js + const waitMs = await chatThrottle(req.user.uid); + if (waitMs > 0) { + return res.status(429).json({ error: 'Please wait', waitMs }); + } + ``` + 3. **Build prompt:** User question + architecture context (if provided) + 4. **Call Kilo Gateway:** + ```js + const response = await axios.post( + `${KILO_CODE_GATEWAY_URL}/v1/chat/completions`, + { + model: KILO_CODE_GATEWAY_MODEL, + messages: [ + { role: 'system', content: 'You are an expert software architect...' }, + { role: 'user', content: prompt } + ], + temperature: 0.3, + }, + { headers: { Authorization: `Bearer ${KILO_CODE_GATEWAY_API_KEY}` }, timeout: 60000 } + ); + ``` + 5. **Return response:** `res.json({ reply: response.data?.choices?.[0]?.message?.content })` + +### GET /quota (lines 102-105) +- **Auth:** required +- **Logic:** `const quota = await getUserQuota(req.user.uid)` +- **Response:** `{ gensUsed, gensLimit, resetOn }` + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| Gateway not configured | 503 | `{ error: 'Architecture agent is not configured.' }` | +| Throttled (too fast) | 429 | `{ error: 'Please wait', waitMs }` | +| Gateway timeout | 500 | `{ error: 'AI service timeout' }` | +| Gateway error | 500 | `{ error: 'AI service error' }` | +| No token | 401 | Unauthorized | + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `KILO_CODE_GATEWAY_URL` | Yes | Gateway API URL | +| `KILO_CODE_GATEWAY_API_KEY` | Yes | Gateway API key | +| `KILO_CODE_GATEWAY_MODEL` | No | Model (default: `kilo-auto/free`) | + +--- + +## Cross-References + +- [25-ai-architecture-analysis.md](./25-ai-architecture-analysis.md) — Architecture analysis endpoint +- [26-kilo-code-gateway.md](./26-kilo-code-gateway.md) — Gateway service +- [27-usage-service-quota.md](./27-usage-service-quota.md) — Quota and throttle diff --git a/docs/features/36-project-generation.md b/docs/features/36-project-generation.md new file mode 100644 index 00000000..7da53e89 --- /dev/null +++ b/docs/features/36-project-generation.md @@ -0,0 +1,122 @@ +# 36 — Project Generation + +**NEW document** — AI-powered project scaffolding, architecture blueprint generation, auto-create steps and tasks + +--- + +## Feature Summary + +Users can generate a complete project blueprint from a name and description. The AI generates a full architecture (frontend, backend, database, API design), and Zync auto-creates the project with default steps and suggested tasks based on the AI output. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ DashboardHome.tsx │ +│ ├─ "Generate Project" button │ +│ │ └─ GenerateProjectDialog.tsx │ +│ │ ├─ Name input │ +│ │ ├─ Description textarea │ +│ │ ├─ Model selector (optional) │ +│ │ └─ Submit → POST /api/generate-project │ +│ │ │ +│ ├─ GeneratedProjectPreview.tsx │ +│ │ ├─ Architecture blueprint viewer │ +│ │ ├─ Suggested pages, components, APIs │ +│ │ ├─ "Create Project" confirm button │ +│ │ └─ "Discard" button │ +│ │ │ +│ └─ Quota chip: "2/4 used this week" │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/routes/generateProjectRoutes.js │ +│ │ +│ POST / → generate project blueprint + create │ +│ │ +│ Flow: │ +│ 1. checkAndReserveGen(uid) → quota check │ +│ 2. generateArchitectureWithKilo({ name, description }) │ +│ 3. Create Project from blueprint │ +│ 4. Create Steps from AI suggested pages/screens │ +│ 5. Create ProjectTasks from AI suggested APIs/features │ +│ 6. Invalidate project cache │ +│ 7. If gateway fails: refundGen(uid, key) │ +│ 8. Return project + architecture blueprint │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/generateProjectRoutes.js` + +### POST / (line 199+) +- **Auth:** required +- **Input:** `{ name, description, ownerId }` +- **Logic:** + 1. **Reserve quota:** `checkAndReserveGen(uid)` — 429 if exceeded + 2. **Generate architecture:** `generateArchitectureWithKilo({ projectName: name, projectDescription: description })` + 3. **Create Project:** `Project.create({ name, description, ownerUid: uid })` + 4. **Create Steps from AI:** For each suggested page/screen, create a Step + 5. **Create Tasks from AI:** For each suggested API endpoint, create a ProjectTask + 6. **Invalidate cache:** `cache.invalidate('projects:' + uid)` + 7. **On failure:** `refundGen(uid, key)` — refund quota + 8. **Return:** `{ project, architecture, steps, tasks }` + +### AI Blueprint Schema +```json +{ + "highLevel": "Detailed architecture explanation", + "frontend": { + "structure": "Frontend organization", + "pages": ["Home", "Dashboard", "Settings", ...], + "components": ["Header", "Sidebar", "Card", ...], + "routing": "React Router DOM" + }, + "backend": { + "structure": "Modular controller-service pattern", + "apis": ["/api/auth/login", "/api/users", ...], + "controllers": ["AuthController", "UserController", ...], + "services": ["AuthService", "EmailService", ...], + "authFlow": "Firebase Auth JWT" + }, + "database": { + "design": "Document-based NoSQL", + "collections": ["users", "projects", "tasks", ...], + "relationships": ["User → Projects (1:N)", ...] + }, + "apiFlow": "REST API with TanStack Query caching", + "integrations": ["React", "Express", "MongoDB", ...] +} +``` + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| Quota exceeded | 429 | `{ error: "Generation limit reached" }` | +| Gateway not configured | 503 | `{ error: "AI service not configured" }` | +| Gateway timeout | 500 | Error + quota refunded | +| Invalid AI response | 500 | Error + quota refunded | +| Server error | 500 | `{ error: "Server error" }` | + +--- + +## Cross-References + +- [25-ai-architecture-analysis.md](./25-ai-architecture-analysis.md) — Analyze existing repos +- [26-kilo-code-gateway.md](./26-kilo-code-gateway.md) — generateArchitectureWithKilo +- [27-usage-service-quota.md](./27-usage-service-quota.md) — Quota management +- [14-project-crud.md](./14-project-crud.md) — Project creation +- [15-project-steps-pipeline.md](./15-project-steps-pipeline.md) — Step creation diff --git a/docs/features/37-calendar-and-holidays.md b/docs/features/37-calendar-and-holidays.md new file mode 100644 index 00000000..1a64c28e --- /dev/null +++ b/docs/features/37-calendar-and-holidays.md @@ -0,0 +1,96 @@ +# 37 — Calendar & Holidays + +**NEW document** — Public holiday API, country list, in-memory caching, meeting scheduling support + +--- + +## Feature Summary + +The calendar service provides public holiday data by country/year and a cached country list. Used for meeting scheduling (avoiding holidays), session planning, and displaying holiday awareness in the UI. Uses an external holiday API with in-memory caching to reduce API calls. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ CalendarView.tsx │ +│ ├─ Month view with holiday markers │ +│ ├─ GET /api/calendar/holidays?year=2024&country=US │ +│ ├─ Country selector │ +│ │ └─ GET /api/calendar/countries │ +│ └─ Meeting scheduler avoids holidays │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/routes/calendarRoutes.js │ +│ │ +│ GET /holidays → public holidays by year + country │ +│ GET /countries → available country list (cached) │ +│ │ +│ Caching: │ +│ ├─ countriesCache: in-memory { data, timestamp } │ +│ ├─ COUNTRIES_CACHE_TTL: 24 hours │ +│ └─ Holiday API: https://date.nager.at/api/v3/ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/calendarRoutes.js` + +### GET /holidays (lines 95+) +- **Auth:** required +- **Query:** `?year=2024&country=US` +- **Logic:** + 1. Parse year (default: current year) + 2. Parse country code (default: `US`) + 3. Call external API: `GET https://date.nager.at/api/v3/PublicHolidays/{year}/{country}` + 4. Return array of holidays: `{ date, localName, name, countryCode, fixed, global, type }` +- **No caching:** Holidays are static per year/country — frontend can cache + +### GET /countries (lines 191+) +- **Auth:** required +- **Logic:** + 1. Check in-memory cache: `countriesCache` with 24h TTL + 2. If cache hit: return cached data + 3. If cache miss: `GET https://date.nager.at/api/v3/AvailableCountries` + 4. Store in cache: `countriesCache = { data, timestamp: Date.now() }` + 5. Return country list: `{ countryCode, name }[]` + +### In-Memory Cache +```js +let countriesCache = null; +const COUNTRIES_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +if (countriesCache && Date.now() - countriesCache.timestamp < COUNTRIES_CACHE_TTL) { + return res.json(countriesCache.data); +} +``` +- **Why in-memory?** Country list rarely changes, no need for Redis +- **TTL:** 24 hours — auto-refreshes daily + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| Holiday API down | 500 | `{ error: "Failed to fetch holidays" }` | +| Invalid country code | 500 | Error from API | +| Server error | 500 | `{ error: error.message }` | + +--- + +## Cross-References + +- [30-meeting-system.md](./30-meeting-system.md) — Meeting scheduling with holiday awareness +- [04-service-inventory.md](./04-service-inventory.md) — Holiday API listing diff --git a/docs/features/38-file-upload-service.md b/docs/features/38-file-upload-service.md new file mode 100644 index 00000000..0bb4b87c --- /dev/null +++ b/docs/features/38-file-upload-service.md @@ -0,0 +1,161 @@ +# 38 — File Upload Service + +**NEW document** — Multer configuration, multipart handling, chat file attachments, image optimization, upload routes + +--- + +## Feature Summary + +The file upload service handles multipart/form-data uploads for chat attachments and profile photos. Uses Multer for file parsing with memory storage, Sharp for image optimization, and Cloudinary for persistent storage. Supports images, documents, and general file types with size limits. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ ChatInterface.tsx │ +│ ├─ File attach button → file input │ +│ ├─ Drag-and-drop zone │ +│ ├─ Image preview before send │ +│ └─ POST /api/upload (multipart/form-data) │ +│ │ +│ SettingsView.tsx → Avatar │ +│ └─ POST /api/users/me/avatar (multipart) │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/routes/uploadRoutes.js │ +│ ├─ POST / → general file upload │ +│ └─ POST /image → optimized image upload │ +│ │ +│ Middleware: │ +│ ├─ authMiddleware → verify JWT │ +│ └─ multer → parse multipart, memory storage │ +│ │ +│ Image Pipeline: │ +│ 1. Multer receives file → req.file.buffer │ +│ 2. Sharp: resize, compress, format conversion │ +│ 3. Cloudinary: uploadImageBuffer(optimized) │ +│ 4. Return { url, publicId, fileName, fileSize } │ +│ │ +│ File Pipeline: │ +│ 1. Multer receives file → req.file.buffer │ +│ 2. Cloudinary: uploadImageBuffer(buffer, folder) │ +│ 3. Return { url, publicId, fileName, fileSize } │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/uploadRoutes.js` + +### Multer Configuration +```js +const multer = require('multer'); +const storage = multer.memoryStorage(); // Store in memory, not disk +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit + fileFilter: (req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', 'text/plain', 'application/json']; + if (allowed.includes(file.mimetype)) cb(null, true); + else cb(new Error('File type not allowed')); + } +}); +``` + +### POST / (general upload) +- **Auth:** required +- **Middleware:** `upload.single('file')` +- **Logic:** + 1. File in `req.file.buffer` (memory storage) + 2. Upload to Cloudinary: `uploadImageBuffer(req.file.buffer, 'zync-uploads', publicId)` + 3. Return: `{ url, publicId, fileName: req.file.originalname, fileSize: req.file.size }` + +### POST /image (optimized image upload) +- **Auth:** required +- **Middleware:** `upload.single('file')` +- **Logic:** + 1. File in `req.file.buffer` + 2. **Sharp optimization:** + ```js + const optimized = await sharp(req.file.buffer) + .resize(1920, 1080, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 80, progressive: true }) + .toBuffer(); + ``` + 3. Upload optimized buffer to Cloudinary + 4. Return: `{ url, publicId, fileName, fileSize: optimized.length }` + +--- + +## File Type Support + +| Type | MIME Types | Max Size | Optimization | +|---|---|---|---| +| Images | jpeg, png, gif, webp | 10MB | Sharp resize + compress | +| Documents | pdf, txt, json | 10MB | None (stored as-is) | +| Other | Rejected | — | — | + +--- + +## Sharp Image Optimization + +``` +Input: any image format, any size + ↓ +Sharp pipeline: + ├─ Resize: max 1920x1080 (fit: inside, no enlargement) + ├─ Format: JPEG (quality 80, progressive) + └─ Output: optimized buffer + ↓ +Cloudinary upload_stream + ↓ +Return: secure_url +``` + +- **Quality 80:** Good balance of visual quality and file size +- **Progressive JPEG:** Better perceived load time +- **Max 1920x1080:** Sufficient for most display contexts +- **withoutEnlargement:** Small images aren't upscaled + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| No file provided | 400 | `{ error: "No file provided" }` | +| File too large | 413 | `{ error: "File too large (max 10MB)" }` | +| File type not allowed | 400 | `{ error: "File type not allowed" }` | +| Cloudinary upload fails | 500 | `{ error: "Upload failed" }` | +| Sharp processing fails | 500 | `{ error: "Image processing failed" }` | + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `CLOUDINARY_CLOUD_NAME` | Yes | Cloudinary cloud name | +| `CLOUDINARY_API_KEY` | Yes | Cloudinary API key | +| `CLOUDINARY_API_SECRET` | Yes | Cloudinary API secret | +| `MAX_FILE_SIZE_MB` | No | Default: 10 | + +--- + +## Cross-References + +- [32-cloudinary-upload-service.md](./32-cloudinary-upload-service.md) — Cloudinary service +- [23-instant-chat-system.md](./23-instant-chat-system.md) — Chat file attachments +- [09-user-profile-management.md](./09-user-profile-management.md) — Avatar upload diff --git a/docs/features/39-user-search-and-discovery.md b/docs/features/39-user-search-and-discovery.md new file mode 100644 index 00000000..42fecaab --- /dev/null +++ b/docs/features/39-user-search-and-discovery.md @@ -0,0 +1,173 @@ +# 39 — User Search & Discovery + +**NEW document** — Regex search, pagination, text index, chat request flow, user discovery for team invites + +--- + +## Feature Summary + +User search enables finding other Zync users by name or email for team invites, chat requests, and collaborator discovery. Uses MongoDB text index with regex fallback, pagination via utility functions, and escapeRegExp for safe pattern matching. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ UserSearch component (reusable) │ +│ ├─ Debounced input (300ms) │ +│ ├─ GET /api/users/search?query=...&page=1 │ +│ ├─ Results: avatar, name, email │ +│ ├─ "Send Chat Request" button │ +│ │ └─ POST /api/users/chat-request │ +│ └─ "Invite to Team" button │ +│ └─ POST /api/teams/invite │ +│ │ +│ Used in: │ +│ ├─ TeamsView.tsx → invite members │ +│ ├─ MessagesPage.tsx → start new chat │ +│ └─ ShareFolderDialog.tsx → share with user │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ userRoutes.js → GET /search │ +│ │ +│ 1. Escape regex: escapeRegExp(query) │ +│ 2. Build filter: │ +│ { $or: [ │ +│ { displayName: { $regex: query, $options: 'i' } },│ +│ { email: { $regex: query, $options: 'i' } } │ +│ ]} │ +│ 3. Exclude self: { uid: { $ne: req.user.uid } } │ +│ 4. Project: uid, displayName, email, photoURL │ +│ 5. Paginate: paginateArray() │ +│ 6. Set pagination headers │ +│ 7. Return results │ +│ │ +│ Chat Request: │ +│ POST /chat-request │ +│ ├─ Store request in DB │ +│ ├─ Send email notification │ +│ └─ Return { message: "Request sent" } │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/userRoutes.js` + +### GET /search +- **Auth:** required +- **Query:** `?query=&page=1&limit=20` +- **Logic:** + 1. **Escape regex:** `const safeQuery = escapeRegExp(query)` — prevents regex injection + 2. **Build filter:** + ```js + const filter = { + uid: { $ne: req.user.uid }, // Exclude self + $or: [ + { displayName: { $regex: safeQuery, $options: 'i' } }, + { email: { $regex: safeQuery, $options: 'i' } } + ] + }; + ``` + 3. **Execute query:** `User.find(filter).select('uid displayName email photoURL').lean()` + 4. **Paginate:** `paginateArray(results, req.query, { defaultLimit: 20, maxLimit: 50 })` + 5. **Set headers:** `setPaginationHeaders(res, pagination)` + 6. **Return:** Paginated user array + +### escapeRegExp Utility +**File:** `backend/utils/regexUtils.js` +```js +const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +``` +- Escapes special regex characters in user input +- Prevents regex injection attacks +- Example: `John.*` → `John\.\*` + +### POST /chat-request +- **Auth:** required +- **Input:** `{ receiverUid, message? }` +- **Logic:** + 1. Verify receiver exists + 2. Check if request already sent (prevent duplicates) + 3. Store chat request in DB (or on User model) + 4. Send email notification: `sendZyncEmail(receiverEmail, 'New Chat Request', html)` + 5. Return `{ message: "Request sent" }` +- **Used by:** UserSearch "Send Chat Request" button + +--- + +## Database Layer + +### User Model — Text Index +**File:** `backend/models/User.js` +```js +UserSchema.index({ + displayName: 'text', + email: 'text' +}); +``` +- MongoDB text index for efficient search +- Regex search used as fallback for partial matches +- Text index supports full-text search, regex supports partial/suffix + +### Search Fields +| Field | Searchable | Notes | +|---|---|---| +| `displayName` | Yes (regex + text) | User's display name | +| `email` | Yes (regex) | User's email | +| `uid` | No | Excluded from search, used for exclusion | +| `photoURL` | No | Returned in results only | + +--- + +## Pagination + +Uses `backend/utils/pagination.js`: +```js +const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); + +const { items, pagination } = paginateArray(results, req.query, { + defaultLimit: 20, + maxLimit: 50, +}); +setPaginationHeaders(res, pagination); +res.json(items); +``` + +**Pagination headers:** +- `X-Total-Count`: Total items +- `X-Page`: Current page +- `X-Page-Size`: Items per page +- `X-Total-Pages`: Total pages + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| Empty query | 200 | Empty array (no error) | +| Chat request to self | 400 | `{ error: "Cannot send request to yourself" }` | +| Duplicate chat request | 400 | `{ error: "Request already sent" }` | +| Receiver not found | 404 | `{ error: "User not found" }` | +| Server error | 500 | `{ error: error.message }` | + +--- + +## Cross-References + +- [09-user-profile-management.md](./09-user-profile-management.md) — User profile endpoints +- [31-team-crud-and-invites.md](./31-team-crud-and-invites.md) — Team invite uses user search +- [18-folders-and-organization.md](./18-folders-and-organization.md) — Folder sharing uses user search +- [23-instant-chat-system.md](./23-instant-chat-system.md) — Chat request initiates chat +- [28-email-service-notifications.md](./28-email-service-notifications.md) — Chat request email diff --git a/docs/features/40-google-oauth-integration.md b/docs/features/40-google-oauth-integration.md new file mode 100644 index 00000000..ed63a836 --- /dev/null +++ b/docs/features/40-google-oauth-integration.md @@ -0,0 +1,226 @@ +# 40 — Google OAuth Integration + +**NEW document** — Google sign-in via Firebase popup, token storage, Google Calendar/Meet integration, Google Drive scope + +--- + +## Feature Summary + +Google integration in Zync serves two purposes: (1) Google sign-in via Firebase Auth's `signInWithPopup` (handled entirely on the frontend) and (2) server-side Google API access for Calendar (meeting creation) and Gmail (SMTP email). The backend stores Google OAuth tokens encrypted and refreshes them automatically. + +--- + +## Architecture Diagram + +``` +┌─────────────────── FRONTEND ───────────────────────────┐ +│ │ +│ Login.tsx │ +│ ├─ "Sign in with Google" button │ +│ │ └─ signInWithPopup(auth, googleProvider) │ +│ │ └─ Firebase handles OAuth flow entirely │ +│ ├─ Account linking: if email exists with different │ +│ │ provider → linkWithPopup instead │ +│ └─ Post-login: postLoginRedirect → /dashboard │ +│ │ +│ SettingsView.tsx → Google integration status │ +│ ├─ Shows connected/disconnected state │ +│ └─ Google Calendar permissions for meetings │ +│ │ +└──────────────────────┬──────────────────────────────────┘ + │ + ▼ +┌─────────────────── BACKEND ────────────────────────────┐ +│ │ +│ backend/routes/googleRoutes.js │ +│ ├─ POST /connect → store Google tokens │ +│ ├─ DELETE /disconnect → remove Google tokens │ +│ └─ GET /status → check Google integration status │ +│ │ +│ backend/services/googleMeet.js │ +│ ├─ createMeeting() → Google Calendar API │ +│ ├─ send_ZYNC_email() → Gmail SMTP │ +│ └─ OAuth2 client with refresh token │ +│ │ +│ Token Storage: │ +│ ├─ User.googleIntegration = { │ +│ │ connected, accessToken (encrypted), │ +│ │ refreshToken (encrypted), expiryDate │ +│ │ } │ +│ └─ AES-256 encryption via ENCRYPTION_KEY │ +│ │ +│ Google APIs: │ +│ ├─ Calendar API → create events with Meet links │ +│ ├─ Gmail API → send transactional emails │ +│ └─ OAuth2 scopes: calendar, gmail.send, userinfo.email │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Backend Trace + +### File: `backend/routes/googleRoutes.js` + +### POST /connect +- **Auth:** required +- **Input:** `{ accessToken, refreshToken, expiryDate }` +- **Logic:** + 1. Encrypt `accessToken` and `refreshToken` with AES-256 + 2. Store on User: `googleIntegration: { connected: true, accessToken, refreshToken, expiryDate }` + 3. Invalidate user cache +- **Response:** `{ message: "Google connected" }` + +### DELETE /disconnect +- **Auth:** required +- **Logic:** + 1. Clear `User.googleIntegration` fields + 2. Invalidate cache +- **Response:** `{ message: "Google disconnected" }` + +### GET /status +- **Auth:** required +- **Logic:** + 1. Check `User.googleIntegration.connected` + 2. If token expired: attempt refresh using `refreshToken` + 3. Return `{ connected, hasCalendarAccess, hasGmailAccess }` +- **Response:** Google integration status + +--- + +## Google Meet Service + +### File: `backend/services/googleMeet.js` + +### OAuth2 Client Setup +```js +const { google } = require('googleapis'); +const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI +); +``` + +### Token Refresh +```js +oauth2Client.setCredentials({ + access_token: decrypt(user.googleIntegration.accessToken), + refresh_token: decrypt(user.googleIntegration.refreshToken), + expiry_date: user.googleIntegration.expiryDate +}); + +// Auto-refreshed by Google library when expired +``` + +### createMeeting(title, startTime, attendees) +1. `google.calendar('v3').events.insert({` +2. Calendar ID: `primary` +3. Event: `{ summary: title, start: { dateTime }, end: { dateTime }, attendees }` +4. Conference data: `{ createRequest: { requestId, conferenceSolutionKey: { type: 'hangoutsMeet' } } }` +5. Returns: `{ meetLink, eventId }` + +### send_ZYNC_email(to, subject, html, text) +- Uses nodemailer with Gmail SMTP +- Auth: OAuth2 with refresh token +- Alternative to Google Gmail API for sending emails + +--- + +## Frontend Trace + +### Google Sign-In (Firebase) +**File:** `src/pages/Login.tsx` +```js +import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; + +const googleProvider = new GoogleAuthProvider(); +googleProvider.setCustomParameters({ prompt: 'select_account' }); + +// On click: +const result = await signInWithPopup(auth, googleProvider); +const user = result.user; +``` +- **No backend involvement:** Firebase handles the entire OAuth flow +- **Account linking:** If email exists with different provider, `linkWithPopup` is used +- **Scopes:** Default Firebase scopes (email, profile) + +### Google Integration Status +**File:** `src/components/views/SettingsView.tsx` +- Fetches `GET /api/google/status` +- Shows: connected state, Calendar access, Gmail access +- "Connect Google" button triggers OAuth flow +- "Disconnect" button calls `DELETE /api/google/disconnect` + +--- + +## Token Lifecycle + +``` +1. User signs in with Google (Firebase popup) + → Firebase Auth token (JWT) for Zync authentication + +2. User connects Google Calendar (optional) + → OAuth flow → access_token + refresh_token + → POST /api/google/connect (encrypted storage) + +3. Access token expires + → Google library auto-refreshes using refresh_token + → New access_token stored (if backend involved) + +4. User disconnects + → DELETE /api/google/disconnect + → Tokens removed from User document +``` + +--- + +## Encryption + +Google tokens are encrypted at rest using AES-256: +```js +const CryptoJS = require('crypto-js'); +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; + +const encrypt = (text) => CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString(); +const decrypt = (ciphertext) => CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY).toString(CryptoJS.enc.Utf8); +``` +- Same encryption as GitHub tokens +- `ENCRYPTION_KEY` required in production + +--- + +## Error Paths + +| Scenario | HTTP Status | Response | +|---|---|---| +| No token | 401 | Unauthorized | +| Google tokens missing | 400 | `{ error: "Google not connected" }` | +| Token refresh fails | 401 | `{ error: "Google re-authentication required" }` | +| Calendar API error | 500 | `{ error: "Failed to create meeting" }` | +| SMTP auth failure | — | Email not sent, operation continues | +| Server error | 500 | `{ error: "Server error" }` | + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `GOOGLE_CLIENT_ID` | Yes | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth client secret | +| `GOOGLE_REDIRECT_URI` | Yes | OAuth callback URL | +| `ENCRYPTION_KEY` | Yes (prod) | AES-256 key for token encryption | +| `SMTP_USER` | Yes | Gmail address for SMTP | +| `SMTP_PASS` | Yes | Gmail app password | + +--- + +## Cross-References + +- [08-firebase-auth-flow.md](./08-firebase-auth-flow.md) — Google sign-in via Firebase +- [30-meeting-system.md](./30-meeting-system.md) — Google Meet creation +- [28-email-service-notifications.md](./28-email-service-notifications.md) — Gmail SMTP +- [21-github-oauth-integration.md](./21-github-oauth-integration.md) — Similar OAuth pattern +- [02-security-auth-architecture.md](./02-security-auth-architecture.md) — Token encryption