50 Commits

Author SHA1 Message Date
Mathis HERRIOT
22c753d1e7 chore: bump version to 1.9.6
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m43s
CI/CD Pipeline / Valider frontend (push) Successful in 1m50s
CI/CD Pipeline / Valider documentation (push) Successful in 1m51s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m26s
2026-02-01 20:27:58 +01:00
Mathis HERRIOT
1f7bd51a7b feat(docs): add detailed features and business flow diagrams
- Introduced new interaction and community features, including comments and private messaging.
- Added technical diagrams for critical workflows: authentication, content publication, and messaging.
- Enhanced data model documentation with support for comments and messaging tables.
- Updated API references with endpoints for comments, messaging, and user search.
- Integrated post-quantum cryptography for improved data protection.
2026-02-01 20:27:46 +01:00
Mathis HERRIOT
f34fd644b8 chore: bump version to 1.9.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 17s
2026-01-29 21:42:34 +01:00
Mathis HERRIOT
c827c2e58d feat(database): increase passwordHash length and add migration snapshot
- Extended `passwordHash` field length in `users` schema from 100 to 255.
- Added migration snapshot for schema updates.
2026-01-29 21:42:05 +01:00
Mathis HERRIOT
30bcfdb436 chore: bump version to 1.9.4
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m30s
CI/CD Pipeline / Valider documentation (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m26s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m19s
2026-01-29 20:49:07 +01:00
Mathis HERRIOT
0b4753c47b style(messages): reformat import statements in MessagesService 2026-01-29 20:48:57 +01:00
Mathis HERRIOT
69b90849fd feat(messages): integrate UsersModule into MessagesModule with forward-ref
- Added `UsersModule` to `MessagesModule` imports using `forwardRef`.
- Injected `UsersService` into `MessagesService` to support user-related operations.
2026-01-29 20:44:35 +01:00
Mathis HERRIOT
f2950ecf86 chore: bump version to 1.9.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m16s
2026-01-29 20:33:19 +01:00
Mathis HERRIOT
1e17308aab feat(realtime): add ConfigModule and UsersModule to RealtimeModule
- Integrated `ConfigModule` for configuration management.
- Added `UsersModule` to enable forward-ref dependencies in realtime services.
2026-01-29 20:32:34 +01:00
Mathis HERRIOT
ca4b594828 chore: bump version to 1.9.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m36s
2026-01-29 18:22:00 +01:00
Mathis HERRIOT
2ea16773c8 feat(users): add boolean fields for online status and read receipts
- Added `showOnlineStatus` and `showReadReceipts` fields to `UpdateUserDto` with validation.
2026-01-29 18:21:54 +01:00
Mathis HERRIOT
616d7f76d7 feat: add support for online status and read receipt preferences
- Added `showOnlineStatus` and `showReadReceipts` fields to settings form.
- Introduced real-time synchronization for read receipts in message threads.
- Enhanced avatars to display online status indicators.
- Automatically mark messages as read when viewing active conversations.
2026-01-29 18:20:58 +01:00
Mathis HERRIOT
f882a70343 feat: add read receipt handling based on user preferences
- Integrated `UsersService` into `MessagesService` for retrieving user preferences.
- Updated `markAsRead` functionality to respect `showReadReceipts` preference.
- Enhanced real-time read receipt notifications via `EventsGateway`.
- Added `markAsRead` method to the frontend message service.
2026-01-29 18:20:18 +01:00
Mathis HERRIOT
779bb5c112 feat: integrate user preferences for online status in WebSocket gateway
- Added `UsersService` to manage user preferences in `EventsGateway`.
- Enhanced online/offline broadcasting to respect user `showOnlineStatus` preference.
- Updated `handleTyping` and `check_status` to verify user preferences before emitting events.
- Abstracted status broadcasting logic into `broadcastStatus`.
2026-01-29 18:20:04 +01:00
Mathis HERRIOT
5753477717 feat: add user preferences for online status and read receipts with real-time updates
- Introduced `showOnlineStatus` and `showReadReceipts` fields in the user schema and API.
- Integrated real-time status broadcasting in `UsersService` via `EventsGateway`.
- Updated repository and frontend user types to align with new fields.
- Enhanced user update handling to support dynamic preference changes for online status.
2026-01-29 18:18:52 +01:00
Mathis HERRIOT
7615ec670e chore: bump version to 1.9.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m58s
2026-01-29 17:44:55 +01:00
Mathis HERRIOT
40cfff683d fix: ensure decrypted PGP values are cast to text in SQL queries
- Added `::text` cast to `pgp_sym_decrypt` function calls for consistent data type handling.
2026-01-29 17:44:50 +01:00
Mathis HERRIOT
bb52782226 feat: enhance environment configuration and CORS handling
- Added `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_CONTACT_EMAIL` to environment variables for frontend configuration.
- Updated CORS logic to support domain-based restrictions with dynamic origin validation.
- Improved frontend image hostname resolution using environment-driven URLs.
- Standardized contact email usage across the application.
2026-01-29 17:34:53 +01:00
Mathis HERRIOT
6a70274623 fix: handle null enriched comment in comments service
- Added a null check for `enrichedComment` to prevent processing invalid data and potential runtime errors.
2026-01-29 17:22:00 +01:00
Mathis HERRIOT
aabc615b89 feat: enhance CORS and user connection handling in WebSocket gateway
- Improved CORS configuration to allow specific origins for development and mobile use cases.
- Added validation for token payload to ensure `sub` property is present.
- Enhanced user connection management by using `userId` consistently for online status tracking and room joining.
2026-01-29 17:21:53 +01:00
Mathis HERRIOT
f9b202375f feat: improve accessibility, security & user interaction in notifications and setup
- Replaced `div` with `button` elements in `NotificationHandler` for better semantics and accessibility.
- Added conditional QR Code reveal in 2FA setup with `blur` effect for enhanced security and user control.
- Enhanced messages layout for responsiveness on smaller screens with dynamic chat/sidebar toggling.
- Simplified legacy prop handling in `ShareDialog`.
2026-01-29 17:21:19 +01:00
Mathis HERRIOT
6398965f16 chore: bump version to 1.9.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m13s
CI/CD Pipeline / Valider frontend (push) Failing after 1m18s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 17:06:14 +01:00
Mathis HERRIOT
9e9b1db012 feat: manage user online status and typing indicator in socket gateway
- Added tracking of online users with real-time status updates (online/offline).
- Implemented `handleTyping` to broadcast user typing events to recipients.
- Added `check_status` handler to query user online status.
- Enhanced CORS configuration to support multi-domain deployments with credentials.
2026-01-29 16:56:36 +01:00
Mathis HERRIOT
62bf03d07a feat: implement NotificationHandler component for real-time notifications
- Added `NotificationHandler` component for managing real-time notifications using sockets.
- Display notifications for comments, replies, likes, and messages with interactive toasts.
- Integrated click handling for navigation to relevant pages based on notification type.
2026-01-29 16:56:19 +01:00
Mathis HERRIOT
c83ba6eb7d feat: add NotificationHandler component to layout
- Integrated `NotificationHandler` into the app layout for centralized notification management.
- Ensured compatibility with existing `Toaster` component for consistent user feedback.
2026-01-29 16:51:20 +01:00
Mathis HERRIOT
05a05a1940 feat: add share dialog and typing indicator in messages
- Implemented `ShareDialog` component for sharing content directly with other users.
- Added typing indicator when a user is composing a message in an active conversation.
- Updated `SocketProvider` to handle improved connection management and user status updates.
- Enhanced the messages UI with real-time online status and typing indicators for better feedback.
2026-01-29 16:50:53 +01:00
Mathis HERRIOT
7c065a2fb9 feat: inject ContentsRepository into CommentsService for better integration
- Added `ContentsRepository` as a dependency to `CommentsService` and updated tests for mock setup.
- Adjusted import order in relevant files to align with project standards.
2026-01-29 16:49:13 +01:00
Mathis HERRIOT
001cdaff8f feat: add notification system for comments and likes
- Notify post authors when their content receives a new comment.
- Notify parent comment authors when their comment receives a reply.
- Send notifications to comment authors when their comments are liked.
- Handle notification errors gracefully with error
2026-01-29 16:41:13 +01:00
Mathis HERRIOT
0eb940c5ce feat: add ContentsModule to CommentsModule with forward reference
- Updated imports in `comments.module.ts` to include `ContentsModule` using `forwardRef` for dependency resolution.
2026-01-29 16:22:55 +01:00
Mathis HERRIOT
f0617c8ba5 chore: bump version to 1.8.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m53s
2026-01-29 16:10:08 +01:00
Mathis HERRIOT
27ea6fa413 feat: add twoFactorEnabled field to User type definition 2026-01-29 16:09:13 +01:00
Mathis HERRIOT
e2146f4502 feat: update exportData method with improved type annotations
- Refined `exportData` method to use `Record<string, unknown>` for more precise type safety.
2026-01-29 16:09:00 +01:00
Mathis HERRIOT
484b775923 feat: update updateUser method to use Partial<User> for improved type safety
- Refactored `updateUser` method in `admin.service.ts` to accept `Partial<User>` instead of `any`.
- Added `User` type import for more precise typing.
2026-01-29 16:06:45 +01:00
Mathis HERRIOT
5b05a14932 feat: update 2FA QR code rendering with Next.js Image
- Replaced `<img>` with Next.js `<Image>` for optimized 2FA QR code rendering.
- Refined `twoFactorEnabled` check for improved readability.
2026-01-29 16:04:58 +01:00
Mathis HERRIOT
2704f7d5c5 feat: add Link import for navigation in messages page
- Introduced `Link` from Next.js for improved inter-page navigation.
2026-01-29 16:01:11 +01:00
Mathis HERRIOT
d271cc215b feat: improve message scrolling and enhance conversation header UX
- Fixed auto-scrolling to the latest message by targeting the correct scroll container.
- Updated the conversation header to include a clickable link to the recipient's profile.
2026-01-29 15:57:25 +01:00
Mathis HERRIOT
9eb5a60fb2 feat: add unread messages badge and live updates in sidebar
- Display unread message count badge in the sidebar.
- Integrate `useSocket` for real-time updates on unread messages.
- Reset unread message count when navigating to the messages page.
- Increment badge count on receiving `new_message` WebSocket events.
2026-01-29 15:56:16 +01:00
Mathis HERRIOT
950646a426 feat: add WebSocket integration for live comment updates
- Introduced `useSocket` to manage WebSocket connections in comment sections.
- Implemented real-time comment updates via `new_comment` WebSocket events.
- Added auto-join and leave for content-specific rooms using WebSocket upon mounting/unmounting.
2026-01-29 15:55:39 +01:00
Mathis HERRIOT
a9b80e66cd feat: enhance user search query with additional filter
- Updated `UsersRepository` to support `lte` condition in user search queries.
- Improved search flexibility by refining query logic with enhanced filters.
2026-01-29 15:55:10 +01:00
Mathis HERRIOT
307655371d feat: add content room subscription and messaging support
- Added `join_content` and `leave_content` WebSocket events for subscribing and unsubscribing to content rooms.
- Implemented `sendToContent` utility method for broadcasting messages to specific content rooms.
- Enhanced connection handling with logging and session validation updates.
2026-01-29 15:54:39 +01:00
Mathis HERRIOT
8eb0cba050 test: improve unit tests with new mocks and WebSocket validation
- Added `markAsRead` and `countUnreadMessages` mocks to `MessagesService` tests.
- Included enriched comment retrieval and WebSocket notification validation in `CommentsService` tests.
- Updated dependency injection to include `EventsGateway` in `CommentsService` tests.
2026-01-29 15:54:16 +01:00
Mathis HERRIOT
50787c9357 feat: enhance messaging system with user search and direct conversations
- Added user-to-user messaging via profile pages.
- Implemented user search functionality with instant result display in the messaging sidebar.
- Introduced support for temporary chat interfaces when messaging new users without prior conversations.
- Included "Message read status" updates with improved UX for message timestamps.
2026-01-29 15:53:53 +01:00
Mathis HERRIOT
0972ed951f feat: add unread message count API
- Added `GET /messages/unread-count` endpoint to retrieve the count of unread messages for a user.
- Implemented `getUnreadCount` method in `MessagesService` and `MessagesRepository`.
- Updated frontend service to support fetching unread message count via API.
2026-01-29 15:47:43 +01:00
Mathis HERRIOT
f852835c59 feat: add user search functionality
- Implemented `GET /users/search` endpoint in the backend to enable user search by username or display name.
- Added `search` method in `UsersService` and `UsersRepository`.
- Updated frontend `UserService` to support the new search API.
2026-01-29 15:47:03 +01:00
Mathis HERRIOT
2c18fd1c1a feat: add API for fetching direct conversation with a user
- Added `GET /messages/conversations/with/:userId` endpoint in the backend to retrieve direct conversation data.
- Implemented corresponding method in `MessagesService` and `MessagesRepository`.
- Updated the frontend service to support fetching direct conversations via API.
2026-01-29 15:46:38 +01:00
Mathis HERRIOT
6d80795e44 feat: add WebSocket notifications for new comments
- Introduced enriched comment retrieval with user information and like statistics.
- Implemented WebSocket notifications to notify users of new comments on content.
- Updated dependency injection to include `EventsGateway` and `RealtimeModule`.
2026-01-29 15:46:00 +01:00
Mathis HERRIOT
ace438be6b chore: bump version to 1.8.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m33s
2026-01-29 15:33:51 +01:00
Mathis HERRIOT
ea1afa7688 test: add unit tests for comment liking and enriched comment data
- Added tests for comment liking (`like` and `unlike` methods).
- Improved `findAllByContentId` tests to cover enriched comment data (likes count, isLiked, and user avatar URL resolution).
- Mocked new dependencies (`CommentLikesRepository` and `S3Service`) in `CommentsService` unit tests.
2026-01-29 15:30:20 +01:00
Mathis HERRIOT
0976850c0c feat: add comment replies and liking functionality
- Introduced support for nested comment replies in both frontend and backend.
- Added comment liking and unliking features, including like count and "isLiked" state tracking.
- Updated database schema with `parentId` and new `comment_likes` table.
- Enhanced UI for threaded comments and implemented display of like counts and reply actions.
- Refactored APIs and repositories to support replies, likes, and enriched comment data.
2026-01-29 15:26:54 +01:00
Mathis HERRIOT
ed3ed66cab feat: add database snapshot for schema changes
- Created a snapshot to reflect the updated database schema, including new tables `api_keys`, `audit_logs`, `categories`, `comments`, `contents`, and related relationships.
- Includes indexes, unique constraints, and foreign key definitions.
2026-01-29 15:26:09 +01:00
67 changed files with 8594 additions and 192 deletions

View File

@@ -0,0 +1,54 @@
CREATE TABLE "comment_likes" (
"comment_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "comment_likes_comment_id_user_id_pk" PRIMARY KEY("comment_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"parent_id" uuid,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "conversation_participants" (
"conversation_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "conversation_participants_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "conversations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"conversation_id" uuid NOT NULL,
"sender_id" uuid NOT NULL,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"read_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_uuid_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_content_id_idx" ON "comments" USING btree ("content_id");--> statement-breakpoint
CREATE INDEX "comments_user_id_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
CREATE INDEX "messages_sender_id_idx" ON "messages" USING btree ("sender_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "show_online_status" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "show_read_receipts" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "password_hash" SET DATA TYPE varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,27 @@
"when": 1769605995410, "when": 1769605995410,
"tag": "0007_melodic_synch", "tag": "0007_melodic_synch",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1769696731978,
"tag": "0008_bitter_darwin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1769717126917,
"tag": "0009_add_privacy_settings",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769718997591,
"tag": "0010_update_password_hash_length",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.8.1", "version": "1.9.6",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -8,18 +8,45 @@ import {
Req, Req,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { getIronSession } from "iron-session";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { getSessionOptions } from "../auth/session.config";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { JwtService } from "../crypto/services/jwt.service";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.service";
import { CreateCommentDto } from "./dto/create-comment.dto"; import { CreateCommentDto } from "./dto/create-comment.dto";
@Controller() @Controller()
export class CommentsController { export class CommentsController {
constructor(private readonly commentsService: CommentsService) {} constructor(
private readonly commentsService: CommentsService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@Get("contents/:contentId/comments") @Get("contents/:contentId/comments")
findAllByContentId(@Param("contentId") contentId: string) { async findAllByContentId(
return this.commentsService.findAllByContentId(contentId); @Param("contentId") contentId: string,
@Req() req: any,
) {
// Tentative de récupération de l'utilisateur pour isLiked (optionnel)
let userId: string | undefined;
try {
const session = await getIronSession<any>(
req,
req.res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
if (session.accessToken) {
const payload = await this.jwtService.verifyJwt(session.accessToken);
userId = payload.sub;
}
} catch (_e) {
// Ignorer les erreurs de session
}
return this.commentsService.findAllByContentId(contentId, userId);
} }
@Post("contents/:contentId/comments") @Post("contents/:contentId/comments")
@@ -38,4 +65,16 @@ export class CommentsController {
const isAdmin = req.user.role === "admin" || req.user.role === "moderator"; const isAdmin = req.user.role === "admin" || req.user.role === "moderator";
return this.commentsService.remove(req.user.sub, id, isAdmin); return this.commentsService.remove(req.user.sub, id, isAdmin);
} }
@Post("comments/:id/like")
@UseGuards(AuthGuard)
like(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.like(req.user.sub, id);
}
@Delete("comments/:id/like")
@UseGuards(AuthGuard)
unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
return this.commentsService.unlike(req.user.sub, id);
}
} }

View File

@@ -1,13 +1,22 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { ContentsModule } from "../contents/contents.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { CommentsController } from "./comments.controller"; import { CommentsController } from "./comments.controller";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository"; import { CommentsRepository } from "./repositories/comments.repository";
@Module({ @Module({
imports: [AuthModule], imports: [
AuthModule,
S3Module,
RealtimeModule,
forwardRef(() => ContentsModule),
],
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService, CommentsRepository], providers: [CommentsService, CommentsRepository, CommentLikesRepository],
exports: [CommentsService], exports: [CommentsService],
}) })
export class CommentsModule {} export class CommentsModule {}

View File

@@ -1,6 +1,10 @@
import { ForbiddenException, NotFoundException } from "@nestjs/common"; import { ForbiddenException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.service";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository"; import { CommentsRepository } from "./repositories/comments.repository";
describe("CommentsService", () => { describe("CommentsService", () => {
@@ -11,14 +15,40 @@ describe("CommentsService", () => {
create: jest.fn(), create: jest.fn(),
findAllByContentId: jest.fn(), findAllByContentId: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
findOneEnriched: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
}; };
const mockCommentLikesRepository = {
addLike: jest.fn(),
removeLike: jest.fn(),
countByCommentId: jest.fn(),
isLikedByUser: jest.fn(),
};
const mockContentsRepository = {
findOne: jest.fn(),
};
const mockS3Service = {
getPublicUrl: jest.fn(),
};
const mockEventsGateway = {
sendToContent: jest.fn(),
sendToUser: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
CommentsService, CommentsService,
{ provide: CommentsRepository, useValue: mockCommentsRepository }, { provide: CommentsRepository, useValue: mockCommentsRepository },
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service },
{ provide: EventsGateway, useValue: mockEventsGateway },
], ],
}).compile(); }).compile();
@@ -34,8 +64,12 @@ describe("CommentsService", () => {
it("should create a comment", async () => { it("should create a comment", async () => {
const userId = "user1"; const userId = "user1";
const contentId = "content1"; const contentId = "content1";
const dto = { text: "Nice meme" }; const dto = { text: "Nice meme", parentId: undefined };
mockCommentsRepository.create.mockResolvedValue({ id: "c1", ...dto }); const createdComment = { id: "c1", ...dto, user: { username: "u1" } };
mockCommentsRepository.create.mockResolvedValue(createdComment);
mockCommentsRepository.findOneEnriched.mockResolvedValue(createdComment);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(0);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(false);
const result = await service.create(userId, contentId, dto); const result = await service.create(userId, contentId, dto);
expect(result.id).toBe("c1"); expect(result.id).toBe("c1");
@@ -43,16 +77,30 @@ describe("CommentsService", () => {
userId, userId,
contentId, contentId,
text: dto.text, text: dto.text,
parentId: undefined,
}); });
expect(mockEventsGateway.sendToContent).toHaveBeenCalledWith(
contentId,
"new_comment",
expect.any(Object),
);
}); });
}); });
describe("findAllByContentId", () => { describe("findAllByContentId", () => {
it("should return comments for a content", async () => { it("should return comments for a content", async () => {
mockCommentsRepository.findAllByContentId.mockResolvedValue([{ id: "c1" }]); mockCommentsRepository.findAllByContentId.mockResolvedValue([
const result = await service.findAllByContentId("content1"); { id: "c1", user: { avatarUrl: "path" } },
]);
mockCommentLikesRepository.countByCommentId.mockResolvedValue(5);
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(true);
mockS3Service.getPublicUrl.mockReturnValue("url");
const result = await service.findAllByContentId("content1", "u1");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(repository.findAllByContentId).toHaveBeenCalledWith("content1"); expect(result[0].likesCount).toBe(5);
expect(result[0].isLiked).toBe(true);
expect(result[0].user.avatarUrl).toBe("url");
}); });
}); });
@@ -81,4 +129,23 @@ describe("CommentsService", () => {
); );
}); });
}); });
describe("like", () => {
it("should add like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.like("u1", "c1");
expect(mockCommentLikesRepository.addLike).toHaveBeenCalledWith("c1", "u1");
});
});
describe("unlike", () => {
it("should remove like", async () => {
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
await service.unlike("u1", "c1");
expect(mockCommentLikesRepository.removeLike).toHaveBeenCalledWith(
"c1",
"u1",
);
});
});
}); });

