diff --git a/.husky/pre-commit b/.husky/pre-commit index cb2c84d..d46c8d6 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ +pnpm check-types pnpm lint-staged diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 9d84fb1..a9ea1c2 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the DevRadar extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-08 + +### Added + +- **Friend Request System**: Send, accept, reject, and cancel friend requests +- **User Search**: Find users by username or display name +- **Friend Requests View**: New sidebar section showing incoming/outgoing requests +- **Real-time Notifications**: VS Code notifications when you receive or accept requests +- **Unfriend Action**: Remove friends with bidirectional cleanup +- Context menu actions for accept/reject/cancel on request items + +### Changed + +- Friends are now mutual (both users must accept to become friends) +- Updated sidebar with "Add Friend" button + +--- + ## [0.1.0] - 2026-01-05 ### Added diff --git a/apps/extension/media/requests.svg b/apps/extension/media/requests.svg new file mode 100644 index 0000000..521da48 --- /dev/null +++ b/apps/extension/media/requests.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/apps/extension/package.json b/apps/extension/package.json index d6c8a84..3440f8d 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -2,7 +2,7 @@ "name": "devradar", "displayName": "DevRadar", "description": "See what your friends are coding in real-time", - "version": "0.1.3", + "version": "0.2.0", "publisher": "devradar", "license": "AGPL-3.0-or-later", "private": true, @@ -47,6 +47,12 @@ "icon": "media/friends.svg", "contextualTitle": "DevRadar Friends" }, + { + "id": "devradar.friendRequests", + "name": "Friend Requests", + "icon": "media/requests.svg", + "contextualTitle": "DevRadar Friend Requests" + }, { "id": "devradar.activity", "name": "Activity", @@ -104,6 +110,47 @@ "title": "Set Status", "category": "DevRadar", "icon": "$(broadcast)" + }, + { + "command": "devradar.addFriend", + "title": "Add Friend", + "category": "DevRadar", + "icon": "$(person-add)" + }, + { + "command": "devradar.acceptFriendRequest", + "title": "Accept Friend Request", + "category": "DevRadar", + "icon": "$(check)" + }, + { + "command": "devradar.rejectFriendRequest", + "title": "Reject Friend Request", + "category": "DevRadar", + "icon": "$(close)" + }, + { + "command": "devradar.cancelFriendRequest", + "title": "Cancel Friend Request", + "category": "DevRadar", + "icon": "$(trash)" + }, + { + "command": "devradar.refreshFriendRequests", + "title": "Refresh Friend Requests", + "category": "DevRadar", + "icon": "$(refresh)" + }, + { + "command": "devradar.unfriend", + "title": "Unfriend", + "category": "DevRadar", + "icon": "$(person-remove)" + }, + { + "command": "devradar.focusFriendRequests", + "title": "Focus Friend Requests", + "category": "DevRadar" } ], "menus": { @@ -112,6 +159,16 @@ "command": "devradar.refreshFriends", "when": "view == devradar.friends", "group": "navigation" + }, + { + "command": "devradar.addFriend", + "when": "view == devradar.friends && devradar.isAuthenticated", + "group": "navigation" + }, + { + "command": "devradar.refreshFriendRequests", + "when": "view == devradar.friendRequests", + "group": "navigation" } ], "view/item/context": [ @@ -123,6 +180,25 @@ { "command": "devradar.viewProfile", "when": "view == devradar.friends" + }, + { + "command": "devradar.unfriend", + "when": "view == devradar.friends && viewItem =~ /^friend-/" + }, + { + "command": "devradar.acceptFriendRequest", + "when": "view == devradar.friendRequests && viewItem == friendRequest-incoming", + "group": "inline@1" + }, + { + "command": "devradar.rejectFriendRequest", + "when": "view == devradar.friendRequests && viewItem == friendRequest-incoming", + "group": "inline@2" + }, + { + "command": "devradar.cancelFriendRequest", + "when": "view == devradar.friendRequests && viewItem == friendRequest-outgoing", + "group": "inline" } ] }, diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index e05cd43..d421399 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -7,14 +7,16 @@ import * as vscode from 'vscode'; import { ActivityTracker } from './services/activityTracker'; import { AuthService } from './services/authService'; +import { FriendRequestService } from './services/friendRequestService'; import { WebSocketClient } from './services/wsClient'; import { ConfigManager } from './utils/configManager'; import { Logger } from './utils/logger'; import { ActivityProvider } from './views/activityProvider'; +import { FriendRequestsProvider } from './views/friendRequestsProvider'; import { FriendsProvider } from './views/friendsProvider'; import { StatusBarManager } from './views/statusBarItem'; -import type { UserStatusType } from '@devradar/shared'; +import type { UserStatusType, FriendRequestDTO, PublicUserDTO } from '@devradar/shared'; /*** Extension context manager that coordinates all services ***/ class DevRadarExtension implements vscode.Disposable { @@ -25,6 +27,8 @@ class DevRadarExtension implements vscode.Disposable { private readonly wsClient: WebSocketClient; private readonly activityTracker: ActivityTracker; private readonly friendsProvider: FriendsProvider; + private readonly friendRequestsProvider: FriendRequestsProvider; + private readonly friendRequestService: FriendRequestService; private readonly activityProvider: ActivityProvider; private readonly statusBar: StatusBarManager; @@ -35,8 +39,17 @@ class DevRadarExtension implements vscode.Disposable { this.authService = new AuthService(context, this.configManager, this.logger); this.wsClient = new WebSocketClient(this.authService, this.configManager, this.logger); this.activityTracker = new ActivityTracker(this.wsClient, this.configManager, this.logger); + this.friendRequestService = new FriendRequestService( + this.configManager, + this.authService, + this.logger + ); /* Initialize views */ this.friendsProvider = new FriendsProvider(this.logger); + this.friendRequestsProvider = new FriendRequestsProvider( + this.friendRequestService, + this.logger + ); this.activityProvider = new ActivityProvider(this.wsClient, this.logger); this.statusBar = new StatusBarManager(this.wsClient, this.authService, this.logger); /* Track disposables */ @@ -44,7 +57,9 @@ class DevRadarExtension implements vscode.Disposable { this.authService, this.wsClient, this.activityTracker, + this.friendRequestService, this.friendsProvider, + this.friendRequestsProvider, this.activityProvider, this.statusBar, this.configManager @@ -76,6 +91,10 @@ class DevRadarExtension implements vscode.Disposable { private registerTreeViews(): void { this.disposables.push( vscode.window.registerTreeDataProvider('devradar.friends', this.friendsProvider), + vscode.window.registerTreeDataProvider( + 'devradar.friendRequests', + this.friendRequestsProvider + ), vscode.window.registerTreeDataProvider('devradar.activity', this.activityProvider) ); } @@ -115,6 +134,38 @@ class DevRadarExtension implements vscode.Disposable { id: 'devradar.setStatus', handler: () => this.handleSetStatus(), }, + { + id: 'devradar.addFriend', + handler: () => this.handleAddFriend(), + }, + { + id: 'devradar.acceptFriendRequest', + handler: (item: unknown) => this.handleAcceptFriendRequest(item), + }, + { + id: 'devradar.rejectFriendRequest', + handler: (item: unknown) => this.handleRejectFriendRequest(item), + }, + { + id: 'devradar.cancelFriendRequest', + handler: (item: unknown) => this.handleCancelFriendRequest(item), + }, + { + id: 'devradar.refreshFriendRequests', + handler: () => { + this.handleRefreshFriendRequests(); + }, + }, + { + id: 'devradar.unfriend', + handler: (item: unknown) => this.handleUnfriend(item), + }, + { + id: 'devradar.focusFriendRequests', + handler: () => { + void vscode.commands.executeCommand('devradar.friendRequests.focus'); + }, + }, ]; for (const command of commands) { @@ -170,6 +221,18 @@ class DevRadarExtension implements vscode.Disposable { case 'ERROR': this.handleServerError(message.payload); break; + case 'FRIEND_REQUEST_RECEIVED': + this.friendRequestService.handleFriendRequestReceived( + message.payload as { request: FriendRequestDTO } + ); + break; + case 'FRIEND_REQUEST_ACCEPTED': + this.friendRequestService.handleFriendRequestAccepted( + message.payload as { requestId: string; friend: PublicUserDTO } + ); + /* Refresh friends list when a new friend is added */ + this.friendsProvider.refresh(); + break; } }); /* Listen for connection state changes */ @@ -194,6 +257,9 @@ class DevRadarExtension implements vscode.Disposable { void this.statusBar.update(); this.friendsProvider.refresh(); + if (isAuthenticated) { + void this.friendRequestsProvider.refresh(); + } } /*** Handles the login command ***/ @@ -411,6 +477,170 @@ class DevRadarExtension implements vscode.Disposable { } } + /*** Handles adding a friend via search ***/ + private async handleAddFriend(): Promise { + try { + /* Show input box for search query */ + const query = await vscode.window.showInputBox({ + prompt: 'Search for a user by username or display name', + placeHolder: 'Enter username...', + validateInput: (value) => { + if (value.length < 2) { + return 'Enter at least 2 characters'; + } + return null; + }, + }); + + if (!query) { + return; + } + + /* Search for users */ + const users = await this.friendRequestService.searchUsers(query); + + if (users.length === 0) { + void vscode.window.showInformationMessage('DevRadar: No users found'); + return; + } + + /* Filter out current user */ + const currentUser = this.authService.getUser(); + const filteredUsers = users.filter((u) => u.id !== currentUser?.id); + + if (filteredUsers.length === 0) { + void vscode.window.showInformationMessage('DevRadar: No other users found'); + return; + } + + /* Show quick pick to select user */ + const selected = await vscode.window.showQuickPick( + filteredUsers.map((u) => ({ + label: u.displayName ?? u.username, + description: `@${u.username}`, + userId: u.id, + })), + { placeHolder: 'Select a user to send friend request' } + ); + + if (!selected) { + return; + } + + /* Send friend request */ + await this.friendRequestService.sendRequest(selected.userId); + void vscode.window.showInformationMessage( + `DevRadar: Friend request sent to ${selected.label}! 🎉` + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Failed to add friend', error); + void vscode.window.showErrorMessage(`DevRadar: ${message}`); + } + } + + /*** Type guard for friend request item ***/ + private isFriendRequestItem(item: unknown): item is { request: { id: string } } { + if (typeof item !== 'object' || item === null) { + return false; + } + if (!('request' in item)) { + return false; + } + const requestItem = item as { request: unknown }; + if (typeof requestItem.request !== 'object' || requestItem.request === null) { + return false; + } + return 'id' in requestItem.request; + } + + /*** Handles accepting a friend request ***/ + private async handleAcceptFriendRequest(item: unknown): Promise { + if (!this.isFriendRequestItem(item)) { + this.logger.warn('Invalid friend request item for accept', item); + return; + } + + try { + await this.friendRequestService.acceptRequest(item.request.id); + void vscode.window.showInformationMessage('DevRadar: Friend request accepted! 🎉'); + /* Refresh friends list */ + this.friendsProvider.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Failed to accept friend request', error); + void vscode.window.showErrorMessage(`DevRadar: ${message}`); + } + } + + /*** Handles rejecting a friend request ***/ + private async handleRejectFriendRequest(item: unknown): Promise { + if (!this.isFriendRequestItem(item)) { + this.logger.warn('Invalid friend request item for reject', item); + return; + } + + try { + await this.friendRequestService.rejectRequest(item.request.id); + void vscode.window.showInformationMessage('DevRadar: Friend request rejected'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Failed to reject friend request', error); + void vscode.window.showErrorMessage(`DevRadar: ${message}`); + } + } + + /*** Handles cancelling an outgoing friend request ***/ + private async handleCancelFriendRequest(item: unknown): Promise { + if (!this.isFriendRequestItem(item)) { + this.logger.warn('Invalid friend request item for cancel', item); + return; + } + + try { + await this.friendRequestService.cancelRequest(item.request.id); + void vscode.window.showInformationMessage('DevRadar: Friend request cancelled'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Failed to cancel friend request', error); + void vscode.window.showErrorMessage(`DevRadar: ${message}`); + } + } + + /*** Handles refreshing friend requests ***/ + private handleRefreshFriendRequests(): void { + void this.friendRequestsProvider.refresh(); + } + + /*** Handles unfriending a user ***/ + private async handleUnfriend(item: unknown): Promise { + if (!this.isFriendItem(item)) { + this.logger.warn('Invalid friend item for unfriend', item); + return; + } + + /* Confirm with user */ + const confirm = await vscode.window.showWarningMessage( + 'Are you sure you want to unfriend this user?', + { modal: true }, + 'Unfriend' + ); + + if (confirm !== 'Unfriend') { + return; + } + + try { + await this.friendRequestService.unfriend(item.userId); + void vscode.window.showInformationMessage('DevRadar: User unfriended'); + this.friendsProvider.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Failed to unfriend user', error); + void vscode.window.showErrorMessage(`DevRadar: ${message}`); + } + } + /*** Disposes all resources ***/ dispose(): void { this.logger.info('Disposing DevRadar extension...'); diff --git a/apps/extension/src/services/friendRequestService.ts b/apps/extension/src/services/friendRequestService.ts new file mode 100644 index 0000000..5603774 --- /dev/null +++ b/apps/extension/src/services/friendRequestService.ts @@ -0,0 +1,377 @@ +/*** Friend Request Service + * + * Handles friend request operations: + * - Searching users + * - Sending/accepting/rejecting/cancelling friend requests + * - Fetching incoming/outgoing requests + * - Listening to WebSocket events for real-time updates + */ + +import * as vscode from 'vscode'; + +import type { AuthService } from './authService'; +import type { ConfigManager } from '../utils/configManager'; +import type { Logger } from '../utils/logger'; +import type { PublicUserDTO, FriendRequestDTO, PaginatedResponse } from '@devradar/shared'; + +/*** Events emitted by FriendRequestService ***/ +export interface FriendRequestEvents { + onRequestReceived: vscode.Event; + onRequestAccepted: vscode.Event<{ requestId: string; friend: PublicUserDTO }>; + onRequestsChanged: vscode.Event; +} + +/*** Friend Request Service ***/ +export class FriendRequestService implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + + private readonly onRequestReceivedEmitter = new vscode.EventEmitter(); + private readonly onRequestAcceptedEmitter = new vscode.EventEmitter<{ + requestId: string; + friend: PublicUserDTO; + }>(); + private readonly onRequestsChangedEmitter = new vscode.EventEmitter(); + + readonly onRequestReceived = this.onRequestReceivedEmitter.event; + readonly onRequestAccepted = this.onRequestAcceptedEmitter.event; + readonly onRequestsChanged = this.onRequestsChangedEmitter.event; + + constructor( + private readonly configManager: ConfigManager, + private readonly authService: AuthService, + private readonly logger: Logger + ) { + this.disposables.push( + this.onRequestReceivedEmitter, + this.onRequestAcceptedEmitter, + this.onRequestsChangedEmitter + ); + } + + /*** Get the Authorization header ***/ + private getAuthHeader(): Record { + const token = this.authService.getToken(); + if (!token) { + throw new Error('Not authenticated'); + } + return { Authorization: `Bearer ${token}` }; + } + + /*** Get base server URL ***/ + private getServerUrl(): string { + return this.configManager.get('serverUrl'); + } + + /*** Default timeout for fetch requests in milliseconds ***/ + private static readonly DEFAULT_TIMEOUT = 10_000; + + /*** Fetch with timeout using AbortController ***/ + private async fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeout: number = FriendRequestService.DEFAULT_TIMEOUT + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } finally { + clearTimeout(timeoutId); + } + } + + /*** Search for users by query ***/ + async searchUsers(query: string): Promise { + if (query.length < 2) { + return []; + } + + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/users/search?q=${encodeURIComponent(query)}`, + { + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + throw new Error(`Search failed: ${String(response.status)}`); + } + + const result = (await response.json()) as { data: PublicUserDTO[] }; + return result.data; + } catch (error) { + this.logger.error('Failed to search users', error); + throw error; + } + } + + /*** Send a friend request to a user ***/ + async sendRequest(toUserId: string): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests`, + { + method: 'POST', + headers: { + ...this.getAuthHeader(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ toUserId }), + } + ); + + if (!response.ok) { + let message = `Request failed: ${String(response.status)}`; + try { + const errorData = (await response.json()) as { error?: { message?: string } }; + if (errorData.error?.message) { + message = errorData.error.message; + } + } catch { + // Response wasn't JSON, use default message + } + throw new Error(message); + } + + const result = (await response.json()) as { data: FriendRequestDTO }; + this.onRequestsChangedEmitter.fire(); + return result.data; + } catch (error) { + this.logger.error('Failed to send friend request', error); + throw error; + } + } + + /*** Get incoming friend requests ***/ + async getIncomingRequests(page = 1, limit = 20): Promise> { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/incoming?page=${String(page)}&limit=${String(limit)}`, + { + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to get incoming requests: ${String(response.status)}`); + } + + return (await response.json()) as PaginatedResponse; + } catch (error) { + this.logger.error('Failed to get incoming requests', error); + throw error; + } + } + + /*** Get outgoing friend requests ***/ + async getOutgoingRequests(page = 1, limit = 20): Promise> { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/outgoing?page=${String(page)}&limit=${String(limit)}`, + { + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to get outgoing requests: ${String(response.status)}`); + } + + return (await response.json()) as PaginatedResponse; + } catch (error) { + this.logger.error('Failed to get outgoing requests', error); + throw error; + } + } + + /*** Get pending request count (for badge) ***/ + async getPendingCount(): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/count`, + { + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + throw new Error(`Failed to get pending count: ${String(response.status)}`); + } + + const result = (await response.json()) as { data: { count: number } }; + return result.data.count; + } catch (error) { + this.logger.error('Failed to get pending request count', error); + return 0; + } + } + + /*** Accept a friend request ***/ + async acceptRequest(requestId: string): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/${requestId}/accept`, + { + method: 'POST', + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + let message = `Accept failed: ${String(response.status)}`; + try { + const errorData = (await response.json()) as { error?: { message?: string } }; + if (errorData.error?.message) { + message = errorData.error.message; + } + } catch { + // Response wasn't JSON, use default message + } + throw new Error(message); + } + + this.onRequestsChangedEmitter.fire(); + } catch (error) { + this.logger.error('Failed to accept friend request', error); + throw error; + } + } + + /*** Reject a friend request ***/ + async rejectRequest(requestId: string): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/${requestId}/reject`, + { + method: 'POST', + headers: this.getAuthHeader(), + } + ); + + if (!response.ok) { + let message = `Reject failed: ${String(response.status)}`; + try { + const errorData = (await response.json()) as { error?: { message?: string } }; + if (errorData.error?.message) { + message = errorData.error.message; + } + } catch { + // Response wasn't JSON, use default message + } + throw new Error(message); + } + + this.onRequestsChangedEmitter.fire(); + } catch (error) { + this.logger.error('Failed to reject friend request', error); + throw error; + } + } + + /*** Cancel an outgoing friend request ***/ + async cancelRequest(requestId: string): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friend-requests/${requestId}`, + { + method: 'DELETE', + headers: this.getAuthHeader(), + } + ); + + if (!response.ok && response.status !== 204) { + let message = `Cancel failed: ${String(response.status)}`; + try { + const errorData = (await response.json()) as { error?: { message?: string } }; + if (errorData.error?.message) { + message = errorData.error.message; + } + } catch { + // Response wasn't JSON, use default message + } + throw new Error(message); + } + + this.onRequestsChangedEmitter.fire(); + } catch (error) { + this.logger.error('Failed to cancel friend request', error); + throw error; + } + } + + /*** Handle WebSocket message for friend request received ***/ + handleFriendRequestReceived(payload: { request: FriendRequestDTO }): void { + this.logger.info('Friend request received', { from: payload.request.fromUser.username }); + this.onRequestReceivedEmitter.fire(payload.request); + this.onRequestsChangedEmitter.fire(); + + /* Show VS Code notification */ + const fromName = payload.request.fromUser.displayName ?? payload.request.fromUser.username; + void vscode.window + .showInformationMessage(`${fromName} sent you a friend request`, 'Accept', 'View') + .then((action) => { + if (action === 'Accept') { + this.acceptRequest(payload.request.id).catch((error: unknown) => { + this.logger.error('Failed to accept request from notification', error); + void vscode.window.showErrorMessage('Failed to accept friend request'); + }); + } else if (action === 'View') { + void vscode.commands.executeCommand('devradar.focusFriendRequests'); + } + }); + } + + /*** Handle WebSocket message for friend request accepted ***/ + handleFriendRequestAccepted(payload: { requestId: string; friend: PublicUserDTO }): void { + this.logger.info('Friend request accepted', { friend: payload.friend.username }); + this.onRequestAcceptedEmitter.fire(payload); + this.onRequestsChangedEmitter.fire(); + + /* Show VS Code notification */ + const friendName = payload.friend.displayName ?? payload.friend.username; + void vscode.window.showInformationMessage(`${friendName} accepted your friend request! 🎉`); + } + + /*** Unfriend a user by removing the friendship ***/ + async unfriend(userId: string): Promise { + try { + const response = await this.fetchWithTimeout( + `${this.getServerUrl()}/api/v1/friends/${userId}`, + { + method: 'DELETE', + headers: this.getAuthHeader(), + } + ); + + if (!response.ok && response.status !== 204) { + let message = `Unfriend failed: ${String(response.status)}`; + try { + const errorData = (await response.json()) as { error?: { message?: string } }; + if (errorData.error?.message) { + message = errorData.error.message; + } + } catch { + // Response wasn't JSON, use default message + } + throw new Error(message); + } + } catch (error) { + this.logger.error('Failed to unfriend user', error); + throw error; + } + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} diff --git a/apps/extension/src/services/index.ts b/apps/extension/src/services/index.ts index e7def60..d694b50 100644 --- a/apps/extension/src/services/index.ts +++ b/apps/extension/src/services/index.ts @@ -1,3 +1,4 @@ export { AuthService, type AuthState } from './authService'; export { WebSocketClient, type ConnectionState } from './wsClient'; export { ActivityTracker } from './activityTracker'; +export { FriendRequestService } from './friendRequestService'; diff --git a/apps/extension/src/views/friendRequestsProvider.ts b/apps/extension/src/views/friendRequestsProvider.ts new file mode 100644 index 0000000..72a6f9f --- /dev/null +++ b/apps/extension/src/views/friendRequestsProvider.ts @@ -0,0 +1,179 @@ +/*** Friend Requests Tree View Provider + * + * Displays pending friend requests in the sidebar with accept/reject/cancel actions + */ + +import * as vscode from 'vscode'; + +import type { FriendRequestService } from '../services/friendRequestService'; +import type { Logger } from '../utils/logger'; +import type { FriendRequestDTO } from '@devradar/shared'; + +/*** Tree item representing a friend request ***/ +class FriendRequestTreeItem extends vscode.TreeItem { + constructor( + public readonly request: FriendRequestDTO, + public readonly direction: 'incoming' | 'outgoing' + ) { + const user = direction === 'incoming' ? request.fromUser : request.toUser; + const label = user.displayName ?? user.username; + super(label, vscode.TreeItemCollapsibleState.None); + + this.id = `friend-request-${request.id}`; + this.description = `@${user.username}`; + this.tooltip = this.buildTooltip(user); + this.contextValue = `friendRequest-${direction}`; + + /* Set icon based on direction */ + if (direction === 'incoming') { + this.iconPath = new vscode.ThemeIcon('arrow-down', new vscode.ThemeColor('charts.blue')); + } else { + this.iconPath = new vscode.ThemeIcon('arrow-up', new vscode.ThemeColor('charts.yellow')); + } + } + + private buildTooltip(user: { + username: string; + displayName: string | null; + }): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + md.isTrusted = true; + md.appendMarkdown(`### ${user.displayName ?? user.username}\n\n`); + md.appendMarkdown(`**@${user.username}**\n\n`); + md.appendMarkdown( + this.direction === 'incoming' + ? 'Sent you a friend request\n\n' + : 'Pending - waiting for response\n\n' + ); + md.appendMarkdown(`*Sent: ${new Date(this.request.createdAt).toLocaleDateString()}*`); + return md; + } +} + +/*** Section header for organizing requests ***/ +class SectionTreeItem extends vscode.TreeItem { + constructor( + public readonly section: 'incoming' | 'outgoing', + public readonly count: number + ) { + const label = section === 'incoming' ? '📥 Incoming Requests' : '📤 Outgoing Requests'; + super( + label, + count > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + ); + + this.id = `section-${section}`; + this.description = String(count); + this.contextValue = 'section'; + } +} + +type FriendRequestsTreeItem = FriendRequestTreeItem | SectionTreeItem; + +/*** Friend Requests Tree Data Provider ***/ +export class FriendRequestsProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly disposables: vscode.Disposable[] = []; + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter< + FriendRequestsTreeItem | undefined + >(); + private incomingRequests: FriendRequestDTO[] = []; + private outgoingRequests: FriendRequestDTO[] = []; + private isLoading = false; + + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + constructor( + private readonly friendRequestService: FriendRequestService, + private readonly logger: Logger + ) { + this.disposables.push(this.onDidChangeTreeDataEmitter); + + /* Listen for changes and refresh */ + this.disposables.push( + this.friendRequestService.onRequestsChanged(() => { + void this.refresh(); + }) + ); + } + + getTreeItem(element: FriendRequestsTreeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: FriendRequestsTreeItem): FriendRequestsTreeItem[] { + if (!element) { + /* Root level: return sections */ + return [ + new SectionTreeItem('incoming', this.incomingRequests.length), + new SectionTreeItem('outgoing', this.outgoingRequests.length), + ]; + } + + if (element instanceof SectionTreeItem) { + /* Return requests for this section */ + if (element.section === 'incoming') { + return this.incomingRequests.map( + (request) => new FriendRequestTreeItem(request, 'incoming') + ); + } else { + return this.outgoingRequests.map( + (request) => new FriendRequestTreeItem(request, 'outgoing') + ); + } + } + + return []; + } + + /*** Refresh friend requests from the server ***/ + async refresh(): Promise { + if (this.isLoading) { + return; + } + + this.isLoading = true; + + try { + const [incomingResponse, outgoingResponse] = await Promise.all([ + this.friendRequestService.getIncomingRequests(), + this.friendRequestService.getOutgoingRequests(), + ]); + + this.incomingRequests = incomingResponse.data; + this.outgoingRequests = outgoingResponse.data; + + this.logger.debug('Friend requests refreshed', { + incoming: this.incomingRequests.length, + outgoing: this.outgoingRequests.length, + }); + } catch (error) { + this.logger.error('Failed to refresh friend requests', error); + /* Don't clear existing data on error */ + } finally { + this.isLoading = false; + this.onDidChangeTreeDataEmitter.fire(undefined); + } + } + + /*** Clear all requests (called on logout) ***/ + clear(): void { + this.incomingRequests = []; + this.outgoingRequests = []; + this.onDidChangeTreeDataEmitter.fire(undefined); + } + + /*** Get pending incoming count (for badge) ***/ + getPendingCount(): number { + return this.incomingRequests.length; + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} diff --git a/apps/extension/src/views/index.ts b/apps/extension/src/views/index.ts index b8d6975..164d455 100644 --- a/apps/extension/src/views/index.ts +++ b/apps/extension/src/views/index.ts @@ -1,3 +1,4 @@ export { FriendsProvider, type FriendInfo } from './friendsProvider'; +export { FriendRequestsProvider } from './friendRequestsProvider'; export { ActivityProvider, type ActivityEvent } from './activityProvider'; export { StatusBarManager } from './statusBarItem'; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index b1388cb..d997d94 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -25,6 +25,9 @@ model User { teams TeamMember[] ownedTeams Team[] @relation("TeamOwner") + sentFriendRequests FriendRequest[] @relation("SentRequests") + receivedFriendRequests FriendRequest[] @relation("ReceivedRequests") + @@index([username]) @@index([githubId]) } @@ -42,6 +45,25 @@ model Follow { @@index([followingId]) } +/// Friend request between two users. +/// When accepted, bidirectional Follow entries are created. +model FriendRequest { + id String @id @default(cuid()) + fromId String + toId String + status FriendRequestStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + from User @relation("SentRequests", fields: [fromId], references: [id], onDelete: Cascade) + to User @relation("ReceivedRequests", fields: [toId], references: [id], onDelete: Cascade) + + @@unique([fromId, toId]) + @@index([fromId]) + @@index([toId]) + @@index([status]) +} + model Team { id String @id @default(cuid()) name String @@ -59,11 +81,11 @@ model Team { } model TeamMember { - id String @id @default(cuid()) - userId String - teamId String - role Role @default(MEMBER) - joinedAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + teamId String + role Role @default(MEMBER) + joinedAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) @@ -84,3 +106,9 @@ enum Role { ADMIN MEMBER } + +enum FriendRequestStatus { + PENDING + ACCEPTED + REJECTED +} diff --git a/apps/server/src/routes/friendRequests.ts b/apps/server/src/routes/friendRequests.ts new file mode 100644 index 0000000..63522b8 --- /dev/null +++ b/apps/server/src/routes/friendRequests.ts @@ -0,0 +1,482 @@ +/*** Friend Request Routes + * + * Manages friend requests between users: + * - POST /friend-requests - Send a friend request + * - GET /friend-requests/incoming - List pending incoming requests + * - GET /friend-requests/outgoing - List pending outgoing requests + * - POST /friend-requests/:id/accept - Accept a friend request + * - POST /friend-requests/:id/reject - Reject a friend request + * - DELETE /friend-requests/:id - Cancel an outgoing request + */ + +import { PaginationQuerySchema, SendFriendRequestSchema } from '@devradar/shared'; +import { z } from 'zod'; + +import type { PublicUserDTO, FriendRequestDTO } from '@devradar/shared'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { NotFoundError, ConflictError, ValidationError, AuthorizationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { broadcastToUsers } from '@/ws/handler'; + +/*** Request ID params schema ***/ +const RequestIdParamsSchema = z.object({ + id: z.string().min(1, 'Request ID is required'), +}); + +/*** Type for friend request with user details ***/ +interface FriendRequestWithUsers { + id: string; + fromId: string; + toId: string; + status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; + createdAt: Date; + from: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + }; + to: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + }; +} + +/*** Convert user to PublicUserDTO ***/ +function toPublicUserDTO(user: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +}): PublicUserDTO { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }; +} + +/*** Convert friend request to DTO ***/ +function toFriendRequestDTO(request: FriendRequestWithUsers): FriendRequestDTO { + return { + id: request.id, + fromUser: toPublicUserDTO(request.from), + toUser: toPublicUserDTO(request.to), + status: request.status, + createdAt: request.createdAt.toISOString(), + }; +} + +/*** Register friend request routes ***/ +export function friendRequestRoutes(app: FastifyInstance): void { + const db = getDb(); + + /*** POST /friend-requests + * Send a friend request to another user ***/ + app.post( + '/', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const bodyResult = SendFriendRequestSchema.safeParse(request.body); + if (!bodyResult.success) { + throw new ValidationError('Invalid request body', { + details: { errors: bodyResult.error.issues }, + }); + } + + const { toUserId } = bodyResult.data; + + /* Prevent self-request */ + if (userId === toUserId) { + throw new ConflictError('You cannot send a friend request to yourself'); + } + + /* Check if target user exists */ + const targetUser = await db.user.findUnique({ + where: { id: toUserId }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + if (!targetUser) { + throw new NotFoundError('User', toUserId); + } + + /* Check if already friends (bidirectional follow - BOTH directions must exist) */ + const mutualFollows = await db.follow.findMany({ + where: { + OR: [ + { followerId: userId, followingId: toUserId }, + { followerId: toUserId, followingId: userId }, + ], + }, + }); + + if (mutualFollows.length === 2) { + throw new ConflictError('You are already friends with this user'); + } + + /* Check for existing pending request in either direction */ + const existingRequest = await db.friendRequest.findFirst({ + where: { + OR: [ + { fromId: userId, toId: toUserId, status: 'PENDING' }, + { fromId: toUserId, toId: userId, status: 'PENDING' }, + ], + }, + include: { + from: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + to: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + }, + }); + + if (existingRequest) { + if (existingRequest.fromId === userId) { + throw new ConflictError('You already have a pending request to this user'); + } else { + /* They sent us a request - auto-accept it! */ + return await acceptRequestInternal( + db, + existingRequest as FriendRequestWithUsers, + userId, + reply + ); + } + } + + /* Delete any previously REJECTED requests to allow resending */ + await db.friendRequest.deleteMany({ + where: { + fromId: userId, + toId: toUserId, + status: 'REJECTED', + }, + }); + + /* Get current user info for the response */ + const currentUser = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + if (!currentUser) { + throw new NotFoundError('User', userId); + } + + /* Create the friend request */ + const friendRequest = await db.friendRequest.create({ + data: { + fromId: userId, + toId: toUserId, + }, + include: { + from: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + to: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + }, + }); + + const requestDTO = toFriendRequestDTO(friendRequest as FriendRequestWithUsers); + + logger.info( + { fromId: userId, toId: toUserId, requestId: friendRequest.id }, + 'Friend request sent' + ); + + /* Broadcast to target user via WebSocket */ + broadcastToUsers([toUserId], 'FRIEND_REQUEST_RECEIVED', { request: requestDTO }); + + return reply.status(201).send({ + data: requestDTO, + }); + } + ); + + /*** GET /friend-requests/incoming + * List pending incoming friend requests ***/ + app.get( + '/incoming', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paginationResult = PaginationQuerySchema.safeParse(request.query); + const { page, limit } = paginationResult.success + ? paginationResult.data + : { page: 1, limit: 20 }; + + const skip = (page - 1) * limit; + + const [requests, total] = await Promise.all([ + db.friendRequest.findMany({ + where: { toId: userId, status: 'PENDING' }, + include: { + from: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + to: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + db.friendRequest.count({ where: { toId: userId, status: 'PENDING' } }), + ]); + + return reply.send({ + data: (requests as FriendRequestWithUsers[]).map(toFriendRequestDTO), + pagination: { + page, + limit, + total, + hasMore: skip + requests.length < total, + }, + }); + } + ); + + /*** GET /friend-requests/outgoing + * List pending outgoing friend requests ***/ + app.get( + '/outgoing', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paginationResult = PaginationQuerySchema.safeParse(request.query); + const { page, limit } = paginationResult.success + ? paginationResult.data + : { page: 1, limit: 20 }; + + const skip = (page - 1) * limit; + + const [requests, total] = await Promise.all([ + db.friendRequest.findMany({ + where: { fromId: userId, status: 'PENDING' }, + include: { + from: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + to: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + }, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + db.friendRequest.count({ where: { fromId: userId, status: 'PENDING' } }), + ]); + + return reply.send({ + data: (requests as FriendRequestWithUsers[]).map(toFriendRequestDTO), + pagination: { + page, + limit, + total, + hasMore: skip + requests.length < total, + }, + }); + } + ); + + /*** POST /friend-requests/:id/accept + * Accept a friend request ***/ + app.post( + '/:id/accept', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = RequestIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid request ID'); + } + + const { id: requestId } = paramsResult.data; + + /* Find the request */ + const friendRequest = await db.friendRequest.findUnique({ + where: { id: requestId }, + include: { + from: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + to: { select: { id: true, username: true, displayName: true, avatarUrl: true } }, + }, + }); + + if (!friendRequest) { + throw new NotFoundError('Friend request', requestId); + } + + /* Only the recipient can accept */ + if (friendRequest.toId !== userId) { + throw new AuthorizationError('You can only accept requests sent to you'); + } + + if (friendRequest.status !== 'PENDING') { + throw new ConflictError(`Request has already been ${friendRequest.status.toLowerCase()}`); + } + + return await acceptRequestInternal( + db, + friendRequest as FriendRequestWithUsers, + userId, + reply + ); + } + ); + + /*** POST /friend-requests/:id/reject + * Reject a friend request ***/ + app.post( + '/:id/reject', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = RequestIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid request ID'); + } + + const { id: requestId } = paramsResult.data; + + /* Find the request */ + const friendRequest = await db.friendRequest.findUnique({ + where: { id: requestId }, + }); + + if (!friendRequest) { + throw new NotFoundError('Friend request', requestId); + } + + /* Only the recipient can reject */ + if (friendRequest.toId !== userId) { + throw new AuthorizationError('You can only reject requests sent to you'); + } + + if (friendRequest.status !== 'PENDING') { + throw new ConflictError(`Request has already been ${friendRequest.status.toLowerCase()}`); + } + + /* Update status to rejected */ + await db.friendRequest.update({ + where: { id: requestId }, + data: { status: 'REJECTED' }, + }); + + logger.info({ requestId, userId }, 'Friend request rejected'); + + return reply.status(200).send({ + data: { message: 'Friend request rejected' }, + }); + } + ); + + /*** DELETE /friend-requests/:id + * Cancel an outgoing friend request ***/ + app.delete( + '/:id', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = RequestIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid request ID'); + } + + const { id: requestId } = paramsResult.data; + + /* Find the request */ + const friendRequest = await db.friendRequest.findUnique({ + where: { id: requestId }, + }); + + if (!friendRequest) { + throw new NotFoundError('Friend request', requestId); + } + + /* Only the sender can cancel */ + if (friendRequest.fromId !== userId) { + throw new AuthorizationError('You can only cancel requests you sent'); + } + + if (friendRequest.status !== 'PENDING') { + throw new ConflictError(`Request has already been ${friendRequest.status.toLowerCase()}`); + } + + /* Delete the request */ + await db.friendRequest.delete({ + where: { id: requestId }, + }); + + logger.info({ requestId, userId }, 'Friend request cancelled'); + + return reply.status(204).send(); + } + ); + + /*** GET /friend-requests/count + * Get count of pending incoming requests (for badge) ***/ + app.get( + '/count', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const count = await db.friendRequest.count({ + where: { toId: userId, status: 'PENDING' }, + }); + + return reply.send({ + data: { count }, + }); + } + ); +} + +/*** Internal function to accept a friend request ***/ +async function acceptRequestInternal( + db: ReturnType, + friendRequest: FriendRequestWithUsers, + userId: string, + reply: FastifyReply +): Promise { + /* Use a transaction to ensure atomicity */ + await db.$transaction(async (tx) => { + /* Update request status */ + await tx.friendRequest.update({ + where: { id: friendRequest.id }, + data: { status: 'ACCEPTED' }, + }); + + /* Create bidirectional follow entries */ + await tx.follow.createMany({ + data: [ + { followerId: friendRequest.fromId, followingId: friendRequest.toId }, + { followerId: friendRequest.toId, followingId: friendRequest.fromId }, + ], + skipDuplicates: true, + }); + }); + + logger.info({ requestId: friendRequest.id, userId }, 'Friend request accepted'); + + /* Broadcast to both users */ + broadcastToUsers([friendRequest.fromId], 'FRIEND_REQUEST_ACCEPTED', { + requestId: friendRequest.id, + friend: toPublicUserDTO(friendRequest.to), + }); + + broadcastToUsers([friendRequest.toId], 'FRIEND_REQUEST_ACCEPTED', { + requestId: friendRequest.id, + friend: toPublicUserDTO(friendRequest.from), + }); + + return reply.status(200).send({ + data: { + message: 'Friend request accepted', + friend: toPublicUserDTO(friendRequest.from), + }, + }); +} diff --git a/apps/server/src/routes/friends.ts b/apps/server/src/routes/friends.ts index 53e8d67..c10fdcf 100644 --- a/apps/server/src/routes/friends.ts +++ b/apps/server/src/routes/friends.ts @@ -236,7 +236,7 @@ export function friendRoutes(app: FastifyInstance): void { ); /*** DELETE /friends/:id - * Unfollow a user ***/ + * Unfriend a user (removes bidirectional follow relationship) ***/ app.delete( '/:id', { onRequest: [app.authenticate] }, @@ -249,30 +249,32 @@ export function friendRoutes(app: FastifyInstance): void { } const { id: targetUserId } = paramsResult.data; - /* Find and delete follow */ - const follow = await db.follow.findUnique({ + + /* Check if friendship exists (at least one direction) */ + const existingFollow = await db.follow.findFirst({ where: { - followerId_followingId: { - followerId: userId, - followingId: targetUserId, - }, + OR: [ + { followerId: userId, followingId: targetUserId }, + { followerId: targetUserId, followingId: userId }, + ], }, }); - if (!follow) { - throw new NotFoundError('Follow relationship'); + if (!existingFollow) { + throw new NotFoundError('Friendship'); } - await db.follow.delete({ + /* Delete both directions (unfriend is bidirectional) */ + await db.follow.deleteMany({ where: { - followerId_followingId: { - followerId: userId, - followingId: targetUserId, - }, + OR: [ + { followerId: userId, followingId: targetUserId }, + { followerId: targetUserId, followingId: userId }, + ], }, }); - logger.info({ userId, targetUserId }, 'User unfollowed'); + logger.info({ userId, targetUserId }, 'Users unfriended (bidirectional)'); return reply.status(204).send(); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c8e4abd..654023d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -22,6 +22,7 @@ import { env, isProduction, isDevelopment } from '@/config'; import { toAppError, AuthenticationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { authRoutes } from '@/routes/auth'; +import { friendRequestRoutes } from '@/routes/friendRequests'; import { friendRoutes } from '@/routes/friends'; import { userRoutes } from '@/routes/users'; import { connectDb, disconnectDb, isDbHealthy } from '@/services/db'; @@ -213,6 +214,7 @@ async function buildServer() { (api, _opts, done) => { api.register(userRoutes, { prefix: '/users' }); api.register(friendRoutes, { prefix: '/friends' }); + api.register(friendRequestRoutes, { prefix: '/friend-requests' }); done(); }, { prefix: '/api/v1' } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6cbe0b9..4933470 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -7,10 +7,14 @@ export type TierType = 'FREE' | 'PRO' | 'TEAM'; /*** Team member role ***/ export type RoleType = 'OWNER' | 'ADMIN' | 'MEMBER'; +/*** Friend request status ***/ +export type FriendRequestStatus = 'PENDING' | 'ACCEPTED' | 'REJECTED'; + /*** WebSocket message types ***/ export type MessageType = | 'AUTH' | 'AUTH_SUCCESS' + | 'CONNECTED' | 'STATUS_UPDATE' | 'FRIEND_STATUS' | 'POKE' @@ -18,7 +22,9 @@ export type MessageType = | 'ACHIEVEMENT' | 'ERROR' | 'HEARTBEAT' - | 'PONG'; + | 'PONG' + | 'FRIEND_REQUEST_RECEIVED' + | 'FRIEND_REQUEST_ACCEPTED'; /*** Unix timestamp in milliseconds (value returned by Date.now()) ***/ export type EpochMillis = number; @@ -83,6 +89,35 @@ export interface UserDTO { createdAt: string; } +/*** Minimal public user info for friend requests and search results ***/ +export interface PublicUserDTO { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +/*** Friend request data transfer object ***/ +export interface FriendRequestDTO { + id: string; + fromUser: PublicUserDTO; + toUser: PublicUserDTO; + status: FriendRequestStatus; + /** Request creation timestamp as ISO 8601 string */ + createdAt: string; +} + +/*** WebSocket payload: Friend request received ***/ +export interface FriendRequestReceivedPayload { + request: FriendRequestDTO; +} + +/*** WebSocket payload: Friend request accepted ***/ +export interface FriendRequestAcceptedPayload { + requestId: string; + friend: PublicUserDTO; +} + /*** Poke request payload ***/ export interface PokePayload { fromUserId: string; diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index d4d6abf..8dafc23 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -9,6 +9,9 @@ export const TierSchema = z.enum(['FREE', 'PRO', 'TEAM']); /*** Role schema ***/ export const RoleSchema = z.enum(['OWNER', 'ADMIN', 'MEMBER']); +/*** Friend request status schema ***/ +export const FriendRequestStatusSchema = z.enum(['PENDING', 'ACCEPTED', 'REJECTED']); + /*** Message type schema ***/ export const MessageTypeSchema = z.enum([ 'STATUS_UPDATE', @@ -19,6 +22,9 @@ export const MessageTypeSchema = z.enum([ 'ERROR', 'HEARTBEAT', 'PONG', + 'CONNECTED', + 'FRIEND_REQUEST_RECEIVED', + 'FRIEND_REQUEST_ACCEPTED', ]); /*** Activity payload schema ***/ @@ -70,6 +76,12 @@ export const PaginationQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), }); + +/*** Send friend request schema ***/ +export const SendFriendRequestSchema = z.object({ + toUserId: z.string().min(1, 'User ID is required'), +}); + /* Type exports from schemas */ export type ActivityPayloadInput = z.infer; export type UserStatusInput = z.infer; @@ -78,3 +90,5 @@ export type PokePayloadInput = z.infer; export type LoginRequestInput = z.infer; export type UserUpdateInput = z.infer; export type PaginationQueryInput = z.infer; +export type SendFriendRequestInput = z.infer; +export type FriendRequestStatusInput = z.infer;