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;