View File

@@ -1,25 +1,132 @@
import { import {
ForbiddenException, ForbiddenException,
forwardRef,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import type { CreateCommentDto } from "./dto/create-comment.dto"; import type { CreateCommentDto } from "./dto/create-comment.dto";
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
import { CommentsRepository } from "./repositories/comments.repository"; import { CommentsRepository } from "./repositories/comments.repository";
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor(private readonly commentsRepository: CommentsRepository) {} constructor(
private readonly commentsRepository: CommentsRepository,
private readonly commentLikesRepository: CommentLikesRepository,
@Inject(forwardRef(() => ContentsRepository))
private readonly contentsRepository: ContentsRepository,
private readonly s3Service: S3Service,
private readonly eventsGateway: EventsGateway,
) {}
async create(userId: string, contentId: string, dto: CreateCommentDto) { async create(userId: string, contentId: string, dto: CreateCommentDto) {
return this.commentsRepository.create({ const comment = await this.commentsRepository.create({
userId, userId,
contentId, contentId,
text: dto.text, text: dto.text,
parentId: dto.parentId,
}); });
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);
// Notifications ciblées
try {
// 1. Notifier l'auteur du post
const content = await this.contentsRepository.findOne(contentId);
if (content && content.userId !== userId) {
this.eventsGateway.sendToUser(content.userId, "notification", {
type: "comment",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a commenté votre post : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
// 2. Si c'est une réponse, notifier l'auteur du commentaire parent
if (dto.parentId) {
const parentComment = await this.commentsRepository.findOne(dto.parentId);
if (
parentComment &&
parentComment.userId !== userId &&
(!content || parentComment.userId !== content.userId)
) {
this.eventsGateway.sendToUser(parentComment.userId, "notification", {
type: "reply",
userId: userId,
username: enrichedComment.user.username,
contentId: contentId,
commentId: comment.id,
text: `a répondu à votre commentaire : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
});
}
}
} catch (error) {
console.error("Failed to send notification:", error);
}
return enrichedComment;
} }
async findAllByContentId(contentId: string) { async findOneEnriched(commentId: string, currentUserId?: string) {
return this.commentsRepository.findAllByContentId(contentId); const comment = await this.commentsRepository.findOneEnriched(commentId);
if (!comment) return null;
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
currentUserId
? this.commentLikesRepository.isLikedByUser(comment.id, currentUserId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}
async findAllByContentId(contentId: string, userId?: string) {
const comments = await this.commentsRepository.findAllByContentId(contentId);
return Promise.all(
comments.map(async (comment) => {
const [likesCount, isLiked] = await Promise.all([
this.commentLikesRepository.countByCommentId(comment.id),
userId
? this.commentLikesRepository.isLikedByUser(comment.id, userId)
: Promise.resolve(false),
]);
return {
...comment,
likesCount,
isLiked,
user: {
...comment.user,
avatarUrl: comment.user.avatarUrl
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
: null,
},
};
}),
);
} }
async remove(userId: string, commentId: string, isAdmin = false) { async remove(userId: string, commentId: string, isAdmin = false) {
@@ -34,4 +141,37 @@ export class CommentsService {
await this.commentsRepository.delete(commentId); await this.commentsRepository.delete(commentId);
} }
async like(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.addLike(commentId, userId);
// Notifier l'auteur du commentaire
if (comment.userId !== userId) {
try {
const liker = await this.findOneEnriched(commentId, userId);
this.eventsGateway.sendToUser(comment.userId, "notification", {
type: "like_comment",
userId: userId,
username: liker?.user.username,
contentId: comment.contentId,
commentId: commentId,
text: "a aimé votre commentaire",
});
} catch (error) {
console.error("Failed to send like notification:", error);
}
}
}
async unlike(userId: string, commentId: string) {
const comment = await this.commentsRepository.findOne(commentId);
if (!comment) {
throw new NotFoundException("Comment not found");
}
await this.commentLikesRepository.removeLike(commentId, userId);
}
} }

View File

@@ -1,8 +1,18 @@
import { IsNotEmpty, IsString, MaxLength } from "class-validator"; import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateCommentDto { export class CreateCommentDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(1000) @MaxLength(1000)
text!: string; text!: string;
@IsOptional()
@IsUUID()
parentId?: string;
} }

View File

@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { and, eq, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import { commentLikes } from "../../database/schemas/comment_likes";
@Injectable()
export class CommentLikesRepository {
constructor(private readonly databaseService: DatabaseService) {}
async addLike(commentId: string, userId: string) {
await this.databaseService.db
.insert(commentLikes)
.values({ commentId, userId })
.onConflictDoNothing();
}
async removeLike(commentId: string, userId: string) {
await this.databaseService.db
.delete(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
}
async countByCommentId(commentId: string) {
const results = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(commentLikes)
.where(eq(commentLikes.commentId, commentId));
return Number(results[0]?.count || 0);
}
async isLikedByUser(commentId: string, userId: string) {
const results = await this.databaseService.db
.select()
.from(commentLikes)
.where(
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
);
return !!results[0];
}
}

View File

@@ -9,11 +9,11 @@ export class CommentsRepository {
constructor(private readonly databaseService: DatabaseService) {} constructor(private readonly databaseService: DatabaseService) {}
async create(data: NewCommentInDb) { async create(data: NewCommentInDb) {
const [comment] = await this.databaseService.db const results = await this.databaseService.db
.insert(comments) .insert(comments)
.values(data) .values(data)
.returning(); .returning();
return comment; return results[0];
} }
async findAllByContentId(contentId: string) { async findAllByContentId(contentId: string) {
@@ -21,6 +21,7 @@ export class CommentsRepository {
.select({ .select({
id: comments.id, id: comments.id,
text: comments.text, text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt, createdAt: comments.createdAt,
updatedAt: comments.updatedAt, updatedAt: comments.updatedAt,
user: { user: {
@@ -37,11 +38,32 @@ export class CommentsRepository {
} }
async findOne(id: string) { async findOne(id: string) {
const [comment] = await this.databaseService.db const results = await this.databaseService.db
.select() .select()
.from(comments) .from(comments)
.where(and(eq(comments.id, id), isNull(comments.deletedAt))); .where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return comment; return results[0];
}
async findOneEnriched(id: string) {
const results = await this.databaseService.db
.select({
id: comments.id,
text: comments.text,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
user: {
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.uuid))
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
return results[0];
} }
async delete(id: string) { async delete(id: string) {

View File

@@ -0,0 +1,21 @@
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
import { comments } from "./comments";
import { users } from "./users";
export const commentLikes = pgTable(
"comment_likes",
{
commentId: uuid("comment_id")
.notNull()
.references(() => comments.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.uuid, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.commentId, t.userId] }),
}),
);

View File

@@ -12,6 +12,9 @@ export const comments = pgTable(
userId: uuid("user_id") userId: uuid("user_id")
.notNull() .notNull()
.references(() => users.uuid, { onDelete: "cascade" }), .references(() => users.uuid, { onDelete: "cascade" }),
parentId: uuid("parent_id").references(() => comments.id, {
onDelete: "cascade",
}),
text: text("text").notNull(), text: text("text").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
@@ -24,6 +27,7 @@ export const comments = pgTable(
(table) => ({ (table) => ({
contentIdIdx: index("comments_content_id_idx").on(table.contentId), contentIdIdx: index("comments_content_id_idx").on(table.contentId),
userIdIdx: index("comments_user_id_idx").on(table.userId), userIdIdx: index("comments_user_id_idx").on(table.userId),
parentIdIdx: index("comments_parent_id_idx").on(table.parentId),
}), }),
); );

View File

@@ -1,6 +1,7 @@
export * from "./api_keys"; export * from "./api_keys";
export * from "./audit_logs"; export * from "./audit_logs";
export * from "./categories"; export * from "./categories";
export * from "./comment_likes";
export * from "./comments"; export * from "./comments";
export * from "./content"; export * from "./content";
export * from "./favorites"; export * from "./favorites";

View File

@@ -21,14 +21,19 @@ const getPgpKey = () => process.env.PGP_ENCRYPTION_KEY || "default-pgp-key";
* withAutomaticPgpDecrypt(users.email); * withAutomaticPgpDecrypt(users.email);
* ``` * ```
*/ */
export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({ export const pgpEncrypted = customType<{
data: string | null;
driverData: Buffer | string | null | SQL;
}>({
dataType() { dataType() {
return "bytea"; return "bytea";
}, },
toDriver(value: string): SQL { toDriver(value: string | null): SQL | null {
if (value === null) return null;
return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`; return sql`pgp_sym_encrypt(${value}, ${getPgpKey()})`;
}, },
fromDriver(value: Buffer | string): string { fromDriver(value: Buffer | string | null | any): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") return value; if (typeof value === "string") return value;
return value.toString(); return value.toString();
}, },
@@ -41,7 +46,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T { export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column); const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () => column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column); sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
return column; return column;
} }
@@ -59,5 +66,7 @@ export function pgpSymDecrypt(
column: AnyPgColumn, column: AnyPgColumn,
key: string | SQL, key: string | SQL,
): SQL<string> { ): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>; return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
column,
) as SQL<string>;
} }

View File

@@ -29,13 +29,15 @@ export const users = pgTable(
displayName: varchar("display_name", { length: 32 }), displayName: varchar("display_name", { length: 32 }),
username: varchar("username", { length: 32 }).notNull().unique(), username: varchar("username", { length: 32 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 100 }).notNull(), passwordHash: varchar("password_hash", { length: 255 }).notNull(),
avatarUrl: varchar("avatar_url", { length: 512 }), avatarUrl: varchar("avatar_url", { length: 512 }),
bio: varchar("bio", { length: 255 }), bio: varchar("bio", { length: 255 }),
// Sécurité // Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"), twoFactorSecret: pgpEncrypted("two_factor_secret"),
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false), isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
showOnlineStatus: boolean("show_online_status").notNull().default(true),
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
// RGPD & Conformité // RGPD & Conformité
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées

View File

@@ -22,6 +22,22 @@ export class MessagesController {
return this.messagesService.getConversations(req.user.sub); return this.messagesService.getConversations(req.user.sub);
} }
@Get("unread-count")
getUnreadCount(@Req() req: AuthenticatedRequest) {
return this.messagesService.getUnreadCount(req.user.sub);
}
@Get("conversations/with/:userId")
getConversationWithUser(
@Req() req: AuthenticatedRequest,
@Param("userId") targetUserId: string,
) {
return this.messagesService.getConversationWithUser(
req.user.sub,
targetUserId,
);
}
@Get("conversations/:id") @Get("conversations/:id")
getMessages( getMessages(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,

View File

@@ -1,12 +1,13 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { RealtimeModule } from "../realtime/realtime.module"; import { RealtimeModule } from "../realtime/realtime.module";
import { UsersModule } from "../users/users.module";
import { MessagesController } from "./messages.controller"; import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service"; import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@Module({ @Module({
imports: [AuthModule, RealtimeModule], imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
controllers: [MessagesController], controllers: [MessagesController],
providers: [MessagesService, MessagesRepository], providers: [MessagesService, MessagesRepository],
exports: [MessagesService], exports: [MessagesService],

View File

@@ -1,6 +1,7 @@
import { ForbiddenException } from "@nestjs/common"; import { ForbiddenException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import { MessagesService } from "./messages.service"; import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@@ -16,19 +17,27 @@ describe("MessagesService", () => {
createMessage: jest.fn(), createMessage: jest.fn(),
findAllConversations: jest.fn(), findAllConversations: jest.fn(),
isParticipant: jest.fn(), isParticipant: jest.fn(),
getParticipants: jest.fn(),
findMessagesByConversationId: jest.fn(), findMessagesByConversationId: jest.fn(),
markAsRead: jest.fn(),
countUnreadMessages: jest.fn(),
}; };
const mockEventsGateway = { const mockEventsGateway = {
sendToUser: jest.fn(), sendToUser: jest.fn(),
}; };
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
MessagesService, MessagesService,
{ provide: MessagesRepository, useValue: mockMessagesRepository }, { provide: MessagesRepository, useValue: mockMessagesRepository },
{ provide: EventsGateway, useValue: mockEventsGateway }, { provide: EventsGateway, useValue: mockEventsGateway },
{ provide: UsersService, useValue: mockUsersService },
], ],
}).compile(); }).compile();

