Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions backend/src/groups/groups.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { GroupsService } from './groups.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';

@Controller('groups')
@UseGuards(JwtAuthGuard)
export class GroupsController {
constructor(private groupsService: GroupsService) {}

@Get()
getMyGroups(@CurrentUser('id') userId: string) {
return this.groupsService.getMyGroups(userId);
}

@Post()
createGroup(
@CurrentUser('id') userId: string,
@Body() body: { name: string; avatar?: string; description?: string },
) {
return this.groupsService.createGroup(userId, body.name, body.avatar, body.description);
}

@Get(':id')
getGroup(@Param('id') id: string, @CurrentUser('id') userId: string) {
return this.groupsService.getGroup(id, userId);
}

@Patch(':id')
updateGroup(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@Body() body: { name?: string; avatar?: string; description?: string },
) {
return this.groupsService.updateGroup(id, userId, body);
}

@Delete(':id')
deleteGroup(@Param('id') id: string, @CurrentUser('id') userId: string) {
return this.groupsService.deleteGroup(id, userId);
}

@Post(':id/members')
addMember(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@Body() body: { userId: string },
) {
return this.groupsService.addMember(id, userId, body.userId);
}

@Delete(':id/members/:userId')
removeMember(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@Param('userId') targetUserId: string,
) {
return this.groupsService.removeMember(id, userId, targetUserId);
}

@Get(':id/messages')
getMessages(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.groupsService.getMessages(id, userId, page ? +page : 1, limit ? +limit : 50);
}

@Post(':id/messages')
sendMessage(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@Body() body: { content: string; type?: string },
) {
return this.groupsService.sendMessage(id, userId, body.content, body.type);
}
}
11 changes: 11 additions & 0 deletions backend/src/groups/groups.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
imports: [PrismaModule],
controllers: [GroupsController],
providers: [GroupsService],
})
export class GroupsModule {}
111 changes: 111 additions & 0 deletions backend/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GroupRole } from '@prisma/client';

@Injectable()
export class GroupsService {
constructor(private prisma: PrismaService) {}

async createGroup(userId: string, name: string, avatar?: string, description?: string) {
return this.prisma.group.create({
data: {
name,
avatar,
description,
members: {
create: { userId, role: GroupRole.CREATOR },
},
},
include: { members: true },
});
}

async getMyGroups(userId: string) {
const members = await this.prisma.groupMember.findMany({
where: { userId },
include: { group: true },
orderBy: { joinedAt: 'desc' },
});
return members.map(m => m.group);
}

async getGroup(groupId: string, userId: string) {
await this.checkMembership(groupId, userId);
return this.prisma.group.findUnique({
where: { id: groupId },
include: { members: true },
});
}

async updateGroup(groupId: string, userId: string, data: { name?: string; avatar?: string; description?: string }) {
await this.checkRole(groupId, userId, [GroupRole.CREATOR, GroupRole.ADMIN]);
return this.prisma.group.update({
where: { id: groupId },
data,
});
}

async deleteGroup(groupId: string, userId: string) {
await this.checkRole(groupId, userId, [GroupRole.CREATOR]);
return this.prisma.group.delete({ where: { id: groupId } });
}

async addMember(groupId: string, userId: string, targetUserId: string) {
await this.checkMembership(groupId, userId);
const group = await this.prisma.group.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.memberCount >= group.maxMembers) throw new ForbiddenException('Group is full');

const member = await this.prisma.groupMember.create({
data: { groupId, userId: targetUserId },
});
await this.prisma.group.update({
where: { id: groupId },
data: { memberCount: { increment: 1 } },
});
return member;
}

async removeMember(groupId: string, userId: string, targetUserId: string) {
await this.checkRole(groupId, userId, [GroupRole.CREATOR, GroupRole.ADMIN]);
await this.prisma.groupMember.delete({
where: { groupId_userId: { groupId, userId: targetUserId } },
});
await this.prisma.group.update({
where: { id: groupId },
data: { memberCount: { decrement: 1 } },
});
}

async getMessages(groupId: string, userId: string, page = 1, limit = 50) {
await this.checkMembership(groupId, userId);
const messages = await this.prisma.groupMessage.findMany({
where: { groupId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
});
return messages.reverse();
}

async sendMessage(groupId: string, userId: string, content: string, type = 'TEXT') {
await this.checkMembership(groupId, userId);
return this.prisma.groupMessage.create({
data: { groupId, senderId: userId, content, type: type as any },
});
}

private async checkMembership(groupId: string, userId: string) {
const member = await this.prisma.groupMember.findUnique({
where: { groupId_userId: { groupId, userId } },
});
if (!member) throw new ForbiddenException('Not a group member');
return member;
}

private async checkRole(groupId: string, userId: string, allowedRoles: GroupRole[]) {
const member = await this.checkMembership(groupId, userId);
if (!allowedRoles.includes(member.role)) throw new ForbiddenException('Insufficient permissions');
return member;
}
}
70 changes: 70 additions & 0 deletions frontend/api/groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import apiClient from './client';

export interface GroupMember {
id: number;
userId: number;
username: string;
avatar?: string;
role: 'owner' | 'admin' | 'member';
joinedAt: string;
}

export interface GroupMessage {
id: number;
groupId: number;
userId: number;
username: string;
avatar?: string;
content: string;
createdAt: string;
}

export interface Group {
id: number;
name: string;
avatar?: string;
description?: string;
memberCount: number;
lastMessage?: string;
lastMessageAt?: string;
unreadCount: number;
createdAt: string;
}

export interface PaginatedMessages {
data: GroupMessage[];
total: number;
page: number;
limit: number;
}

export const groupsApi = {
list: () => apiClient.get<Group[]>('/groups'),

create: (data: { name: string; description?: string }) =>
apiClient.post<Group>('/groups', data),

get: (id: number) => apiClient.get<Group>(`/groups/${id}`),

update: (id: number, data: { name?: string; description?: string; avatar?: string }) =>
apiClient.patch<Group>(`/groups/${id}`, data),

delete: (id: number) => apiClient.delete(`/groups/${id}`),

members: (id: number) => apiClient.get<GroupMember[]>(`/groups/${id}/members`),

addMember: (id: number, userId: number) =>
apiClient.post(`/groups/${id}/members`, { userId }),

removeMember: (id: number, userId: number) =>
apiClient.delete(`/groups/${id}/members/${userId}`),

updateMemberRole: (id: number, userId: number, role: 'admin' | 'member') =>
apiClient.patch(`/groups/${id}/members/${userId}`, { role }),

messages: (id: number, page = 1, limit = 20) =>
apiClient.get<PaginatedMessages>(`/groups/${id}/messages`, { params: { page, limit } }),

sendMessage: (id: number, content: string) =>
apiClient.post<GroupMessage>(`/groups/${id}/messages`, { content }),
};
8 changes: 7 additions & 1 deletion frontend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ export enum View {
WeightRecord = 'WEIGHT_RECORD',
TodoList = 'TODO_LIST',
TrainingLog = 'TRAINING_LOG',
CommunityShare = 'COMMUNITY_SHARE'
CommunityShare = 'COMMUNITY_SHARE',
DraftConfirm = 'DRAFT_CONFIRM',
BuddyRecommend = 'BUDDY_RECOMMEND',
GroupList = 'GROUP_LIST',
GroupChat = 'GROUP_CHAT',
GroupSettings = 'GROUP_SETTINGS',
ManualPost = 'MANUAL_POST'
}

export interface UserState {
Expand Down
Loading