diff --git a/backend-node/controllers/groups.controller.js b/backend-node/controllers/groups.controller.js index 0f8cadd..26d0a55 100644 --- a/backend-node/controllers/groups.controller.js +++ b/backend-node/controllers/groups.controller.js @@ -25,7 +25,7 @@ class GroupsController { async createGroup(req, res, next) { try { - const group = await this.groupsService.createGroup(req.body); + const group = await this.groupsService.createGroup(req.body, req.userId); return ResponseHandler.created(res, group, 'Group created successfully'); } catch (error) { next(error); diff --git a/backend-node/controllers/users.controller.js b/backend-node/controllers/users.controller.js index 92fc887..33d7612 100644 --- a/backend-node/controllers/users.controller.js +++ b/backend-node/controllers/users.controller.js @@ -14,6 +14,19 @@ class UsersController { } } + async searchUsers(req, res, next) { + try { + const { q } = req.query; + if (!q || !q.trim()) { + return ResponseHandler.badRequest(res, 'Query parameter "q" is required'); + } + const users = await this.usersService.searchUsers(q.trim(), req.userId); + return ResponseHandler.success(res, users, 'Users found'); + } catch (error) { + next(error); + } + } + async updateCurrentUser(req, res, next) { try { const user = await this.usersService.updateUserProfile(req.userId, req.body); diff --git a/backend-node/repositories/postgres/users.repository.js b/backend-node/repositories/postgres/users.repository.js index a7ee3a2..eb03d0f 100644 --- a/backend-node/repositories/postgres/users.repository.js +++ b/backend-node/repositories/postgres/users.repository.js @@ -1,3 +1,4 @@ +const { Op } = require('sequelize'); const { NotFoundError, DatabaseError } = require('../../shared/errors'); class UsersRepository { @@ -6,6 +7,23 @@ class UsersRepository { this.User = sequelize.models.User; } + async search(query, excludeUserId) { + try { + return await this.User.findAll({ + where: { + id: { [Op.ne]: excludeUserId }, + [Op.or]: [ + { name: { [Op.iLike]: `%${query}%` } }, + { email: { [Op.iLike]: `%${query}%` } }, + ], + }, + limit: 20, + }); + } catch (error) { + throw new DatabaseError('Failed to search users', error); + } + } + async findAll(options = {}) { try { return await this.User.findAll(options); diff --git a/backend-node/routes/v1/users.routes.js b/backend-node/routes/v1/users.routes.js index c6229c1..9e3e087 100644 --- a/backend-node/routes/v1/users.routes.js +++ b/backend-node/routes/v1/users.routes.js @@ -8,8 +8,10 @@ module.exports = ({ usersController }) => { const controller = { getCurrentUser: (req, res, next) => usersController.getCurrentUser(req, res, next), updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next), + searchUsers: (req, res, next) => usersController.searchUsers(req, res, next), }; + router.get('/search', controller.searchUsers); router.get('/current', controller.getCurrentUser); router.put('/current', validate(updateUserSchema), controller.updateCurrentUser); diff --git a/backend-node/services/groups.service.js b/backend-node/services/groups.service.js index 9011058..67c4214 100644 --- a/backend-node/services/groups.service.js +++ b/backend-node/services/groups.service.js @@ -14,23 +14,24 @@ class GroupsService { return await this.groupsRepository.findById(groupId, true); } - async createGroup(groupData) { + async createGroup(groupData, creatorId) { const { name, members } = groupData; if (!name || name.trim() === '') { throw new ValidationError('Group name is required'); } - if (!members || !Array.isArray(members) || members.length === 0) { - throw new ValidationError('At least one member is required'); + const memberList = Array.isArray(members) ? [...members] : []; + if (!memberList.includes(creatorId)) { + memberList.push(creatorId); } - for (const userId of members) { + for (const userId of memberList) { await this.usersRepository.findById(userId); } const group = await this.groupsRepository.create({ name }); - return await this.groupsRepository.addMembers(group.id, members); + return await this.groupsRepository.addMembers(group.id, memberList); } async updateGroup(groupId, updateData) { diff --git a/backend-node/services/users.service.js b/backend-node/services/users.service.js index 5554578..af4fb2f 100644 --- a/backend-node/services/users.service.js +++ b/backend-node/services/users.service.js @@ -13,6 +13,10 @@ class UsersService { return await this.usersRepository.findById(id); } + async searchUsers(query, excludeUserId) { + return await this.usersRepository.search(query, excludeUserId); + } + async updateUserProfile(id, updateData) { const { name, email, avatarUrl } = updateData; diff --git a/frontend-angular-new/src/app/components/profile/profile.html b/frontend-angular-new/src/app/components/profile/profile.html index 61e2727..6a7798b 100644 --- a/frontend-angular-new/src/app/components/profile/profile.html +++ b/frontend-angular-new/src/app/components/profile/profile.html @@ -56,36 +56,81 @@ } + + @if (incomingRequests().length > 0) { +
+
+

Richieste di amicizia

+ {{ incomingRequests().length }} +
+
+ @for (req of incomingRequests(); track req.id) { +
+
+ @if (req.fromUser?.avatarUrl) { + + } @else { + person + } +
+

{{ req.fromUser?.name }}

+ @if (respondingToId() === req.id) { +
+ } @else { +
+ + +
+ } +
+ } +
+
+ } +

Amici

-
- +
-
-
- Friend Avatar + @for (friend of friends(); track friend.id) { +
+
+ @if (friend.avatarUrl) { + + } @else { +
+ person +
+ } +
+ {{ friend.name }}
- Marco -
-
-
- Friend Avatar -
- Luca -
-
+ } + @if (friends().length === 0) { +

Nessun amico ancora

+ } +
+
@@ -147,3 +192,164 @@ + + +@if (showFriendsSheet()) { +
+
+ +
+ + +
+
+
+ + +
+

Amici ({{ friends().length }})

+ +
+ + +
+
+ search + + @if (friendsSearchQuery().trim()) { + + } +
+
+ + +
+ @if (filteredFriends().length > 0) { + @for (friend of filteredFriends(); track friend.id) { +
+
+ @if (friend.avatarUrl) { + + } @else { + person + } +
+
+

{{ friend.name }}

+

{{ friend.email }}

+
+ people +
+ } + } @else if (friendsSearchQuery().trim()) { +

Nessun amico trovato

+ } @else { +

Non hai ancora nessun amico

+ } +
+
+
+} + + +@if (showAddFriendSheet()) { +
+ +
+ + +
+ +
+
+
+ + +
+

Aggiungi amico

+ +
+ + +
+
+ search + + @if (isSearching()) { +
+ } +
+
+ + +
+ @if (searchResults().length > 0) { + @for (user of searchResults(); track user.id) { + + } + } @else if (searchQuery().trim() && !isSearching()) { +

Nessun utente trovato

+ } @else if (!searchQuery().trim()) { +

Cerca un utente per nome o email

+ } +
+
+
+} diff --git a/frontend-angular-new/src/app/components/profile/profile.ts b/frontend-angular-new/src/app/components/profile/profile.ts index 4014ead..c7604a4 100644 --- a/frontend-angular-new/src/app/components/profile/profile.ts +++ b/frontend-angular-new/src/app/components/profile/profile.ts @@ -1,26 +1,59 @@ import {ChangeDetectionStrategy, Component, inject, signal, OnInit} from '@angular/core'; import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; import {MatIconModule} from '@angular/material/icon'; import {RouterModule} from '@angular/router'; +import {debounceTime, distinctUntilChanged, Subject, switchMap} from 'rxjs'; import {UserService} from '../../services/user'; +import {FriendService} from '../../services/friend.service'; import {LanguageService} from '../../services/language'; -import {User} from '../../models/data.models'; +import {FriendRequest, User} from '../../models/data.models'; @Component({ selector: 'app-profile', standalone: true, - imports: [CommonModule, MatIconModule, RouterModule], + imports: [CommonModule, FormsModule, MatIconModule, RouterModule], templateUrl: './profile.html', styleUrl: './profile.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class Profile implements OnInit { private userService = inject(UserService); + private friendService = inject(FriendService); private langService = inject(LanguageService); - + userProfile = signal(null); + friends = signal([]); + pendingRequests = signal([]); + respondingToId = signal(null); isLoading = signal(true); + // Add friend sheet + showAddFriendSheet = signal(false); + sheetExpanded = signal(false); + searchQuery = signal(''); + searchResults = signal([]); + isSearching = signal(false); + sendingToId = signal(null); + sentToIds = signal>(new Set()); + + // Friends list sheet + showFriendsSheet = signal(false); + friendsSheetExpanded = signal(false); + friendsSearchQuery = signal(''); + + filteredFriends(): User[] { + const q = this.friendsSearchQuery().toLowerCase().trim(); + if (!q) return this.friends(); + return this.friends().filter(f => + f.name.toLowerCase().includes(q) || f.email?.toLowerCase().includes(q) + ); + } + + private touchStartY = 0; + + private searchInput$ = new Subject(); + ngOnInit() { this.userService.getCurrentUser().subscribe({ next: (user) => { @@ -32,6 +65,151 @@ export class Profile implements OnInit { this.isLoading.set(false); } }); + + this.friendService.getFriends().subscribe({ + next: (friends) => this.friends.set(friends), + error: (err) => console.error('Error loading friends:', err), + }); + + this.friendService.getPendingRequests().subscribe({ + next: (requests) => this.pendingRequests.set(requests), + error: (err) => console.error('Error loading pending requests:', err), + }); + + this.searchInput$.pipe( + debounceTime(350), + distinctUntilChanged(), + switchMap((query) => { + if (!query.trim()) { + this.searchResults.set([]); + this.isSearching.set(false); + return []; + } + this.isSearching.set(true); + return this.userService.searchUsers(query); + }) + ).subscribe({ + next: (results) => { + this.searchResults.set(results); + this.isSearching.set(false); + }, + error: () => this.isSearching.set(false), + }); + } + + openAddFriendSheet() { + this.searchQuery.set(''); + this.searchResults.set([]); + this.sentToIds.set(new Set()); + this.sheetExpanded.set(false); + this.showAddFriendSheet.set(true); + } + + closeAddFriendSheet() { + this.showAddFriendSheet.set(false); + this.sheetExpanded.set(false); + } + + openFriendsSheet() { + this.friendsSearchQuery.set(''); + this.friendsSheetExpanded.set(false); + this.showFriendsSheet.set(true); + } + + closeFriendsSheet() { + this.showFriendsSheet.set(false); + this.friendsSheetExpanded.set(false); + } + + onHandleTouchStart(event: TouchEvent) { + this.touchStartY = event.touches[0].clientY; + } + + onHandleTouchEnd(event: TouchEvent) { + const deltaY = this.touchStartY - event.changedTouches[0].clientY; + if (deltaY > 40) { + this.sheetExpanded.set(true); + } else if (deltaY < -40) { + if (this.sheetExpanded()) { + this.sheetExpanded.set(false); + } else { + this.closeAddFriendSheet(); + } + } + } + + onFriendsHandleTouchEnd(event: TouchEvent) { + const deltaY = this.touchStartY - event.changedTouches[0].clientY; + if (deltaY > 40) { + this.friendsSheetExpanded.set(true); + } else if (deltaY < -40) { + if (this.friendsSheetExpanded()) { + this.friendsSheetExpanded.set(false); + } else { + this.closeFriendsSheet(); + } + } + } + + onSearchChange(query: string) { + this.searchQuery.set(query); + this.searchInput$.next(query); + } + + sendFriendRequest(user: User) { + this.sendingToId.set(user.id); + this.friendService.sendRequest(user.id).subscribe({ + next: () => { + this.sendingToId.set(null); + const updated = new Set(this.sentToIds()); + updated.add(user.id); + this.sentToIds.set(updated); + }, + error: (err) => { + console.error('Error sending friend request:', err); + this.sendingToId.set(null); + }, + }); + } + + incomingRequests(): FriendRequest[] { + const currentId = this.userProfile()?.id; + return this.pendingRequests().filter(r => String(r.toUserId) === String(currentId)); + } + + acceptRequest(request: FriendRequest) { + this.respondingToId.set(request.id); + this.friendService.acceptRequest(Number(request.id)).subscribe({ + next: () => { + this.pendingRequests.update(reqs => reqs.filter(r => r.id !== request.id)); + if (request.fromUser) { + this.friends.update(f => [...f, request.fromUser!]); + } + this.respondingToId.set(null); + }, + error: (err) => { + console.error('Error accepting request:', err); + this.respondingToId.set(null); + }, + }); + } + + rejectRequest(request: FriendRequest) { + this.respondingToId.set(request.id); + this.friendService.rejectRequest(Number(request.id)).subscribe({ + next: () => { + this.pendingRequests.update(reqs => reqs.filter(r => r.id !== request.id)); + this.respondingToId.set(null); + }, + error: (err) => { + console.error('Error rejecting request:', err); + this.respondingToId.set(null); + }, + }); + } + + isFriend(userId: string): boolean { + return this.friends().some(f => f.id === userId); } t(key: string): string { diff --git a/frontend-angular-new/src/app/models/data.models.ts b/frontend-angular-new/src/app/models/data.models.ts index 23a15ae..16d4ad4 100644 --- a/frontend-angular-new/src/app/models/data.models.ts +++ b/frontend-angular-new/src/app/models/data.models.ts @@ -110,6 +110,8 @@ export interface FriendRequest { fromUserId: number; toUserId: number; createdAt: string; + fromUser?: User; + toUser?: User; } export interface ActivityItem { diff --git a/frontend-angular-new/src/app/services/friend.service.ts b/frontend-angular-new/src/app/services/friend.service.ts index c183926..960346a 100644 --- a/frontend-angular-new/src/app/services/friend.service.ts +++ b/frontend-angular-new/src/app/services/friend.service.ts @@ -1,8 +1,10 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { environment } from '../../environments/environment'; -import { FriendRequest } from '../models/data.models'; +import { FriendRequest, User } from '../models/data.models'; +import { SuccessResponse } from '../models/api-responses.model'; @Injectable({ providedIn: 'root', @@ -11,12 +13,12 @@ export class FriendService { private http = inject(HttpClient); private readonly base = `${environment.apiUrl}/api/v1/friend-requests`; - getFriends(): Observable { - return this.http.get(this.base); + getFriends(): Observable { + return this.http.get>(this.base).pipe(map(res => res.data)); } getPendingRequests(): Observable { - return this.http.get(`${this.base}/pending`); + return this.http.get>(`${this.base}/pending`).pipe(map(res => res.data)); } sendRequest(identifier: string | number): Observable {