View File

@@ -1,5 +1,11 @@
import { ForbiddenException, Injectable } from "@nestjs/common"; import {
ForbiddenException,
forwardRef,
Inject,
Injectable,
} from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import type { CreateMessageDto } from "./dto/create-message.dto"; import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository"; import { MessagesRepository } from "./repositories/messages.repository";
@@ -8,6 +14,8 @@ export class MessagesService {
constructor( constructor(
private readonly messagesRepository: MessagesRepository, private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway, private readonly eventsGateway: EventsGateway,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {} ) {}
async sendMessage(senderId: string, dto: CreateMessageDto) { async sendMessage(senderId: string, dto: CreateMessageDto) {
@@ -42,6 +50,17 @@ export class MessagesService {
return this.messagesRepository.findAllConversations(userId); return this.messagesRepository.findAllConversations(userId);
} }
async getUnreadCount(userId: string) {
return this.messagesRepository.countUnreadMessages(userId);
}
async getConversationWithUser(userId: string, targetUserId: string) {
return this.messagesRepository.findConversationBetweenUsers(
userId,
targetUserId,
);
}
async getMessages(userId: string, conversationId: string) { async getMessages(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant( const isParticipant = await this.messagesRepository.isParticipant(
conversationId, conversationId,
@@ -51,6 +70,56 @@ export class MessagesService {
throw new ForbiddenException("You are not part of this conversation"); throw new ForbiddenException("You are not part of this conversation");
} }
// Récupérer les préférences de l'utilisateur actuel
const user = await this.usersService.findOne(userId);
// Marquer comme lus seulement si l'utilisateur l'autorise
if (user?.showReadReceipts) {
await this.messagesRepository.markAsRead(conversationId, userId);
// Notifier l'expéditeur que les messages ont été lus
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
}
return this.messagesRepository.findMessagesByConversationId(conversationId); return this.messagesRepository.findMessagesByConversationId(conversationId);
} }
async markAsRead(userId: string, conversationId: string) {
const isParticipant = await this.messagesRepository.isParticipant(
conversationId,
userId,
);
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
const user = await this.usersService.findOne(userId);
if (!user?.showReadReceipts) return;
const result = await this.messagesRepository.markAsRead(
conversationId,
userId,
);
// Notifier l'autre participant
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
return result;
}
} }

View File

@@ -133,4 +133,35 @@ export class MessagesRepository {
.from(conversationParticipants) .from(conversationParticipants)
.where(eq(conversationParticipants.conversationId, conversationId)); .where(eq(conversationParticipants.conversationId, conversationId));
} }
async markAsRead(conversationId: string, userId: string) {
await this.databaseService.db
.update(messages)
.set({ readAt: new Date() })
.where(
and(
eq(messages.conversationId, conversationId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
}
async countUnreadMessages(userId: string) {
const result = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(messages)
.innerJoin(
conversationParticipants,
eq(messages.conversationId, conversationParticipants.conversationId),
)
.where(
and(
eq(conversationParticipants.userId, userId),
sql`${messages.senderId} != ${userId}`,
sql`${messages.readAt} IS NULL`,
),
);
return Number(result[0]?.count || 0);
}
} }

View File

@@ -1,6 +1,7 @@
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
import { EventsGateway } from "./events.gateway"; import { EventsGateway } from "./events.gateway";
describe("EventsGateway", () => { describe("EventsGateway", () => {
@@ -15,12 +16,17 @@ describe("EventsGateway", () => {
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"), get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
}; };
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
EventsGateway, EventsGateway,
{ provide: JwtService, useValue: mockJwtService }, { provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
{ provide: UsersService, useValue: mockUsersService },
], ],
}).compile(); }).compile();

View File

@@ -1,9 +1,12 @@
import { Logger } from "@nestjs/common"; import { forwardRef, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { import {
ConnectedSocket,
MessageBody,
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect, OnGatewayDisconnect,
OnGatewayInit, OnGatewayInit,
SubscribeMessage,
WebSocketGateway, WebSocketGateway,
WebSocketServer, WebSocketServer,
} from "@nestjs/websockets"; } from "@nestjs/websockets";
@@ -11,11 +14,42 @@ import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config"; import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
@WebSocketGateway({ @WebSocketGateway({
transports: ["websocket"],
cors: { cors: {
origin: "*", origin: (
origin: string,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
// ou si on est en développement local
if (
!origin ||
origin.includes("localhost") ||
origin.includes("127.0.0.1")
) {
callback(null, true);
return;
}
// En production, on peut restreindre via une variable d'environnement
const domainName = process.env.CORS_DOMAIN_NAME;
if (!domainName || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true, credentials: true,
methods: ["GET", "POST"],
}, },
}) })
export class EventsGateway export class EventsGateway
@@ -25,10 +59,13 @@ export class EventsGateway
server!: Server; server!: Server;
private readonly logger = new Logger(EventsGateway.name); private readonly logger = new Logger(EventsGateway.name);
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {} ) {}
afterInit(_server: Server) { afterInit(_server: Server) {
@@ -54,16 +91,36 @@ export class EventsGateway
if (!session.accessToken) { if (!session.accessToken) {
this.logger.warn(`Client ${client.id} unauthorized connection`); this.logger.warn(`Client ${client.id} unauthorized connection`);
// Permettre les connexions anonymes pour voir les commentaires en temps réel ?
// Pour l'instant on déconnecte car le système actuel semble exiger l'auth
client.disconnect(); client.disconnect();
return; return;
} }
const payload = await this.jwtService.verifyJwt(session.accessToken); const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload; client.data.user = payload;
// Rejoindre une room personnelle pour les notifications // Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`); client.join(`user:${payload.sub}`);
// Gérer le statut en ligne
const userId = payload.sub as string;
if (!this.onlineUsers.has(userId)) {
this.onlineUsers.set(userId, new Set());
// Vérifier les préférences de l'utilisateur
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "online");
}
}
this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`); this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) { } catch (error) {
this.logger.error(`Connection error for client ${client.id}: ${error}`); this.logger.error(`Connection error for client ${client.id}: ${error}`);
@@ -71,12 +128,93 @@ export class EventsGateway
} }
} }
handleDisconnect(client: Socket) { async handleDisconnect(client: Socket) {
const userId = client.data.user?.sub;
if (userId && this.onlineUsers.has(userId)) {
const sockets = this.onlineUsers.get(userId);
sockets?.delete(client.id);
if (sockets?.size === 0) {
this.onlineUsers.delete(userId);
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "offline");
}
}
}
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
} }
broadcastStatus(userId: string, status: "online" | "offline") {
this.server.emit("user_status", { userId, status });
}
isUserOnline(userId: string): boolean {
return this.onlineUsers.has(userId);
}
@SubscribeMessage("join_content")
handleJoinContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.join(`content:${contentId}`);
this.logger.log(`Client ${client.id} joined content room: ${contentId}`);
}
@SubscribeMessage("leave_content")
handleLeaveContent(
@ConnectedSocket() client: Socket,
@MessageBody() contentId: string,
) {
client.leave(`content:${contentId}`);
this.logger.log(`Client ${client.id} left content room: ${contentId}`);
}
@SubscribeMessage("typing")
async handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean },
) {
const userId = client.data.user?.sub;
if (!userId) return;
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId,
isTyping: data.isTyping,
});
}
@SubscribeMessage("check_status")
async handleCheckStatus(
@ConnectedSocket() _client: Socket,
@MessageBody() userId: string,
) {
const isOnline = this.onlineUsers.has(userId);
if (!isOnline) return { userId, status: "offline" };
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return { userId, status: "offline" };
return {
userId,
status: "online",
};
}
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique // Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
sendToUser(userId: string, event: string, data: any) { sendToUser(userId: string, event: string, data: any) {
this.server.to(`user:${userId}`).emit(event, data); this.server.to(`user:${userId}`).emit(event, data);
} }
sendToContent(contentId: string, event: string, data: any) {
this.server.to(`content:${contentId}`).emit(event, data);
}
} }

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CryptoModule } from "../crypto/crypto.module"; import { CryptoModule } from "../crypto/crypto.module";
import { UsersModule } from "../users/users.module";
import { EventsGateway } from "./events.gateway"; import { EventsGateway } from "./events.gateway";
@Module({ @Module({
imports: [CryptoModule], imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
providers: [EventsGateway], providers: [EventsGateway],
exports: [EventsGateway], exports: [EventsGateway],
}) })

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, MaxLength } from "class-validator"; import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto { export class UpdateUserDto {
@IsOptional() @IsOptional()
@@ -22,4 +22,12 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
role?: string; role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
} }

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { and, eq, lte, sql } from "drizzle-orm"; import { and, eq, ilike, lte, or, sql } from "drizzle-orm";
import { DatabaseService } from "../../database/database.service"; import { DatabaseService } from "../../database/database.service";
import { contents, favorites, users } from "../../database/schemas"; import { contents, favorites, users } from "../../database/schemas";
@@ -47,6 +47,8 @@ export class UsersRepository {
bio: users.bio, bio: users.bio,
status: users.status, status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled, isTwoFactorEnabled: users.isTwoFactorEnabled,
showOnlineStatus: users.showOnlineStatus,
showReadReceipts: users.showReadReceipts,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })
@@ -97,6 +99,24 @@ export class UsersRepository {
return result[0] || null; return result[0] || null;
} }
async search(query: string) {
return this.databaseService.db
.select({
uuid: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
})
.from(users)
.where(
or(
ilike(users.username, `%${query}%`),
ilike(users.displayName, `%${query}%`),
),
)
.limit(10);
}
async findOne(uuid: string) { async findOne(uuid: string) {
const result = await this.databaseService.db const result = await this.databaseService.db
.select() .select()

View File

@@ -54,6 +54,12 @@ export class UsersController {
return this.usersService.findPublicProfile(username); return this.usersService.findPublicProfile(username);
} }
@Get("search")
@UseGuards(AuthGuard)
search(@Query("q") query: string) {
return this.usersService.search(query);
}
// Gestion de son propre compte // Gestion de son propre compte
@Get("me") @Get("me")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)

View File

@@ -1,13 +1,19 @@
import { forwardRef, Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module"; import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module"; import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller"; import { UsersController } from "./users.controller";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@Module({ @Module({
imports: [forwardRef(() => AuthModule), MediaModule, S3Module], imports: [
forwardRef(() => AuthModule),
MediaModule,
S3Module,
forwardRef(() => RealtimeModule),
],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService, UsersRepository], providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository], exports: [UsersService, UsersRepository],

View File

@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service"; import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
@@ -49,6 +50,7 @@ describe("UsersService", () => {
const mockRbacService = { const mockRbacService = {
getUserRoles: jest.fn(), getUserRoles: jest.fn(),
assignRoleToUser: jest.fn(),
}; };
const mockMediaService = { const mockMediaService = {
@@ -65,6 +67,11 @@ describe("UsersService", () => {
get: jest.fn(), get: jest.fn(),
}; };
const mockEventsGateway = {
isUserOnline: jest.fn(),
broadcastStatus: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -77,6 +84,7 @@ describe("UsersService", () => {
{ provide: MediaService, useValue: mockMediaService }, { provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service }, { provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService }, { provide: ConfigService, useValue: mockConfigService },
{ provide: EventsGateway, useValue: mockEventsGateway },
], ],
}).compile(); }).compile();

View File

@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface"; import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface"; import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service"; import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository"; import { UsersRepository } from "./repositories/users.repository";
@@ -27,6 +28,8 @@ export class UsersService {
private readonly rbacService: RbacService, private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService, @Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService, @Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(forwardRef(() => EventsGateway))
private readonly eventsGateway: EventsGateway,
) {} ) {}
private async clearUserCache(username?: string) { private async clearUserCache(username?: string) {
@@ -106,6 +109,16 @@ export class UsersService {
}; };
} }
async search(query: string) {
const users = await this.usersRepository.search(query);
return users.map((user) => ({
...user,
avatarUrl: user.avatarUrl
? this.s3Service.getPublicUrl(user.avatarUrl)
: null,
}));
}
async findOne(uuid: string) { async findOne(uuid: string) {
const user = await this.usersRepository.findOne(uuid); const user = await this.usersRepository.findOne(uuid);
if (!user) return null; if (!user) return null;
@@ -127,6 +140,9 @@ export class UsersService {
const { role, ...userData } = data; const { role, ...userData } = data;
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
const oldUser = await this.usersRepository.findOne(uuid);
const result = await this.usersRepository.update(uuid, userData); const result = await this.usersRepository.update(uuid, userData);
if (role) { if (role) {
@@ -135,6 +151,21 @@ export class UsersService {
if (result[0]) { if (result[0]) {
await this.clearUserCache(result[0].username); await this.clearUserCache(result[0].username);
// Gérer le changement de préférence de statut en ligne
if (
data.showOnlineStatus !== undefined &&
data.showOnlineStatus !== oldUser?.showOnlineStatus
) {
const isOnline = this.eventsGateway.isUserOnline(uuid);
if (isOnline) {
if (data.showOnlineStatus) {
this.eventsGateway.broadcastStatus(uuid, "online");
} else {
this.eventsGateway.broadcastStatus(uuid, "offline");
}
}
}
} }
return result; return result;
} }

View File

@@ -131,6 +131,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
depends_on: depends_on:
- backend - backend

View File

@@ -7,6 +7,7 @@
"features": "Fonctionnalités", "features": "Fonctionnalités",
"stack": "Stack Technologique", "stack": "Stack Technologique",
"database": "Modèle de Données", "database": "Modèle de Données",
"flows": "Flux Métiers",
"---security---": { "---security---": {
"type": "separator", "type": "separator",
"label": "Sécurité & Conformité" "label": "Sécurité & Conformité"

View File

@@ -216,6 +216,16 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
- `200 OK` : 2FA désactivée. - `200 OK` : 2FA désactivée.
</Accordion> </Accordion>
<Accordion title="GET /users/search">
Recherche des utilisateurs par leur nom d'utilisateur ou nom d'affichage. Requiert l'authentification.
**Query Params :**
- `q` (string) : Terme de recherche.
**Réponses :**
- `200 OK` : Liste des utilisateurs correspondants.
</Accordion>
<Accordion title="GET /users/admin"> <Accordion title="GET /users/admin">
Liste tous les utilisateurs. **Réservé aux administrateurs.** Liste tous les utilisateurs. **Réservé aux administrateurs.**
@@ -406,6 +416,92 @@ Cette page documente tous les points de terminaison disponibles sur l'API Memego
</Accordion> </Accordion>
</Accordions> </Accordions>
### 💬 Commentaires (`/comments` & `/contents/:id/comments`)
<Accordions>
<Accordion title="GET /contents/:contentId/comments">
Liste les commentaires d'un contenu.
**Réponses :**
- `200 OK` : Liste des commentaires, incluant l'auteur et si l'utilisateur actuel a aimé le commentaire.
</Accordion>
<Accordion title="POST /contents/:contentId/comments">
Ajoute un commentaire à un contenu. Requiert l'authentification.
**Corps de la requête :**
- `text` (string) : Contenu du commentaire.
- `parentId` (uuid, optional) : ID du commentaire parent pour les réponses.
**Réponses :**
- `201 Created` : Commentaire ajouté.
</Accordion>
<Accordion title="DELETE /comments/:id">
Supprime un commentaire. L'utilisateur doit être l'auteur ou un modérateur/admin.
**Réponses :**
- `200 OK` : Commentaire supprimé.
</Accordion>
<Accordion title="POST /comments/:id/like">
Ajoute un "like" à un commentaire. Requiert l'authentification.
**Réponses :**
- `201 Created` : Like ajouté.
</Accordion>
<Accordion title="DELETE /comments/:id/like">
Retire un "like" d'un commentaire. Requiert l'authentification.
**Réponses :**
- `200 OK` : Like retiré.
</Accordion>
</Accordions>
### ✉️ Messagerie (`/messages`)
<Accordions>
<Accordion title="GET /messages/conversations">
Liste les conversations de l'utilisateur connecté. Requiert l'authentification.
**Réponses :**
- `200 OK` : Liste des conversations avec le dernier message et le nombre de messages non lus.
</Accordion>
<Accordion title="GET /messages/unread-count">
Récupère le nombre total de messages non lus pour l'utilisateur. Requiert l'authentification.
**Réponses :**
- `200 OK` : `{ "count": number }`.
</Accordion>
<Accordion title="GET /messages/conversations/with/:userId">
Récupère ou crée une conversation avec un utilisateur spécifique. Requiert l'authentification.
**Réponses :**
- `200 OK` : Objet conversation.
</Accordion>
<Accordion title="GET /messages/conversations/:id">
Récupère les messages d'une conversation. Marque les messages comme lus. Requiert l'authentification.
**Réponses :**
- `200 OK` : Liste des messages.
</Accordion>
<Accordion title="POST /messages">
Envoie un message. Requiert l'authentification.
**Corps de la requête :**
- `recipientId` (uuid) : ID du destinataire.
- `text` (string) : Contenu du message.
**Réponses :**
- `201 Created` : Message envoyé.
</Accordion>
</Accordions>
### ⭐ Favoris (`/favorites`) ### ⭐ Favoris (`/favorites`)
<Accordions> <Accordions>

View File

@@ -29,4 +29,4 @@ Memegoat utilise une architecture de stockage d'objets compatible S3 (MinIO). Le
### Notifications (Mail) ### Notifications (Mail)
Le système intègre un service d'envoi d'emails (SMTP) pour les notifications critiques et la gestion des comptes. Le système intègre un service d'envoi d'emails (SMTP) via `@nestjs-modules/mailer` pour les notifications critiques, la validation des comptes et la réinitialisation de mots de passe.

View File

@@ -19,7 +19,8 @@ Le projet Memegoat s'inscrit dans une démarche de respect de la vie privée et
Conformément à la section [Sécurité](/docs/security), les mesures suivantes sont appliquées : Conformément à la section [Sécurité](/docs/security), les mesures suivantes sont appliquées :
- **Chiffrement au repos** : Utilisation de **PGP (pgcrypto)** pour les données identifiantes. - **Chiffrement au repos** : Utilisation de **PGP (pgcrypto)** pour les données identifiantes.
- **Hachage aveugle** : Pour permettre les opérations sur données chiffrées sans compromettre la confidentialité. - **Cryptographie Post-Quantique** : Mise en œuvre de `@noble/post-quantum` pour protéger les données contre les futures capacités de calcul quantique.
- **Hachage aveugle (Blind Indexing)** : Pour permettre les opérations d'unicité et de recherche sur données chiffrées sans compromettre la confidentialité.
- **Hachage des mots de passe** : Utilisation de l'algorithme **Argon2id**. - **Hachage des mots de passe** : Utilisation de l'algorithme **Argon2id**.
- **Communications sécurisées** : Utilisation de **TLS 1.3** via Caddy. - **Communications sécurisées** : Utilisation de **TLS 1.3** via Caddy.
- **Suivi des Erreurs (Sentry)** : Configuration conforme avec désactivation de l'envoi des PII (Personally Identifiable Information) et masquage des données sensibles. - **Suivi des Erreurs (Sentry)** : Configuration conforme avec désactivation de l'envoi des PII (Personally Identifiable Information) et masquage des données sensibles.

View File

@@ -18,13 +18,24 @@ erDiagram
USER ||--o{ API_KEY : "genere" USER ||--o{ API_KEY : "genere"
USER ||--o{ AUDIT_LOG : "genere" USER ||--o{ AUDIT_LOG : "genere"
USER ||--o{ FAVORITE : "ajoute" USER ||--o{ FAVORITE : "ajoute"
USER ||--o{ COMMENT : "rédige"
USER ||--o{ COMMENT_LIKE : "aime"
USER ||--o{ CONVERSATION_PARTICIPANT : "participe"
USER ||--o{ MESSAGE : "envoie"
CONTENT ||--o{ CONTENT_TAG : "possede" CONTENT ||--o{ CONTENT_TAG : "possede"
TAG ||--o{ CONTENT_TAG : "est_lie_a" TAG ||--o{ CONTENT_TAG : "est_lie_a"
CONTENT ||--o{ REPORT : "est_signale" CONTENT ||--o{ REPORT : "est_signale"
CONTENT ||--o{ FAVORITE : "est_mis_en" CONTENT ||--o{ FAVORITE : "est_mis_en"
CONTENT ||--o{ COMMENT : "reçoit"
TAG ||--o{ REPORT : "est_signale" TAG ||--o{ REPORT : "est_signale"
COMMENT ||--o{ COMMENT : "possède des réponses"
COMMENT ||--o{ COMMENT_LIKE : "est aimé par"
CONVERSATION ||--o{ CONVERSATION_PARTICIPANT : "regroupe"
CONVERSATION ||--o{ MESSAGE : "contient"
CATEGORY ||--o{ CONTENT : "catégorise" CATEGORY ||--o{ CONTENT : "catégorise"
ROLE ||--o{ USER_ROLE : "attribue_a" ROLE ||--o{ USER_ROLE : "attribue_a"
@@ -45,6 +56,15 @@ erDiagram
string type string type
string storage_key string storage_key
} }
COMMENT {
string text
}
CONVERSATION {
timestamp created_at
}
MESSAGE {
string text
}
TAG { TAG {
string name string name
string slug string slug
@@ -140,6 +160,39 @@ erDiagram
uuid content_id PK, FK uuid content_id PK, FK
uuid tag_id PK, FK uuid tag_id PK, FK
} }
comments {
uuid id PK
uuid content_id FK
uuid user_id FK
uuid parent_id FK
text text
timestamp created_at
timestamp updated_at
timestamp deleted_at
}
comment_likes {
uuid comment_id PK, FK
uuid user_id PK, FK
timestamp created_at
}
conversations {
uuid id PK
timestamp created_at
timestamp updated_at
}
conversation_participants {
uuid conversation_id PK, FK
uuid user_id PK, FK
timestamp joined_at
}
messages {
uuid id PK
uuid conversation_id FK
uuid sender_id FK
text text
timestamp created_at
timestamp read_at
}
roles { roles {
uuid id PK uuid id PK
varchar name varchar name
@@ -225,6 +278,15 @@ erDiagram
users ||--o{ sessions : "user_id" users ||--o{ sessions : "user_id"
users ||--o{ api_keys : "user_id" users ||--o{ api_keys : "user_id"
users ||--o{ audit_logs : "user_id" users ||--o{ audit_logs : "user_id"
contents ||--o{ comments : "content_id"
users ||--o{ comments : "user_id"
comments ||--o{ comments : "parent_id"
comments ||--o{ comment_likes : "comment_id"
users ||--o{ comment_likes : "user_id"
conversations ||--o{ conversation_participants : "conversation_id"
users ||--o{ conversation_participants : "user_id"
conversations ||--o{ messages : "conversation_id"
users ||--o{ messages : "sender_id"
``` ```
### Physique (MPD) ### Physique (MPD)
@@ -278,6 +340,7 @@ erDiagram
#### Sécurité et Chiffrement #### Sécurité et Chiffrement
- **Chiffrement PGP (Native)** : Les colonnes `email` et `two_factor_secret` sont stockées au format `bytea` et chiffrées/déchiffrées via les fonctions `pgp_sym_encrypt` et `pgp_sym_decrypt` de PostgreSQL (via l'extension `pgcrypto`). - **Chiffrement PGP (Native)** : Les colonnes `email` et `two_factor_secret` sont stockées au format `bytea` et chiffrées/déchiffrées via les fonctions `pgp_sym_encrypt` et `pgp_sym_decrypt` de PostgreSQL (via l'extension `pgcrypto`).
- **Cryptographie Post-Quantique** : Utilisation de la bibliothèque `@noble/post-quantum` pour anticiper les futures menaces cryptographiques.
- **Hachage aveugle (Blind Indexing)** : La colonne `email_hash` stocke un hash (SHA-256) de l'email pour permettre les recherches d'unicité et les recherches rapides sans déchiffrer la donnée. - **Hachage aveugle (Blind Indexing)** : La colonne `email_hash` stocke un hash (SHA-256) de l'email pour permettre les recherches d'unicité et les recherches rapides sans déchiffrer la donnée.
#### Index et Optimisations #### Index et Optimisations

View File

@@ -12,10 +12,10 @@ Un conteneur **Caddy** est utilisé en tant que reverse proxy pour fournir le TL
### Pré-requis Système ### Pré-requis Système
<Cards> <Cards>
<Card title="Environnement" description="Node.js >= 20, pnpm >= 10." /> <Card title="Environnement" description="Node.js >= 22 (recommandé pour NestJS 11), pnpm >= 10." />
<Card title="Base de données" description="PostgreSQL >= 15 + pgcrypto et Redis." /> <Card title="Base de données" description="PostgreSQL >= 16 + pgcrypto et Redis 7+." />
<Card title="Stockage" description="MinIO ou S3 Compatible." /> <Card title="Stockage" description="MinIO ou S3 Compatible." />
<Card title="Services" description="ClamAV (clamd) et FFmpeg." /> <Card title="Services" description="ClamAV (clamd), FFmpeg 6+ et Serveur SMTP." />
</Cards> </Cards>
### Procédure de Déploiement ### Procédure de Déploiement

View File

@@ -10,7 +10,7 @@ Le projet Memegoat intègre un ensemble de fonctionnalités avancées pour garan
## 🏗️ Infrastructure & Médias ## 🏗️ Infrastructure & Médias
### 📤 Publication & Traitement ### 📤 Publication & Traitement
Le coeur de la plateforme permet la publication sécurisée de mèmes et de GIFs avec un pipeline de traitement complet : Le coeur de la plateforme permet la publication sécurisée de mèmes et de GIFs avec un pipeline de traitement complet (voir le [Flux de Publication](/docs/flows#-publication-de-contenu-pipeline-médía)) :
<Cards> <Cards>
<Card icon="🛡️" title="Sécurité (Antivirus)" description="Chaque fichier uploadé est scanné en temps réel par ClamAV." /> <Card icon="🛡️" title="Sécurité (Antivirus)" description="Chaque fichier uploadé est scanné en temps réel par ClamAV." />
@@ -64,6 +64,11 @@ Un système complet de gestion de profil permet aux utilisateurs de :
- Configurer la **Double Authentification (2FA)**. - Configurer la **Double Authentification (2FA)**.
- Consulter leurs sessions actives et révoquer des accès. - Consulter leurs sessions actives et révoquer des accès.
### 💬 Interaction & Communauté
Memegoat favorise l'interaction entre les utilisateurs via plusieurs fonctionnalités sociales :
- **Système de Commentaires** : Les utilisateurs peuvent commenter les mèmes, répondre à d'autres commentaires et aimer les contributions.
- **Messagerie Privée** : Un système de messagerie sécurisé permettant des conversations directes entre utilisateurs, avec gestion des conversations et compteurs de messages non lus.
<Callout type="info"> <Callout type="info">
Toutes les données sensibles du profil sont protégées par **chiffrement PGP** au repos. Toutes les données sensibles du profil sont protégées par **chiffrement PGP** au repos.
</Callout> </Callout>

View File

@@ -0,0 +1,177 @@
---
title: Flux Métiers
description: Diagrammes de séquence et explications des flux critiques de Memegoat.
---
# 🔄 Flux Métiers
Cette section détaille les processus critiques de la plateforme Memegoat à travers des diagrammes de séquence et des explications techniques étape par étape.
## 🔐 Authentification & Sécurité
### Inscription & Double Authentification (2FA)
Le processus d'inscription intègre immédiatement les mesures de sécurité fortes (Argon2id, PGP). L'activation de la 2FA est optionnelle mais fortement recommandée.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant F as Frontend
participant B as Backend
participant DB as PostgreSQL
participant M as Serveur SMTP
Note over U, DB: Flux d'Inscription
U->>F: Remplir formulaire (email, password)
F->>B: POST /auth/register
B->>B: Hash password (Argon2id)
B->>B: Chiffrement Email (PGP)
B->>B: Génération Email Hash (Blind Indexing)
B->>DB: INSERT INTO users
B->>M: Envoi email de validation
B-->>F: 201 Created
F-->>U: Succès (Redirection Login)
Note over U, DB: Activation 2FA
U->>F: Activer 2FA
F->>B: POST /users/me/2fa/setup
B->>B: Générer Secret TOTP
B->>B: Chiffrer Secret (PGP)
B->>DB: UPDATE users SET two_factor_secret
B-->>F: Secret + QR Code URL
F-->>U: Affiche QR Code
U->>F: Saisir code TOTP
F->>B: POST /users/me/2fa/enable (token)
B->>B: Déchiffrer Secret (PGP)
B->>B: Vérifier TOTP (otplib)
B->>DB: UPDATE users SET is_two_factor_enabled = true
B-->>F: 200 OK
```
---
## 📤 Publication de Contenu (Pipeline Média)
La publication d'un mème ou d'un GIF suit un pipeline rigoureux garantissant la sécurité (Antivirus) et l'optimisation (Transcodage).
```mermaid
sequenceDiagram
participant U as Utilisateur
participant F as Frontend
participant B as Backend
participant AV as ClamAV
participant S3 as MinIO (S3)
participant DB as PostgreSQL
U->>F: Sélectionner image/vidéo
F->>B: POST /contents/upload (multipart)
B->>B: Validation (Taille, MIME-Type)
B->>AV: Scan Antivirus (Stream)
AV-->>B: Verdict (Clean/Infected)
alt Infecté
B-->>F: 400 Bad Request (Virus detected)
else Sain
B->>B: Transcodage (Sharp/FFmpeg)
Note right of B: WebP pour images, WebM pour vidéos
B->>S3: Upload fichier optimisé
S3-->>B: Storage Key
B->>DB: INSERT INTO contents
B->>DB: INSERT INTO audit_logs (Upload action)
B-->>F: 201 Created
end
```
---
## 💬 Messagerie & Temps Réel
Memegoat utilise **Socket.io** pour les interactions en temps réel, avec une validation de session robuste via `iron-session`.
```mermaid
sequenceDiagram
participant U1 as Utilisateur A
participant F1 as Frontend A
participant WS as WebSocket Gateway
participant B as Backend (API)
participant F2 as Frontend B
participant U2 as Utilisateur B
U1->>F1: Ouvre le chat
F1->>WS: Connexion (transports: websocket)
Note over WS: Authentification via iron-session cookie
WS->>WS: Vérifie Access Token (JWT)
WS->>WS: Rejoindre room "user:A"
WS-->>F1: Connected
U1->>F1: Tape un message
F1->>WS: Event "typing" { recipientId: B, isTyping: true }
WS->>F2: Event "user_typing" { userId: A, isTyping: true }
F2-->>U2: Affiche "A est en train d'écrire..."
U1->>F1: Envoyer message
F1->>B: POST /messages { recipientId: B, text: "Salut !" }
B->>DB: INSERT INTO messages
B-->>F1: 201 Created
B->>WS: Trigger Notify(B)
WS->>F2: Event "new_message" { senderId: A, text: "Salut !" }
F2-->>U2: Affiche message + Notification
```
---
## ⚖️ Cycle de Vie & Conformité (RGPD)
La gestion des données respecte le droit à l'oubli à travers un processus de suppression en deux étapes et une purge automatique.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant B as Backend
participant DB as PostgreSQL
participant S3 as MinIO (S3)
participant C as Cron Job (PurgeService)
Note over U, DB: Droit à l'oubli (Phase 1)
U->>B: DELETE /users/me
B->>DB: UPDATE users SET deleted_at = NOW()
B->>DB: UPDATE contents SET deleted_at = NOW() WHERE user_id = U
B-->>U: 200 OK (Compte désactivé)
Note over C, S3: Purge Automatique (Phase 2 - après 30 jours)
C->>B: Execute purgeExpiredData()
B->>DB: SELECT users WHERE deleted_at < 30 days
B->>DB: DELETE FROM users (Hard Delete)
Note right of B: Cascade delete sur API keys, Sessions, etc.
B->>DB: DELETE FROM contents (Hard Delete)
B->>S3: DELETE objects (Storage Keys)
B->>DB: Purge Audit Logs / Reports expirés
```
---
## 🚩 Modération
Le flux de modération permet aux utilisateurs de signaler des abus, traités ensuite par les administrateurs.
```mermaid
sequenceDiagram
participant U as Utilisateur
participant B as Backend
participant DB as PostgreSQL
participant A as Administrateur
U->>B: POST /reports { contentId, reason, description }
B->>DB: INSERT INTO reports (status: pending)
B-->>U: 201 Created
A->>B: GET /reports (Admin Panel)
B->>DB: SELECT * FROM reports WHERE status = pending
B-->>A: Liste des signalements
A->>B: PATCH /reports/:id/status { status: resolved }
B->>DB: UPDATE reports SET status = resolved
Note right of B: Si contenu illicite, l'admin peut supprimer le contenu
B->>B: DELETE /contents/:id/admin (Hard Delete)
B-->>A: 200 OK
```

View File

@@ -18,10 +18,11 @@ graph TD
User([Utilisateur]) User([Utilisateur])
Caddy[Reverse Proxy: Caddy] Caddy[Reverse Proxy: Caddy]
Frontend[Frontend: Next.js] Frontend[Frontend: Next.js]
Backend[Backend: NestJS] Backend[Backend: NestJS 11]
DB[(Database: PostgreSQL)] DB[(Database: PostgreSQL)]
Storage[Storage: S3/MinIO] Storage[Storage: S3/MinIO]
Cache[(Cache: Redis)] Cache[(Cache: Redis)]
AV[Antivirus: ClamAV]
Monitoring[Monitoring: Sentry] Monitoring[Monitoring: Sentry]
User <--> Caddy User <--> Caddy
@@ -30,6 +31,7 @@ graph TD
Backend <--> DB Backend <--> DB
Backend <--> Storage Backend <--> Storage
Backend <--> Cache Backend <--> Cache
Backend <--> AV
Backend --> Monitoring Backend --> Monitoring
``` ```
@@ -43,6 +45,11 @@ Explorez les sections clés pour approfondir vos connaissances techniques :
href="/docs/features" href="/docs/features"
description="Détails des capacités techniques et du pipeline média haute performance." description="Détails des capacités techniques et du pipeline média haute performance."
/> />
<Card
title="🔄 Flux Métiers"
href="/docs/flows"
description="Diagrammes de séquence des processus critiques (Publication, 2FA, Chat)."
/>
<Card <Card
title="🔐 Sécurité" title="🔐 Sécurité"
href="/docs/security" href="/docs/security"

View File

@@ -7,6 +7,7 @@ description: Mesures de sécurité implémentées
### Protection des Données (At Rest) ### Protection des Données (At Rest)
- **Cryptographie Post-Quantique** : Utilisation de la bibliothèque `@noble/post-quantum` pour anticiper les futures menaces cryptographiques et protéger les données sensibles contre les attaques "Harvest Now, Decrypt Later".
- **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`). - **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`).
<Callout type="warn" title="Sécurité des Clés"> <Callout type="warn" title="Sécurité des Clés">

View File

@@ -17,9 +17,9 @@ description: Technologies utilisées dans le projet Memegoat
### Backend ### Backend
<Cards> <Cards>
<Card title="NestJS" description="Framework Node.js modulaire et robuste." /> <Card title="NestJS 11" description="Framework Node.js modulaire et robuste (dernière version majeure)." />
<Card title="PostgreSQL" description="Base de données relationnelle puissante." /> <Card title="PostgreSQL" description="Base de données relationnelle puissante." />
<Card title="Redis" description="Store clé-valeur pour le cache haute performance." /> <Card title="Redis" description="Store clé-valeur pour le cache haute performance (Cache Manager v5+)." />
<Card title="Drizzle ORM" description="ORM TypeScript-first avec support des migrations." /> <Card title="Drizzle ORM" description="ORM TypeScript-first avec support des migrations." />
<Card title="Sharp & FFmpeg" description="Traitement haute performance des images et vidéos." /> <Card title="Sharp & FFmpeg" description="Traitement haute performance des images et vidéos." />
</Cards> </Cards>
@@ -28,8 +28,9 @@ description: Technologies utilisées dans le projet Memegoat
<Cards> <Cards>
<Card title="ClamAV" description="Protection antivirus en temps réel." /> <Card title="ClamAV" description="Protection antivirus en temps réel." />
<Card title="Sentry" description="Reporting d'erreurs et profiling de performance." /> <Card title="Sentry" description="Reporting d'erreurs et profiling de performance (SDK v8+)." />
<Card title="Argon2id" description="Hachage de mots de passe de grade militaire." /> <Card title="Argon2id" description="Hachage de mots de passe de grade militaire via @node-rs/argon2." />
<Card title="Post-Quantum Crypto" description="Algorithmes résistants aux futurs ordinateurs quantiques via @noble/post-quantum." />
<Card title="PGP (pgcrypto)" description="Chiffrement natif des données sensibles." /> <Card title="PGP (pgcrypto)" description="Chiffrement natif des données sensibles." />
<Card title="otplib" description="Implémentation TOTP pour la 2FA." /> <Card title="otplib" description="Implémentation TOTP pour la 2FA." />
<Card title="iron-session" description="Gestion sécurisée des sessions via cookies chiffrés." /> <Card title="iron-session" description="Gestion sécurisée des sessions via cookies chiffrés." />

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.memegoat.fr";
const getHostname = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "memegoat.fr", hostname: getHostname(appUrl),
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.memegoat.fr", hostname: getHostname(apiUrl),
}, },
], ],
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.8.1", "version": "1.9.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -63,7 +63,9 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </p>
<p className="font-semibold text-primary">contact@memegoat.fr</p> <p className="font-semibold text-primary">
{process.env.NEXT_PUBLIC_CONTACT_EMAIL || "contact@memegoat.fr"}
</p>
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,17 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Search, Send } from "lucide-react"; import {
ArrowLeft,
Check,
CheckCheck,
Search,
Send,
UserPlus,
X,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -16,23 +26,75 @@ import {
type Message, type Message,
MessageService, MessageService,
} from "@/services/message.service"; } from "@/services/message.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
export default function MessagesPage() { export default function MessagesPage() {
const { user } = useAuth(); const { user } = useAuth();
const { socket } = useSocket(); const { socket } = useSocket();
const _router = useRouter();
const searchParams = useSearchParams();
const targetUserId = searchParams.get("user");
const [conversations, setConversations] = React.useState<Conversation[]>([]); const [conversations, setConversations] = React.useState<Conversation[]>([]);
const [activeConv, setActiveConv] = React.useState<Conversation | null>(null); const [activeConv, setActiveConv] = React.useState<Conversation | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]); const [messages, setMessages] = React.useState<Message[]>([]);
const [newMessage, setNewMessage] = React.useState(""); const [newMessage, setNewMessage] = React.useState("");
const typingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleTyping = () => {
if (!socket || !activeConv) return;
socket.emit("typing", {
recipientId: activeConv.recipient.uuid,
isTyping: true,
});
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
socket.emit("typing", {
recipientId: activeConv.recipient.uuid,
isTyping: false,
});
}, 3000);
};
const [isLoadingConvs, setIsLoadingConvs] = React.useState(true); const [isLoadingConvs, setIsLoadingConvs] = React.useState(true);
const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false); const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false);
const [isOtherTyping, setIsOtherTyping] = React.useState(false);
const [onlineUsers, setOnlineUsers] = React.useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<User[]>([]);
const [isSearching, setIsSearching] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
// Charger les conversations initiales
React.useEffect(() => { React.useEffect(() => {
const fetchConvs = async () => { const fetchConvs = async () => {
try { try {
const data = await MessageService.getConversations(); const data = await MessageService.getConversations();
setConversations(data); setConversations(data);
// Si un utilisateur est spécifié dans l'URL, essayer de trouver la conversation
if (targetUserId) {
const existing = data.find((c) => c.recipient.uuid === targetUserId);
if (existing) {
setActiveConv(existing);
} else {
// Chercher les infos de l'utilisateur pour afficher une interface de chat vide
try {
const conv = await MessageService.getConversationWith(targetUserId);
if (conv) {
setConversations((prev) => [conv, ...prev]);
setActiveConv(conv);
}
} catch (_e) {
// Peut-être que l'utilisateur n'existe pas ou erreur
}
}
}
} catch (_error) { } catch (_error) {
toast.error("Erreur lors du chargement des conversations"); toast.error("Erreur lors du chargement des conversations");
} finally { } finally {
@@ -40,7 +102,28 @@ export default function MessagesPage() {
} }
}; };
fetchConvs(); fetchConvs();
}, []); }, [targetUserId]);
// Recherche d'utilisateurs
React.useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
if (searchQuery.length > 1) {
setIsSearching(true);
try {
const results = await UserService.search(searchQuery);
setSearchResults(results.filter((u) => u.uuid !== user?.uuid));
} catch (_error) {
console.error("Search failed");
} finally {
setIsSearching(false);
}
} else {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery, user?.uuid]);
React.useEffect(() => { React.useEffect(() => {
if (activeConv) { if (activeConv) {
@@ -66,6 +149,9 @@ export default function MessagesPage() {
(data: { conversationId: string; message: Message }) => { (data: { conversationId: string; message: Message }) => {
if (activeConv?.id === data.conversationId) { if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]); setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
// Marquer comme lu immédiatement si on est sur la conversation
MessageService.markAsRead(data.conversationId).catch(console.error);
} }
// Mettre à jour la liste des conversations // Mettre à jour la liste des conversations
setConversations((prev) => { setConversations((prev) => {
@@ -90,15 +176,56 @@ export default function MessagesPage() {
}, },
); );
socket.on("user_status", (data: { userId: string; status: string }) => {
setOnlineUsers((prev) => {
const next = new Set(prev);
if (data.status === "online") {
next.add(data.userId);
} else {
next.delete(data.userId);
}
return next;
});
});
socket.on("user_typing", (data: { userId: string; isTyping: boolean }) => {
if (activeConv?.recipient.uuid === data.userId) {
setIsOtherTyping(data.isTyping);
}
});
socket.on(
"messages_read",
(data: { conversationId: string; readerId: string }) => {
if (activeConv?.id === data.conversationId) {
setMessages((prev) =>
prev.map((msg) =>
msg.senderId !== data.readerId && !msg.readAt
? { ...msg, readAt: new Date().toISOString() }
: msg,
),
);
}
},
);
return () => { return () => {
socket.off("new_message"); socket.off("new_message");
socket.off("user_status");
socket.off("user_typing");
socket.off("messages_read");
}; };
} }
}, [socket, activeConv]); }, [socket, activeConv]);
React.useEffect(() => { React.useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; const scrollContainer = scrollRef.current.querySelector(
"[data-slot='scroll-area-viewport']",
);
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
} }
}, []); }, []);
@@ -114,7 +241,21 @@ export default function MessagesPage() {
activeConv.recipient.uuid, activeConv.recipient.uuid,
text, text,
); );
setMessages((prev) => [...prev, msg]);
// Si c'était une conv temporaire, on la remplace par la vraie
if (activeConv.id.startsWith("temp-")) {
const fetchConvs = async () => {
const data = await MessageService.getConversations();
setConversations(data);
const realConv = data.find(
(c) => c.recipient.uuid === activeConv.recipient.uuid,
);
if (realConv) setActiveConv(realConv);
};
fetchConvs();
} else {
setMessages((prev) => [...prev, msg]);
}
} catch (_error) { } catch (_error) {
toast.error("Erreur lors de l'envoi"); toast.error("Erreur lors de l'envoi");
} }
@@ -123,17 +264,100 @@ export default function MessagesPage() {
return ( return (
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950"> <div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
{/* Sidebar - Liste des conversations */} {/* Sidebar - Liste des conversations */}
<div className="w-80 border-r flex flex-col"> <div
className={`w-full md:w-80 border-r flex flex-col ${
activeConv ? "hidden md:flex" : "flex"
}`}
>
<div className="p-4 border-b"> <div className="p-4 border-b">
<h2 className="text-xl font-bold mb-4">Messages</h2> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Messages</h2>
<Button variant="ghost" size="icon" className="rounded-full">
<UserPlus className="h-5 w-5" />
</Button>
</div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Rechercher..." className="pl-9" /> <Input
placeholder="Rechercher un membre..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div> </div>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">
{isLoadingConvs ? ( {searchQuery.length > 0 ? (
<>
<p className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Membres
</p>
{isSearching ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Recherche...
</div>
) : searchResults.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucun membre trouvé.
</div>
) : (
searchResults.map((result) => (
<button
key={result.uuid}
type="button"
onClick={async () => {
setSearchQuery("");
// Chercher si une conv existe déjà
const existing = conversations.find(
(c) => c.recipient.uuid === result.uuid,
);
if (existing) {
setActiveConv(existing);
} else {
// Créer une interface de conv temporaire
const newConv: Conversation = {
id: `temp-${result.uuid}`,
updatedAt: new Date().toISOString(),
recipient: {
uuid: result.uuid,
username: result.username,
displayName: result.displayName,
avatarUrl: result.avatarUrl,
},
};
setConversations((prev) => [newConv, ...prev]);
setActiveConv(newConv);
}
}}
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
>
<Avatar className="h-10 w-10">
<AvatarImage src={result.avatarUrl} />
<AvatarFallback>{result.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left overflow-hidden">
<span className="font-bold block truncate">
{result.displayName || result.username}
</span>
<span className="text-xs text-muted-foreground block truncate">
@{result.username}
</span>
</div>
</button>
))
)}
</>
) : isLoadingConvs ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="p-4 text-center text-sm text-muted-foreground">
Chargement... Chargement...
</div> </div>
@@ -153,7 +377,7 @@ export default function MessagesPage() {
: "hover:bg-zinc-100 dark:hover:bg-zinc-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-900"
}`} }`}
> >
<Avatar> <Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
<AvatarImage src={conv.recipient.avatarUrl} /> <AvatarImage src={conv.recipient.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{conv.recipient.username[0].toUpperCase()} {conv.recipient.username[0].toUpperCase()}
@@ -184,23 +408,53 @@ export default function MessagesPage() {
</div> </div>
{/* Zone de chat */} {/* Zone de chat */}
<div className="flex-1 flex flex-col"> <div
className={`flex-1 flex flex-col ${
!activeConv ? "hidden md:flex" : "flex"
}`}
>
{activeConv ? ( {activeConv ? (
<> <>
{/* Header */} {/* Header */}
<div className="p-4 border-b flex items-center gap-3"> <div className="p-4 border-b flex items-center gap-2">
<Avatar className="h-8 w-8"> <Button
<AvatarImage src={activeConv.recipient.avatarUrl} /> variant="ghost"
<AvatarFallback> size="icon"
{activeConv.recipient.username[0].toUpperCase()} className="md:hidden rounded-full"
</AvatarFallback> onClick={() => setActiveConv(null)}
</Avatar> >
<div> <ArrowLeft className="h-5 w-5" />
<h3 className="font-bold leading-none"> </Button>
{activeConv.recipient.displayName || activeConv.recipient.username} <Link
</h3> href={`/user/${activeConv.recipient.username}`}
<span className="text-xs text-green-500 font-medium">En ligne</span> className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
</div> >
<Avatar
className="h-8 w-8"
isOnline={onlineUsers.has(activeConv.recipient.uuid)}
>
<AvatarImage src={activeConv.recipient.avatarUrl} />
<AvatarFallback>
{activeConv.recipient.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-bold leading-none">
{activeConv.recipient.displayName || activeConv.recipient.username}
</h3>
<span
className={`text-xs font-medium ${
onlineUsers.has(activeConv.recipient.uuid)
? "text-green-500"
: "text-muted-foreground"
}`}
>
{onlineUsers.has(activeConv.recipient.uuid)
? "En ligne"
: "Hors ligne"}
</span>
</div>
</Link>
</div> </div>
{/* Messages */} {/* Messages */}
@@ -226,22 +480,44 @@ export default function MessagesPage() {
}`} }`}
> >
<p className="whitespace-pre-wrap">{msg.text}</p> <p className="whitespace-pre-wrap">{msg.text}</p>
<p <div
className={`text-[10px] mt-1 ${ className={`flex items-center gap-1 text-[10px] mt-1 ${
msg.senderId === user?.uuid msg.senderId === user?.uuid
? "text-primary-foreground/70" ? "text-primary-foreground/70 justify-end"
: "text-muted-foreground" : "text-muted-foreground"
}`} }`}
> >
{new Date(msg.createdAt).toLocaleTimeString([], { <span>
hour: "2-digit", {new Date(msg.createdAt).toLocaleTimeString([], {
minute: "2-digit", hour: "2-digit",
})} minute: "2-digit",
</p> })}
</span>
{msg.senderId === user?.uuid && (
<span className="flex items-center">
{msg.readAt ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
)}
</div>
</div> </div>
</div> </div>
)) ))
)} )}
{isOtherTyping && (
<div className="flex justify-start">
<div className="bg-zinc-100 dark:bg-zinc-800 p-3 rounded-2xl rounded-bl-none">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce" />
</div>
</div>
</div>
)}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -251,7 +527,10 @@ export default function MessagesPage() {
<Input <Input
placeholder="Écrivez un message..." placeholder="Écrivez un message..."
value={newMessage} value={newMessage}
onChange={(e) => setNewMessage(e.target.value)} onChange={(e) => {
setNewMessage(e.target.value);
handleTyping();
}}
className="rounded-full px-4" className="rounded-full px-4"
/> />
<Button <Button

View File

@@ -10,6 +10,7 @@ import {
Palette, Palette,
Save, Save,
Settings, Settings,
Shield,
Sun, Sun,
Trash2, Trash2,
User as UserIcon, User as UserIcon,
@@ -53,6 +54,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
@@ -60,6 +62,8 @@ import { UserService } from "@/services/user.service";
const settingsSchema = z.object({ const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(), displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(), bio: z.string().max(255, "La bio est trop longue").optional(),
showOnlineStatus: z.boolean(),
showReadReceipts: z.boolean(),
}); });
type SettingsFormValues = z.infer<typeof settingsSchema>; type SettingsFormValues = z.infer<typeof settingsSchema>;
@@ -82,6 +86,8 @@ export default function SettingsPage() {
defaultValues: { defaultValues: {
displayName: "", displayName: "",
bio: "", bio: "",
showOnlineStatus: true,
showReadReceipts: true,
}, },
}); });
@@ -90,6 +96,8 @@ export default function SettingsPage() {
form.reset({ form.reset({
displayName: user.displayName || "", displayName: user.displayName || "",
bio: user.bio || "", bio: user.bio || "",
showOnlineStatus: user.showOnlineStatus ?? true,
showReadReceipts: user.showReadReceipts ?? true,
}); });
} }
}, [user, form]); }, [user, form]);
@@ -265,6 +273,73 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Confidentialité */}
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle>Confidentialité</CardTitle>
<CardDescription>Gérez la visibilité de vos activités.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="showOnlineStatus"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Statut en ligne</FormLabel>
<FormDescription>
Affiche quand vous êtes actif sur le site.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="showReadReceipts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Confirmations de lecture
</FormLabel>
<FormDescription>
Permet aux autres de voir quand vous avez lu leurs messages.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Enregistrer
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<TwoFactorSetup /> <TwoFactorSetup />
<Card className="border-none shadow-sm"> <Card className="border-none shadow-sm">

View File

@@ -1,12 +1,19 @@
"use client"; "use client";
import { Calendar, Share2, User as UserIcon } from "lucide-react"; import {
Calendar,
MessageCircle,
Share2,
User as UserIcon,
} from "lucide-react";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ContentList } from "@/components/content-list"; import { ContentList } from "@/components/content-list";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
@@ -17,9 +24,12 @@ export default function PublicProfilePage({
params: Promise<{ username: string }>; params: Promise<{ username: string }>;
}) { }) {
const { username } = React.use(params); const { username } = React.use(params);
const { user: currentUser, isAuthenticated } = useAuth();
const [user, setUser] = React.useState<User | null>(null); const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const isOwnProfile = currentUser?.username === username;
React.useEffect(() => { React.useEffect(() => {
UserService.getProfile(username) UserService.getProfile(username)
.then(setUser) .then(setUser)
@@ -93,7 +103,15 @@ export default function PublicProfilePage({
})} })}
</span> </span>
</div> </div>
<div className="flex justify-center md:justify-start pt-2"> <div className="flex flex-wrap justify-center md:justify-start gap-2 pt-2">
{!isOwnProfile && isAuthenticated && (
<Button size="sm" className="h-9 px-4" asChild>
<Link href={`/messages?user=${user.uuid}`}>
<MessageCircle className="h-4 w-4 mr-2" />
Message
</Link>
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { NotificationHandler } from "@/components/notification-handler";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { AudioProvider } from "@/providers/audio-provider"; import { AudioProvider } from "@/providers/audio-provider";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fr_FR", locale: "fr_FR",
url: "https://memegoat.local", url: "/",
siteName: "MemeGoat", siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes", title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !", description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
@@ -76,6 +77,7 @@ export default function RootLayout({
<SocketProvider> <SocketProvider>
<AudioProvider> <AudioProvider>
{children} {children}
<NotificationHandler />
<Toaster /> <Toaster />
</AudioProvider> </AudioProvider>
</SocketProvider> </SocketProvider>

View File

@@ -45,6 +45,7 @@ import {
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub, SidebarMenuSub,
@@ -54,7 +55,9 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import { MessageService } from "@/services/message.service";
import type { Category } from "@/types/content"; import type { Category } from "@/types/content";
const mainNav = [ const mainNav = [
@@ -79,15 +82,46 @@ export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth(); const { user, logout, isAuthenticated } = useAuth();
const { socket } = useSocket();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const [unreadMessages, setUnreadMessages] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
}, []); }, []);
// Gérer le compteur de messages non-lus
React.useEffect(() => {
if (isAuthenticated) {
MessageService.getUnreadCount().then(setUnreadMessages).catch(console.error);
}
}, [isAuthenticated]);
React.useEffect(() => {
if (socket && isAuthenticated) {
socket.on("new_message", () => {
// Incrémenter si on n'est pas sur la page messages
if (pathname !== "/messages") {
setUnreadMessages((prev) => prev + 1);
}
});
return () => {
socket.off("new_message");
};
}
}, [socket, isAuthenticated, pathname]);
// Remettre à zéro si on arrive sur la page messages
React.useEffect(() => {
if (pathname === "/messages") {
setUnreadMessages(0);
}
}, [pathname]);
const logoSrc = React.useMemo(() => { const logoSrc = React.useMemo(() => {
if (!mounted) return "/memegoat-color.svg"; if (!mounted) return "/memegoat-color.svg";
return resolvedTheme === "dark" return resolvedTheme === "dark"
@@ -193,6 +227,11 @@ export function AppSidebar() {
<span>Messages</span> <span>Messages</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{unreadMessages > 0 && (
<SidebarMenuBadge className="bg-red-500 text-white border-none h-5 min-w-5 flex items-center justify-center p-1 text-[10px]">
{unreadMessages > 9 ? "9+" : unreadMessages}
</SidebarMenuBadge>
)}
</SidebarMenuItem> </SidebarMenuItem>
)} )}
</SidebarMenu> </SidebarMenu>

View File

@@ -2,7 +2,7 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { MoreHorizontal, Send, Trash2 } from "lucide-react"; import { Heart, MoreHorizontal, Send, Trash2 } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -14,7 +14,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useSocket } from "@/providers/socket-provider";
import { type Comment, CommentService } from "@/services/comment.service"; import { type Comment, CommentService } from "@/services/comment.service";
interface CommentSectionProps { interface CommentSectionProps {
@@ -23,8 +25,10 @@ interface CommentSectionProps {
export function CommentSection({ contentId }: CommentSectionProps) { export function CommentSection({ contentId }: CommentSectionProps) {
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const { socket } = useSocket();
const [comments, setComments] = React.useState<Comment[]>([]); const [comments, setComments] = React.useState<Comment[]>([]);
const [newComment, setNewComment] = React.useState(""); const [newComment, setNewComment] = React.useState("");
const [replyingTo, setReplyingTo] = React.useState<Comment | null>(null);
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
@@ -43,15 +47,40 @@ export function CommentSection({ contentId }: CommentSectionProps) {
fetchComments(); fetchComments();
}, [fetchComments]); }, [fetchComments]);
// Gestion du WebSocket
React.useEffect(() => {
if (socket) {
socket.emit("join_content", contentId);
socket.on("new_comment", (comment: Comment) => {
setComments((prev) => {
// Éviter les doublons si l'auteur reçoit son propre commentaire via WS
if (prev.some((c) => c.id === comment.id)) return prev;
return [comment, ...prev];
});
});
return () => {
socket.emit("leave_content", contentId);
socket.off("new_comment");
};
}
}, [socket, contentId]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newComment.trim() || isSubmitting) return; if (!newComment.trim() || isSubmitting) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const comment = await CommentService.create(contentId, newComment.trim()); const comment = await CommentService.create(
contentId,
newComment.trim(),
replyingTo?.id,
);
setComments((prev) => [comment, ...prev]); setComments((prev) => [comment, ...prev]);
setNewComment(""); setNewComment("");
setReplyingTo(null);
toast.success("Commentaire publié !"); toast.success("Commentaire publié !");
} catch (_error) { } catch (_error) {
toast.error("Erreur lors de la publication du commentaire"); toast.error("Erreur lors de la publication du commentaire");
@@ -70,97 +99,214 @@ export function CommentSection({ contentId }: CommentSectionProps) {
} }
}; };
return ( const handleLike = async (comment: Comment) => {
<div className="space-y-6 mt-8"> if (!isAuthenticated) {
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3> toast.error("Vous devez être connecté pour liker");
return;
}
{isAuthenticated ? ( try {
<form onSubmit={handleSubmit} className="flex gap-3"> if (comment.isLiked) {
<Avatar className="h-8 w-8"> await CommentService.unlike(comment.id);
<AvatarImage src={user?.avatarUrl} /> setComments((prev) =>
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback> prev.map((c) =>
c.id === comment.id
? { ...c, isLiked: false, likesCount: c.likesCount - 1 }
: c,
),
);
} else {
await CommentService.like(comment.id);
setComments((prev) =>
prev.map((c) =>
c.id === comment.id
? { ...c, isLiked: true, likesCount: c.likesCount + 1 }
: c,
),
);
}
} catch (_error) {
toast.error("Une erreur est survenue");
}
};
// Organiser les commentaires : Parents d'abord
const rootComments = comments.filter((c) => !c.parentId);
const renderComment = (comment: Comment, depth = 0) => {
const replies = comments.filter((c) => c.parentId === comment.id);
return (
<div key={comment.id} className={cn("space-y-4", depth > 0 && "ml-10")}>
<div className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={comment.user.avatarUrl} />
<AvatarFallback>{comment.user.username[0].toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-1">
<Textarea <div className="flex items-center justify-between">
placeholder="Ajouter un commentaire..." <div className="flex items-center gap-2">
value={newComment} <span className="text-sm font-bold">
onChange={(e) => setNewComment(e.target.value)} {comment.user.displayName || comment.user.username}
className="min-h-[80px] resize-none" </span>
/> <span className="text-xs text-muted-foreground">
<div className="flex justify-end"> {formatDistanceToNow(new Date(comment.createdAt), {
<Button addSuffix: true,
type="submit" locale: fr,
size="sm" })}
disabled={!newComment.trim() || isSubmitting} </span>
> </div>
{isSubmitting ? "Envoi..." : "Publier"} <div className="flex items-center gap-1">
<Send className="ml-2 h-4 w-4" /> <Button
</Button> variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
comment.isLiked && "text-red-500 hover:text-red-600",
)}
onClick={() => handleLike(comment)}
>
<Heart className={cn("h-4 w-4", comment.isLiked && "fill-current")} />
</Button>
{(user?.uuid === comment.user.uuid ||
user?.role === "admin" ||
user?.role === "moderator") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(comment.id)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
<div className="flex items-center gap-4 pt-1">
{comment.likesCount > 0 && (
<span className="text-xs font-semibold text-muted-foreground">
{comment.likesCount} like{comment.likesCount > 1 ? "s" : ""}
</span>
)}
{isAuthenticated && depth < 1 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => {
setReplyingTo(comment);
setNewComment(`@${comment.user.username} `);
document.querySelector("textarea")?.focus();
}}
>
Répondre
</Button>
)}
</div> </div>
</div> </div>
</form> </div>
{replies.length > 0 && (
<div className="space-y-4 pt-2">
{replies.map((reply) => renderComment(reply, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="space-y-6 mt-8">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3>
</div>
{isAuthenticated ? (
<div className="space-y-2">
{replyingTo && (
<div className="flex items-center justify-between bg-zinc-100 dark:bg-zinc-800 px-3 py-1.5 rounded-lg text-xs">
<span className="text-muted-foreground">
En réponse à{" "}
<span className="font-bold">@{replyingTo.user.username}</span>
</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4"
onClick={() => {
setReplyingTo(null);
setNewComment("");
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={user?.avatarUrl} />
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<Textarea
placeholder={
replyingTo ? "Ajouter une réponse..." : "Ajouter un commentaire..."
}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px] resize-none"
/>
<div className="flex justify-end gap-2">
{replyingTo && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setReplyingTo(null);
setNewComment("");
}}
>
Annuler
</Button>
)}
<Button
type="submit"
size="sm"
disabled={!newComment.trim() || isSubmitting}
>
{isSubmitting ? "Envoi..." : replyingTo ? "Répondre" : "Publier"}
<Send className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</form>
</div>
) : ( ) : (
<div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm"> <div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm">
Connectez-vous pour laisser un commentaire. Connectez-vous pour laisser un commentaire.
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-6">
{isLoading ? ( {isLoading ? (
<div className="text-center text-muted-foreground py-4">Chargement...</div> <div className="text-center text-muted-foreground py-4">Chargement...</div>
) : comments.length === 0 ? ( ) : rootComments.length === 0 ? (
<div className="text-center text-muted-foreground py-4"> <div className="text-center text-muted-foreground py-4">
Aucun commentaire pour le moment. Soyez le premier ! Aucun commentaire pour le moment. Soyez le premier !
</div> </div>
) : ( ) : (
comments.map((comment) => ( rootComments.map((comment) => renderComment(comment))
<div key={comment.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={comment.user.avatarUrl} />
<AvatarFallback>
{comment.user.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{comment.user.displayName || comment.user.username}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
{(user?.uuid === comment.user.uuid ||
user?.role === "admin" ||
user?.role === "moderator") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(comment.id)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
</div>
</div>
))
)} )}
</div> </div>
</div> </div>

View File

@@ -35,6 +35,7 @@ import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { ShareDialog } from "./share-dialog";
import { UserContentEditDialog } from "./user-content-edit-dialog"; import { UserContentEditDialog } from "./user-content-edit-dialog";
import { ViewCounter } from "./view-counter"; import { ViewCounter } from "./view-counter";
@@ -51,6 +52,7 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
const [isLiked, setIsLiked] = React.useState(content.isLiked || false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false); const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
const [_reportDialogOpen, setReportDialogOpen] = React.useState(false); const [_reportDialogOpen, setReportDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId; const isAuthor = user?.uuid === content.authorId;
@@ -190,7 +192,15 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}> <DropdownMenuItem
onClick={() => {
if (!isAuthenticated) {
toast.error("Connectez-vous pour partager");
return;
}
setShareDialogOpen(true);
}}
>
<Share2 className="h-4 w-4 mr-2" /> <Share2 className="h-4 w-4 mr-2" />
Partager Partager
</DropdownMenuItem> </DropdownMenuItem>
@@ -263,10 +273,11 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
navigator.clipboard.writeText( if (!isAuthenticated) {
`${window.location.origin}/meme/${content.slug}`, toast.error("Connectez-vous pour partager");
); return;
toast.success("Lien copié !"); }
setShareDialogOpen(true);
}} }}
className="hover:text-muted-foreground" className="hover:text-muted-foreground"
> >
@@ -322,6 +333,13 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
onOpenChange={setEditDialogOpen} onOpenChange={setEditDialogOpen}
onSuccess={() => onUpdate?.()} onSuccess={() => onUpdate?.()}
/> />
<ShareDialog
contentId={content.id}
contentTitle={content.title}
contentUrl={`${typeof window !== "undefined" ? window.location.origin : ""}/meme/${content.slug}`}
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,108 @@
"use client";
import { Bell, Heart, MessageCircle, Reply } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { toast } from "sonner";
import { useSocket } from "@/providers/socket-provider";
interface NotificationData {
type: "comment" | "reply" | "like_comment" | "message";
userId: string;
username: string;
contentId?: string;
commentId?: string;
text: string;
}
export function NotificationHandler() {
const { socket } = useSocket();
const router = useRouter();
React.useEffect(() => {
if (!socket) return;
const handleNotification = (data: NotificationData) => {
// Ne pas afficher de toast si on est déjà sur la page des messages pour un nouveau message
if (data.type === "message" && window.location.pathname === "/messages") {
return;
}
toast.custom(
(t) => (
<button
type="button"
className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors text-left"
onClick={() => {
toast.dismiss(t);
if (data.type === "message") {
router.push("/messages");
} else if (data.contentId) {
router.push(`/meme/${data.contentId}`);
}
}}
>
<div className="bg-primary/10 p-2 rounded-full shrink-0">
{data.type === "comment" && (
<MessageCircle className="h-4 w-4 text-primary" />
)}
{data.type === "reply" && <Reply className="h-4 w-4 text-primary" />}
{data.type === "like_comment" && (
<Heart className="h-4 w-4 text-red-500" />
)}
{data.type === "message" && (
<MessageCircle className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-bold">@{data.username}</p>
<p className="text-xs text-muted-foreground truncate">{data.text}</p>
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
toast.dismiss(t);
}}
>
<Bell className="h-3 w-3" />
</button>
</button>
),
{
duration: 5000,
position: "top-right",
},
);
};
socket.on("notification", handleNotification);
// Aussi pour les nouveaux messages (si on veut un toast global)
socket.on(
"new_message",
(data: { message: { text: string; sender?: { username: string } } }) => {
if (window.location.pathname !== "/messages") {
toast(
`Nouveau message de @${data.message.sender?.username || "un membre"}`,
{
description: data.message.text.substring(0, 50),
action: {
label: "Voir",
onClick: () => router.push("/messages"),
},
},
);
}
},
);
return () => {
socket.off("notification");
socket.off("new_message");
};
}, [socket, router]);
return null;
}

View File

@@ -0,0 +1,186 @@
"use client";
import { Search, Send, X } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageService } from "@/services/message.service";
import { UserService } from "@/services/user.service";
import type { User } from "@/types/user";
interface ShareDialogProps {
contentId: string;
contentTitle: string;
contentUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareDialog({
contentId,
contentTitle,
contentUrl: _unused, // Support legacy prop
open,
onOpenChange,
}: ShareDialogProps) {
const [searchQuery, setSearchQuery] = React.useState("");
const [results, setResults] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [sendingTo, setSendingTo] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) {
setSearchQuery("");
setResults([]);
return;
}
const fetchInitial = async () => {
setIsLoading(true);
try {
// Par défaut, montrer les conversations récentes ou suggérer des gens
const recent = await UserService.search("");
setResults(recent);
} catch (error) {
console.error("Failed to fetch users", error);
} finally {
setIsLoading(false);
}
};
fetchInitial();
}, [open]);
React.useEffect(() => {
if (searchQuery.length < 2) return;
const timeout = setTimeout(async () => {
setIsLoading(true);
try {
const data = await UserService.search(searchQuery);
setResults(data);
} catch (error) {
console.error("Search failed", error);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timeout);
}, [searchQuery]);
const handleShare = async (user: User) => {
setSendingTo(user.uuid);
try {
const shareUrl = `${window.location.origin}/meme/${contentId}`;
await MessageService.sendMessage(
user.uuid,
`Regarde ce mème : ${contentTitle}\n${shareUrl}`,
);
toast.success(`Partagé avec @${user.username}`);
onOpenChange(false);
} catch (_error) {
toast.error("Échec du partage");
} finally {
setSendingTo(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px] p-0 gap-0 overflow-hidden">
<DialogHeader className="p-4 border-b">
<DialogTitle>Partager avec</DialogTitle>
</DialogHeader>
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un membre..."
className="pl-9 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
)}
</div>
</div>
<ScrollArea className="h-[300px]">
<div className="p-2 space-y-1">
{isLoading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Chargement...
</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucun membre trouvé.
</div>
) : (
results.map((user) => (
<div
key={user.uuid}
className="flex items-center justify-between p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-900"
>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback>{user.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-bold leading-none">
{user.displayName || user.username}
</span>
<span className="text-xs text-muted-foreground">
@{user.username}
</span>
</div>
</div>
<Button
size="sm"
variant={sendingTo === user.uuid ? "outline" : "default"}
disabled={sendingTo !== null}
onClick={() => handleShare(user)}
className="h-8 px-4 rounded-full"
>
{sendingTo === user.uuid ? "Envoi..." : "Envoyer"}
</Button>
</div>
))
)}
</div>
</ScrollArea>
<div className="p-4 border-t bg-zinc-50 dark:bg-zinc-900/50">
<Button
variant="outline"
className="w-full justify-start gap-2 h-10 rounded-xl"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/meme/${contentId}`,
);
toast.success("Lien copié !");
onOpenChange(false);
}}
>
<Send className="h-4 w-4" />
Copier le lien
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Loader2, Shield, ShieldAlert, ShieldCheck } from "lucide-react"; import { Loader2, Shield, ShieldAlert, ShieldCheck } from "lucide-react";
import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -28,6 +29,7 @@ export function TwoFactorSetup() {
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState(""); const [otpValue, setOtpValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRevealed, setIsRevealed] = useState(false);
const handleSetup = async () => { const handleSetup = async () => {
setIsLoading(true); setIsLoading(true);
@@ -76,9 +78,7 @@ export function TwoFactorSetup() {
}; };
// Note: We need a way to know if 2FA is enabled. // Note: We need a way to know if 2FA is enabled.
// Assuming user object might have twoFactorEnabled property or similar. const isEnabled = user?.twoFactorEnabled;
// For now, let's assume it's on the user object (we might need to add it to the type).
const isEnabled = (user as any)?.twoFactorEnabled;
if (step === "idle") { if (step === "idle") {
return ( return (
@@ -153,17 +153,59 @@ export function TwoFactorSetup() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-6"> <CardContent className="flex flex-col items-center gap-6">
{qrCode && ( {qrCode && (
<div className="bg-white p-4 rounded-xl border-4 border-zinc-100"> <div className="relative group">
<img src={qrCode} alt="QR Code 2FA" className="w-48 h-48" /> <div
className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
!isRevealed ? "blur-md select-none" : ""
}`}
>
<Image
src={qrCode}
alt="QR Code 2FA"
width={192}
height={192}
className="w-48 h-48"
unoptimized
/>
</div>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<Button
variant="secondary"
size="sm"
onClick={() => setIsRevealed(true)}
className="shadow-lg"
>
Afficher le QR Code
</Button>
</div>
)}
</div> </div>
)} )}
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="text-sm font-medium text-center"> <p className="text-sm font-medium text-center">
Ou entrez ce code manuellement : Ou entrez ce code manuellement :
</p> </p>
<code className="block p-2 bg-muted text-center rounded text-xs font-mono break-all"> <div className="relative group">
{secret} <code
</code> className={`block p-2 bg-muted text-center rounded text-xs font-mono break-all transition-all duration-300 ${
!isRevealed ? "blur-[3px] select-none" : ""
}`}
>
{secret}
</code>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<button
type="button"
onClick={() => setIsRevealed(true)}
className="text-[10px] font-bold uppercase tracking-wider text-primary hover:underline"
>
Afficher le code
</button>
</div>
)}
</div>
</div> </div>
<div className="flex flex-col items-center gap-4 w-full border-t pt-6"> <div className="flex flex-col items-center gap-4 w-full border-t pt-6">
<p className="text-sm font-medium"> <p className="text-sm font-medium">

View File

@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
isOnline,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
return ( return (
<AvatarPrimitive.Root <div className="relative inline-block">
data-slot="avatar" <AvatarPrimitive.Root
className={cn( data-slot="avatar"
"relative flex size-8 shrink-0 overflow-hidden rounded-full", className={cn(
className, "relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
{isOnline && (
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white dark:ring-zinc-900" />
)} )}
{...props} </div>
/>
); );
} }

View File

@@ -24,15 +24,25 @@ export function SocketProvider({ children }: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
// Initialisation du socket avec configuration optimisée pour la production
const socketInstance = io(apiUrl, { const socketInstance = io(apiUrl, {
withCredentials: true, withCredentials: true,
transports: ["websocket"], transports: ["websocket"], // Recommandé pour éviter les problèmes de sticky sessions
reconnectionAttempts: 5,
reconnectionDelay: 1000,
}); });
socketInstance.on("connect", () => { socketInstance.on("connect", () => {
console.log("WebSocket connected to:", apiUrl);
setIsConnected(true); setIsConnected(true);
}); });
socketInstance.on("connect_error", (error) => {
console.error("WebSocket connection error:", error);
// Si le websocket pur échoue, on peut tenter le polling en fallback (optionnel)
});
socketInstance.on("disconnect", () => { socketInstance.on("disconnect", () => {
setIsConnected(false); setIsConnected(false);
}); });

View File

@@ -1,4 +1,5 @@
import api from "@/lib/api"; import api from "@/lib/api";
import type { User } from "@/types/user";
import type { Report, ReportStatus } from "./report.service"; import type { Report, ReportStatus } from "./report.service";
export interface AdminStats { export interface AdminStats {
@@ -29,7 +30,7 @@ export const adminService = {
await api.delete(`/users/${userId}`); await api.delete(`/users/${userId}`);
}, },
updateUser: async (userId: string, data: any): Promise<void> => { updateUser: async (userId: string, data: Partial<User>): Promise<void> => {
await api.patch(`/users/admin/${userId}`, data); await api.patch(`/users/admin/${userId}`, data);
}, },
}; };

View File

@@ -3,6 +3,9 @@ import api from "@/lib/api";
export interface Comment { export interface Comment {
id: string; id: string;
text: string; text: string;
parentId?: string;
likesCount: number;
isLiked: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
user: { user: {
@@ -19,9 +22,14 @@ export const CommentService = {
return data; return data;
}, },
async create(contentId: string, text: string): Promise<Comment> { async create(
contentId: string,
text: string,
parentId?: string,
): Promise<Comment> {
const { data } = await api.post<Comment>(`/contents/${contentId}/comments`, { const { data } = await api.post<Comment>(`/contents/${contentId}/comments`, {
text, text,
parentId,
}); });
return data; return data;
}, },
@@ -29,4 +37,12 @@ export const CommentService = {
async remove(commentId: string): Promise<void> { async remove(commentId: string): Promise<void> {
await api.delete(`/comments/${commentId}`); await api.delete(`/comments/${commentId}`);
}, },
async like(commentId: string): Promise<void> {
await api.post(`/comments/${commentId}/like`);
},
async unlike(commentId: string): Promise<void> {
await api.delete(`/comments/${commentId}/like`);
},
}; };

View File

@@ -29,6 +29,11 @@ export const MessageService = {
return data; return data;
}, },
async getUnreadCount(): Promise<number> {
const { data } = await api.get<number>("/messages/unread-count");
return data;
},
async getMessages(conversationId: string): Promise<Message[]> { async getMessages(conversationId: string): Promise<Message[]> {
const { data } = await api.get<Message[]>( const { data } = await api.get<Message[]>(
`/messages/conversations/${conversationId}`, `/messages/conversations/${conversationId}`,
@@ -36,6 +41,13 @@ export const MessageService = {
return data; return data;
}, },
async getConversationWith(userId: string): Promise<Conversation | null> {
const { data } = await api.get<Conversation | null>(
`/messages/conversations/with/${userId}`,
);
return data;
},
async sendMessage(recipientId: string, text: string): Promise<Message> { async sendMessage(recipientId: string, text: string): Promise<Message> {
const { data } = await api.post<Message>("/messages", { const { data } = await api.post<Message>("/messages", {
recipientId, recipientId,
@@ -43,4 +55,8 @@ export const MessageService = {
}); });
return data; return data;
}, },
async markAsRead(conversationId: string): Promise<void> {
await api.patch(`/messages/conversations/${conversationId}/read`);
},
}; };

View File

@@ -12,6 +12,13 @@ export const UserService = {
return data; return data;
}, },
async search(query: string): Promise<User[]> {
const { data } = await api.get<User[]>("/users/search", {
params: { q: query },
});
return data;
},
async updateMe(update: Partial<User>): Promise<User> { async updateMe(update: Partial<User>): Promise<User> {
const { data } = await api.patch<User>("/users/me", update); const { data } = await api.patch<User>("/users/me", update);
return data; return data;
@@ -54,8 +61,8 @@ export const UserService = {
return data; return data;
}, },
async exportData(): Promise<any> { async exportData(): Promise<Record<string, unknown>> {
const { data } = await api.get("/users/me/export"); const { data } = await api.get<Record<string, unknown>>("/users/me/export");
return data; return data;
}, },
}; };

View File

@@ -8,6 +8,9 @@ export interface User {
bio?: string; bio?: string;
role?: "user" | "admin" | "moderator"; role?: "user" | "admin" | "moderator";
status?: "active" | "verification" | "suspended" | "pending" | "deleted"; status?: "active" | "verification" | "suspended" | "pending" | "deleted";
twoFactorEnabled?: boolean;
showOnlineStatus?: boolean;
showReadReceipts?: boolean;
createdAt: string; createdAt: string;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.8.1", "version": "1.9.6",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",