From 64889128ff803750c3ffbe838029619a9dba8865 Mon Sep 17 00:00:00 2001 From: sadeeq6400 Date: Tue, 30 Jun 2026 02:16:08 +0100 Subject: [PATCH 1/4] Community feed: backend --- backend/src/app.module.ts | 2 + backend/src/community/community.controller.ts | 96 ++++++++++ backend/src/community/community.module.ts | 14 ++ backend/src/community/community.service.ts | 165 ++++++++++++++++++ backend/src/community/dto/create-post.dto.ts | 10 ++ backend/src/community/dto/post-query.dto.ts | 20 +++ .../entities/community-post-like.entity.ts | 38 ++++ .../entities/community-post.entity.ts | 48 +++++ 8 files changed, 393 insertions(+) create mode 100644 backend/src/community/community.controller.ts create mode 100644 backend/src/community/community.module.ts create mode 100644 backend/src/community/community.service.ts create mode 100644 backend/src/community/dto/create-post.dto.ts create mode 100644 backend/src/community/dto/post-query.dto.ts create mode 100644 backend/src/community/entities/community-post-like.entity.ts create mode 100644 backend/src/community/entities/community-post.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 88e247a..2b66aa2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -50,6 +50,7 @@ import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; import { ResourcesModule } from './resources/resources.module'; import { NpsModule } from './nps/nps.module'; import { DoorAccessModule } from './integrations/access-control/door-access.module'; +import { CommunityModule } from './community/community.module'; @Module({ imports: [ @@ -156,6 +157,7 @@ import { DoorAccessModule } from './integrations/access-control/door-access.modu ResourcesModule, NpsModule, DoorAccessModule, + CommunityModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/community/community.controller.ts b/backend/src/community/community.controller.ts new file mode 100644 index 0000000..b8f963f --- /dev/null +++ b/backend/src/community/community.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Post, + Delete, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { CommunityService } from './community.service'; +import { CreatePostDto } from './dto/create-post.dto'; +import { PostQueryDto } from './dto/post-query.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Community Feed') +@ApiBearerAuth() +@UseGuards(RolesGuard) +@Controller('community/posts') +export class CommunityController { + constructor(private readonly communityService: CommunityService) {} + + // POST /community/posts — any active authenticated member + @Post() + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Create a community post' }) + async createPost( + @Body() dto: CreatePostDto, + @GetCurrentUser('id') userId: string, + ) { + const post = await this.communityService.createPost(dto, userId); + return { message: 'Post created successfully', data: post }; + } + + // GET /community/posts — paginated feed; pinned first, then newest + @Get() + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Get paginated community feed (pinned first, then newest)' }) + async getFeed(@Query() query: PostQueryDto) { + return this.communityService.getFeed(query); + } + + // DELETE /community/posts/:id — soft-delete own post (member) or any post (admin) + @Delete(':id') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Soft-delete a post (own post for members, any post for admins)' }) + async deletePost( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + @GetCurrentUser('role') userRole: UserRole, + ) { + await this.communityService.deletePost(id, userId, userRole); + return { message: 'Post deleted successfully' }; + } + + // POST /community/posts/:id/like — toggle like/unlike + @Post(':id/like') + @Roles(UserRole.USER, UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Toggle like/unlike on a post' }) + async toggleLike( + @Param('id', ParseUUIDPipe) id: string, + @GetCurrentUser('id') userId: string, + ) { + const result = await this.communityService.toggleLike(id, userId); + return { + message: result.liked ? 'Post liked' : 'Post unliked', + data: result, + }; + } + + // PATCH /community/posts/:id/pin — toggle pin/unpin (admin only) + @Patch(':id/pin') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Toggle pin/unpin on a post (admin only)' }) + async togglePin(@Param('id', ParseUUIDPipe) id: string) { + const result = await this.communityService.togglePin(id); + return { + message: result.isPinned ? 'Post pinned' : 'Post unpinned', + data: result, + }; + } +} diff --git a/backend/src/community/community.module.ts b/backend/src/community/community.module.ts new file mode 100644 index 0000000..c76973c --- /dev/null +++ b/backend/src/community/community.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CommunityPost } from './entities/community-post.entity'; +import { CommunityPostLike } from './entities/community-post-like.entity'; +import { CommunityService } from './community.service'; +import { CommunityController } from './community.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([CommunityPost, CommunityPostLike])], + controllers: [CommunityController], + providers: [CommunityService], + exports: [CommunityService], +}) +export class CommunityModule {} diff --git a/backend/src/community/community.service.ts b/backend/src/community/community.service.ts new file mode 100644 index 0000000..a1a4c9c --- /dev/null +++ b/backend/src/community/community.service.ts @@ -0,0 +1,165 @@ +import { + Injectable, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { CommunityPost } from './entities/community-post.entity'; +import { CommunityPostLike } from './entities/community-post-like.entity'; +import { CreatePostDto } from './dto/create-post.dto'; +import { PostQueryDto } from './dto/post-query.dto'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { ErrorCatch } from '../utils/error'; + +@Injectable() +export class CommunityService { + constructor( + @InjectRepository(CommunityPost) + private readonly postRepo: Repository, + + @InjectRepository(CommunityPostLike) + private readonly likeRepo: Repository, + + private readonly dataSource: DataSource, + ) {} + + // ─── Create Post ────────────────────────────────────────────────────────── + + async createPost(dto: CreatePostDto, authorUserId: string): Promise { + try { + const post = this.postRepo.create({ body: dto.body, authorUserId }); + return await this.postRepo.save(post); + } catch (error) { + ErrorCatch(error, 'Error creating community post'); + } + } + + // ─── Paginated Feed ─────────────────────────────────────────────────────── + + async getFeed(query: PostQueryDto) { + try { + const { page = 1, limit = 20 } = query; + + const [items, total] = await this.postRepo + .createQueryBuilder('post') + .leftJoin('post.author', 'author') + .addSelect([ + 'author.id', + 'author.username', + 'author.firstname', + 'author.lastname', + 'author.profilePicture', + ]) + .where('post.isDeleted = :isDeleted', { isDeleted: false }) + // pinned first, then newest + .orderBy('post.isPinned', 'DESC') + .addOrderBy('post.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } catch (error) { + ErrorCatch(error, 'Error fetching community feed'); + } + } + + // ─── Soft-Delete Post ───────────────────────────────────────────────────── + + async deletePost( + postId: string, + userId: string, + userRole: UserRole, + ): Promise { + try { + const post = await this.findPostOrFail(postId); + + const isAdmin = + userRole === UserRole.ADMIN || userRole === UserRole.SUPER_ADMIN; + + if (!isAdmin && post.authorUserId !== userId) { + throw new ForbiddenException('You can only delete your own posts'); + } + + post.isDeleted = true; + await this.postRepo.save(post); + } catch (error) { + ErrorCatch(error, 'Error deleting community post'); + } + } + + // ─── Toggle Like ────────────────────────────────────────────────────────── + + async toggleLike( + postId: string, + userId: string, + ): Promise<{ liked: boolean; likeCount: number }> { + try { + const post = await this.findPostOrFail(postId); + + const existingLike = await this.likeRepo.findOne({ + where: { postId, userId }, + }); + + // Use a transaction to update like row + likeCount atomically + await this.dataSource.transaction(async (manager) => { + if (existingLike) { + await manager.remove(CommunityPostLike, existingLike); + await manager + .createQueryBuilder() + .update(CommunityPost) + .set({ likeCount: () => '"likeCount" - 1' }) + .where('id = :id AND "likeCount" > 0', { id: postId }) + .execute(); + } else { + const like = manager.create(CommunityPostLike, { postId, userId }); + await manager.save(CommunityPostLike, like); + await manager + .createQueryBuilder() + .update(CommunityPost) + .set({ likeCount: () => '"likeCount" + 1' }) + .where('id = :id', { id: postId }) + .execute(); + } + }); + + // Reload updated count + const updated = await this.postRepo.findOne({ where: { id: postId } }); + return { + liked: !existingLike, + likeCount: updated?.likeCount ?? post.likeCount, + }; + } catch (error) { + ErrorCatch(error, 'Error toggling post like'); + } + } + + // ─── Toggle Pin ─────────────────────────────────────────────────────────── + + async togglePin(postId: string): Promise<{ isPinned: boolean }> { + try { + const post = await this.findPostOrFail(postId); + post.isPinned = !post.isPinned; + await this.postRepo.save(post); + return { isPinned: post.isPinned }; + } catch (error) { + ErrorCatch(error, 'Error toggling post pin'); + } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private async findPostOrFail(postId: string): Promise { + const post = await this.postRepo.findOne({ where: { id: postId } }); + if (!post) throw new NotFoundException(`Post ${postId} not found`); + if (post.isDeleted) throw new NotFoundException(`Post ${postId} not found`); + return post; + } +} diff --git a/backend/src/community/dto/create-post.dto.ts b/backend/src/community/dto/create-post.dto.ts new file mode 100644 index 0000000..87eec3b --- /dev/null +++ b/backend/src/community/dto/create-post.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatePostDto { + @ApiProperty({ description: 'Post body text', maxLength: 1000, minLength: 1 }) + @IsString() + @MinLength(1) + @MaxLength(1000) + body: string; +} diff --git a/backend/src/community/dto/post-query.dto.ts b/backend/src/community/dto/post-query.dto.ts new file mode 100644 index 0000000..8ac8b5a --- /dev/null +++ b/backend/src/community/dto/post-query.dto.ts @@ -0,0 +1,20 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PostQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/community/entities/community-post-like.entity.ts b/backend/src/community/entities/community-post-like.entity.ts new file mode 100644 index 0000000..aa7d5c1 --- /dev/null +++ b/backend/src/community/entities/community-post-like.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { CommunityPost } from './community-post.entity'; + +@Entity('community_post_likes') +@Unique(['postId', 'userId']) +@Index(['postId']) +@Index(['userId']) +export class CommunityPostLike { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + postId: string; + + @ManyToOne(() => CommunityPost, (post) => post.likes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'postId' }) + post: CommunityPost; + + @Column('uuid') + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/community/entities/community-post.entity.ts b/backend/src/community/entities/community-post.entity.ts new file mode 100644 index 0000000..afa6b7a --- /dev/null +++ b/backend/src/community/entities/community-post.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { CommunityPostLike } from './community-post-like.entity'; + +@Entity('community_posts') +@Index(['isDeleted', 'isPinned', 'createdAt']) +export class CommunityPost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + authorUserId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'authorUserId' }) + author: User; + + @Column({ type: 'text', length: 1000 }) + body: string; + + @Column({ type: 'boolean', default: false }) + isPinned: boolean; + + @Column({ type: 'boolean', default: false }) + isDeleted: boolean; + + @Column({ type: 'int', default: 0 }) + likeCount: number; + + @OneToMany(() => CommunityPostLike, (like) => like.post) + likes: CommunityPostLike[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} From 5cf8d05e6cd4fb6e6b64aaf1a6344163516cf72e Mon Sep 17 00:00:00 2001 From: sadeeq6400 Date: Tue, 30 Jun 2026 02:25:10 +0100 Subject: [PATCH 2/4] Community feed: frontend (bulletin board) --- frontend/app/community/page.tsx | 205 ++++++++++ .../components/community/CommunityFeed.tsx | 350 ++++++++++++++++++ .../components/dashboard/DashboardSidebar.tsx | 3 +- .../hooks/community/useCreateCommunityPost.ts | 26 ++ .../hooks/community/useDeleteCommunityPost.ts | 21 ++ .../hooks/community/useGetCommunityFeed.ts | 17 + .../hooks/community/useTogglePostLike.ts | 73 ++++ .../hooks/community/useTogglePostPin.ts | 22 ++ frontend/lib/react-query/keys/queryKeys.ts | 3 + frontend/lib/types/community.ts | 46 +++ 10 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 frontend/app/community/page.tsx create mode 100644 frontend/components/community/CommunityFeed.tsx create mode 100644 frontend/lib/react-query/hooks/community/useCreateCommunityPost.ts create mode 100644 frontend/lib/react-query/hooks/community/useDeleteCommunityPost.ts create mode 100644 frontend/lib/react-query/hooks/community/useGetCommunityFeed.ts create mode 100644 frontend/lib/react-query/hooks/community/useTogglePostLike.ts create mode 100644 frontend/lib/react-query/hooks/community/useTogglePostPin.ts create mode 100644 frontend/lib/types/community.ts diff --git a/frontend/app/community/page.tsx b/frontend/app/community/page.tsx new file mode 100644 index 0000000..58ed3e1 --- /dev/null +++ b/frontend/app/community/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useEffect } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import CommunityFeed from "@/components/community/CommunityFeed"; +import { apiClient } from "@/lib/apiClient"; +import { Search, User, X, Linkedin, Twitter, Users, Rss } from "lucide-react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type Member = { + id: string; + firstname: string; + lastname: string; + username?: string; + profilePicture?: string; + memberSince?: string; +}; + +type Tab = "feed" | "members"; + +// ─── Members tab (ported from /members with community API) ──────────────────── + +function MembersTab() { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [selectedMember, setSelectedMember] = useState(null); + + useEffect(() => { + fetchMembers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search]); + + const fetchMembers = async () => { + try { + setLoading(true); + const res = await apiClient.get<{ + data: Member[]; + total: number; + }>(`/community/members?search=${encodeURIComponent(search)}&limit=50`); + setMembers(res.data ?? []); + } catch { + setMembers([]); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 text-sm text-gray-900 dark:text-gray-100" + /> +
+ + {loading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : members.length === 0 ? ( +
+ +

No members found.

+
+ ) : ( +
+ {members.map((member) => ( + + ))} +
+ )} + + {/* Member profile modal */} + {selectedMember && ( +
+
+ +
+ {selectedMember.profilePicture ? ( + {`${selectedMember.firstname} + ) : ( +
+ +
+ )} +

+ {selectedMember.firstname} {selectedMember.lastname} +

+ {selectedMember.username && ( +

+ @{selectedMember.username} +

+ )} + {selectedMember.memberSince && ( +

+ Member since{" "} + {new Date(selectedMember.memberSince).toLocaleDateString(undefined, { + month: "long", + year: "numeric", + })} +

+ )} +
+
+
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CommunityPage() { + const [activeTab, setActiveTab] = useState("feed"); + + const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ + { id: "feed", label: "Feed", icon: Rss }, + { id: "members", label: "Members", icon: Users }, + ]; + + return ( + + {/* Header */} +
+

Community

+

+ Connect with fellow members, share updates, and celebrate milestones. +

+
+ + {/* Tabs */} +
+ {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "feed" ? : } +
+ ); +} diff --git a/frontend/components/community/CommunityFeed.tsx b/frontend/components/community/CommunityFeed.tsx new file mode 100644 index 0000000..74dc52d --- /dev/null +++ b/frontend/components/community/CommunityFeed.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useState } from "react"; +import { Heart, Pin, Trash2, PenLine, X } from "lucide-react"; +import { useAuthState } from "@/lib/store/authStore"; +import { useGetCommunityFeed } from "@/lib/react-query/hooks/community/useGetCommunityFeed"; +import { useCreateCommunityPost } from "@/lib/react-query/hooks/community/useCreateCommunityPost"; +import { useTogglePostLike } from "@/lib/react-query/hooks/community/useTogglePostLike"; +import { useDeleteCommunityPost } from "@/lib/react-query/hooks/community/useDeleteCommunityPost"; +import { useTogglePostPin } from "@/lib/react-query/hooks/community/useTogglePostPin"; +import { CommunityPost } from "@/lib/types/community"; + +const MAX_BODY = 1000; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function Avatar({ author }: { author: CommunityPost["author"] }) { + const initials = `${author.firstname?.[0] ?? ""}${author.lastname?.[0] ?? ""}`.toUpperCase(); + if (author.profilePicture) { + return ( + {`${author.firstname} + ); + } + return ( +
+ + {initials || "?"} + +
+ ); +} + +// ─── Write Post Modal ───────────────────────────────────────────────────────── + +function WritePostModal({ onClose }: { onClose: () => void }) { + const [body, setBody] = useState(""); + const createPost = useCreateCommunityPost(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!body.trim()) return; + await createPost.mutateAsync({ body: body.trim() }); + onClose(); + }; + + return ( +
+
+
+

Share with the community

+ +
+
+
+