Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0baf38e
chore: Firebase FCM 푸시 알림을 위한 패키지 설치 및 환경변수 설정(#287)
H-sooyeon Jul 6, 2026
cda15c6
feat: FCM 토큰 등록 및 푸시 알림 발송 백엔드 모듈 구현(#287)
H-sooyeon Jul 6, 2026
7df9311
feat: 그룹 활동 기록 시 FCM 푸시 알림 발송(#287)
H-sooyeon Jul 6, 2026
1b587c3
feat: PWA FCM 푸시 알림 수신 구현(#287)
H-sooyeon Jul 6, 2026
a056ad9
feat: android capacitor FCM 푸시 알림 토큰 등록 추가(#287)
H-sooyeon Jul 6, 2026
4ccf4e4
chore: capacitor/push-notifications 플러그인 등록(#287)
H-sooyeon Jul 6, 2026
953ab3b
fix: 푸시 알림 백엔드 버그 수정 및 DB 스키마 보완(#287)
H-sooyeon Jul 7, 2026
07ed531
fix: 서버사이드 렌더링시 API URL 이중 /v1 버그 수정(#287)
H-sooyeon Jul 7, 2026
1fee6b7
feat: 푸시 알림 권한 요청 UX 추가(#287)
H-sooyeon Jul 7, 2026
8ddc268
fix: FCM 발송 실패시 만료 토큰 자동 삭제(#287)
H-sooyeon Jul 7, 2026
1081210
feat: 그룹별 알림 수신 토글 기능 추가(#287)
H-sooyeon Jul 7, 2026
3c9ce4e
feat: 그룹별 알림 기능 및 unread 기능 추가(#287)
H-sooyeon Jul 7, 2026
512733a
refactor: 안드로이드 capacitor에서는 알림 설정시 시스템 알림 설정 창을 열어주도록 개선(#287)
H-sooyeon Jul 7, 2026
f3be588
feat: 그룹 활동 알림에 groupId/postId 데이터 추가(#287)
H-sooyeon Jul 7, 2026
ddb6390
feat: 푸시 알림 클릭시 해당 그룹/기록 페이지로 딥링크(#287)
H-sooyeon Jul 7, 2026
5c66aeb
fix: 알림 딥링크 진입시 record 페이지 스켈레톤 깜빡임 개선(#287)
H-sooyeon Jul 7, 2026
5af744c
fix: FCM 푸시 알림 sw unregister 후 재등록 및 알림 클릭 내비게이션 버그 수정(#287)
H-sooyeon Jul 7, 2026
22992e7
fix: 기록 상세 페이지 이동시 스켈레톤 플래시 및 하이드레이션 에러 수정(#287)
H-sooyeon Jul 7, 2026
4644c70
refactor: 기록 상세페이지 ssr로 인해 헤더 로드 후 레이아웃이 변경되는 문제 수정(#287)
H-sooyeon Jul 7, 2026
4562a09
refactor: firebase 공식문서에 맞춰 FCM 토큰 관련 메소드 마이그레이션(#287)
H-sooyeon Jul 8, 2026
722c612
fix: /api -> '' 치환해 잘못된 경로로 API 요청이 가던 것을 /api -> /v1으로 치환호(#287)
H-sooyeon Jul 8, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ node_modules
# env files (can opt-in for committing if needed)
.env*
!.env.example

# Firebase Admin SDK 서비스 계정 키 (절대 커밋 금지)
*firebase-adminsdk*.json
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,8 @@ AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED # B2는 WHEN_REQUIRED로 설정
ADMIN_KEY="random string for admin notice"
# backend/.env.docker.example
# be, fe를 도커 컨테이너로 띄울 때는 서비스명으로 설정합니다.

# =================
# Firebase Admin SDK (FCM 푸시 알림 발송)
# =================
FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-adminsdk.json
29 changes: 29 additions & 0 deletions backend/migrations/1783344174180-CreateFcmToken-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateFcmTokenMigration1783344174180 implements MigrationInterface {
name = 'CreateFcmTokenMigration1783344174180';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "fcm_tokens" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for existing uuid_generate_v4 usage or extension enablement in the codebase
rg -n "uuid_generate_v4\|uuid-ossp\|uuid-ossp" --type sql
rg -n "uuid_generate_v4" backend/migrations/

Repository: boostcampwm2025/web25-ittda

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== migration file =="
git ls-files 'backend/migrations/*CreateFcmToken*' 'backend/migrations/*.ts' | sed -n '1,20p'
echo

mig="$(git ls-files 'backend/migrations/*CreateFcmToken*' | head -n 1)"
if [ -n "${mig:-}" ]; then
  echo "== outline: $mig =="
  ast-grep outline "$mig" --view expanded || true
  echo
  echo "== file excerpt: $mig =="
  nl -ba "$mig" | sed -n '1,140p'
fi
echo

echo "== search for uuid-ossp / uuid_generate_v4 / extension creation =="
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'uuid_generate_v4|uuid-ossp|CREATE EXTENSION|uuid_generate_v1|gen_random_uuid' \
  .

Repository: boostcampwm2025/web25-ittda

Length of output: 1953


🏁 Script executed:

#!/bin/bash
set -euo pipefail

mig='backend/migrations/1783344174180-CreateFcmToken-migration.ts'

echo "== file excerpt =="
sed -n '1,120p' "$mig"
echo

echo "== search for extension setup in all migrations =="
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  'CREATE EXTENSION|uuid-ossp|uuid_generate_v4|gen_random_uuid' \
  backend/migrations backend

Repository: boostcampwm2025/web25-ittda

Length of output: 20019


Create uuid-ossp before calling uuid_generate_v4(). backend/migrations/1767732378313-init-schema.ts only enables pgcrypto, so this migration will fail on a clean database unless uuid-ossp is added here or the id default is switched to gen_random_uuid().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/migrations/1783344174180-CreateFcmToken-migration.ts` around lines 8
- 9, Create the uuid-ossp extension in the migration before the fcm_tokens table
definition invokes uuid_generate_v4(), or change the id default to the
already-enabled gen_random_uuid() function; update the corresponding migration
rollback if needed to keep extension handling consistent.

"user_id" uuid NOT NULL,
"token" text NOT NULL,
"platform" character varying(10) NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_fcm_tokens" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fcm_tokens_user_id_platform" ON "fcm_tokens" ("user_id", "platform")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_fcm_tokens_user_id_platform"`,
);
await queryRunner.query(`DROP TABLE "fcm_tokens"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddFcmTokenUniqueConstraint1783348447838 implements MigrationInterface {
name = 'AddFcmTokenUniqueConstraint1783348447838';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "public"."IDX_fcm_tokens_user_id_platform"`,
);
await queryRunner.query(
`ALTER TABLE "fcm_tokens" ADD CONSTRAINT "UQ_fcm_tokens_user_id_platform" UNIQUE ("user_id", "platform")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "fcm_tokens" DROP CONSTRAINT "UQ_fcm_tokens_user_id_platform"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fcm_tokens_user_id_platform" ON "fcm_tokens" ("user_id", "platform")`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddGroupMemberNotificationMuted1783398050652 implements MigrationInterface {
name = 'AddGroupMemberNotificationMuted1783398050652';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "group_members" ADD COLUMN IF NOT EXISTS "notification_muted" BOOLEAN NOT NULL DEFAULT FALSE`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "group_members" DROP COLUMN IF EXISTS "notification_muted"`,
);
}
}
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.2.3",
"firebase-admin": "^14.1.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the latest stable version of the firebase-admin npm package?

💡 Result:

The latest stable version of the firebase-admin npm package as of July 13, 2026, is 14.1.0, which was published on June 24, 2026 [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the package metadata around firebase-admin and Node engine requirements.
file="backend/package.json"
wc -l "$file"
sed -n '1,140p' "$file"

Repository: boostcampwm2025/web25-ittda

Length of output: 6317


🌐 Web query:

firebase-admin 14.1.0 Node.js engine requirement release notes

💡 Result:

The Firebase Admin Node.js SDK version 14.1.0, released on June 24, 2026, officially requires Node.js 22 or higher [1][2][3]. This version includes a shift in support, as the SDK has dropped compatibility for Node.js 18 and 20 [4]. Developers are advised to use environments running Node.js 22 and above when deploying this version of the Admin SDK [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any declared Node.js runtime/engine target in the repository.
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  -e '"engines"\s*:' \
  -e 'NODE_VERSION' \
  -e 'node\s*[0-9]+\b' \
  -e '22(\.x)?' \
  -e '20(\.x)?' \
  -e '18(\.x)?' \
  -e 'FROM\s+node:' \
  -e 'runtime.*node' \
  .

Repository: boostcampwm2025/web25-ittda

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look only at backend/runtime-related files for a declared Node target.
printf '\n[backend files]\n'
git ls-files backend | sed -n '1,200p'

printf '\n[Node target mentions in backend + deployment files]\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  -e '"engines"\s*:' \
  -e 'node-version' \
  -e 'FROM\s+node:' \
  -e 'NODE_VERSION' \
  -e 'nvmrc' \
  backend .github docker-compose.yml Dockerfile* README.md

Repository: boostcampwm2025/web25-ittda

Length of output: 10805


Keep firebase-admin on a Node 20-compatible major
firebase-admin 14.1.0 requires Node.js 22+, but backend/Dockerfile still uses node:20-slim. Either bump the backend runtime to Node 22+ or stay on a 13.x release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/package.json` at line 55, Align the firebase-admin dependency with
the runtime defined by the backend Dockerfile: either update the Dockerfile’s
Node image to Node 22+ or change the firebase-admin entry in package.json to a
compatible 13.x release. Keep the selected dependency and runtime versions
compatible.

"logform": "^2.7.0",
"luxon": "^3.7.2",
"nest-winston": "^1.10.2",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { MediaModule } from './modules/media/media.module';
import { TrashModule } from './modules/trash/trash.module';
import { AnnouncementModule } from './modules/announcement/announcement.module';
import { InquiryModule } from './modules/inquiry/inquiry.module';
import { NotificationModule } from './modules/notification/notification.module';

@Module({
imports: [
Expand Down Expand Up @@ -51,6 +52,7 @@ import { InquiryModule } from './modules/inquiry/inquiry.module';
TrashModule,
AnnouncementModule,
InquiryModule,
NotificationModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,38 @@ export class GroupManagementController {
return this.groupManagementService.getGroupPermission(user.sub, groupId);
}

@Patch(':groupId/members/me/read')
@ApiOperation({
summary: '그룹 읽음 처리',
description: '그룹 페이지 진입 시 lastReadAt을 현재 시간으로 갱신합니다.',
})
@ApiParam({ name: 'groupId', description: '그룹 ID' })
async markGroupAsRead(
@User() user: MyJwtPayload,
@Param('groupId') groupId: string,
): Promise<void> {
await this.groupManagementService.markGroupAsRead(user.sub, groupId);
}

@Patch(':groupId/members/me/notification')
@ApiOperation({
summary: '그룹 알림 설정 토글',
description:
'권한에 관계없이 모든 멤버가 해당 그룹의 알림을 켜고 끌 수 있습니다.',
})
@ApiParam({ name: 'groupId', description: '그룹 ID' })
async toggleGroupNotification(
@User() user: MyJwtPayload,
@Param('groupId') groupId: string,
@Body('muted') muted: boolean,
): Promise<void> {
await this.groupManagementService.toggleGroupNotification(
user.sub,
groupId,
muted,
);
}
Comment on lines +205 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a validation DTO for the muted body field.

@Body('muted') muted: boolean bypasses NestJS validation entirely — there is no class for the ValidationPipe to validate against, so muted can be undefined, a string, or any arbitrary value. If undefined is passed to the service, it could produce a database error when assigning to the non-nullable notificationMuted column. Additionally, the endpoint lacks @ApiBody for Swagger documentation.

🛡️ Proposed fix: create a DTO and apply it
+// In a new or existing DTO file, e.g. toggle-group-notification.dto.ts
+import { IsBoolean } from 'class-validator';
+
+export class ToggleGroupNotificationDto {
+  `@IsBoolean`()
+  muted!: boolean;
+}

Then update the controller:

-  `@Patch`(':groupId/members/me/notification')
+  `@Patch`(':groupId/members/me/notification')
   `@ApiOperation`({
     summary: '그룹 알림 설정 토글',
     description:
       '권한에 관계없이 모든 멤버가 해당 그룹의 알림을 켜고 끌 수 있습니다.',
   })
   `@ApiParam`({ name: 'groupId', description: '그룹 ID' })
+  `@ApiBody`({ type: ToggleGroupNotificationDto })
   async toggleGroupNotification(
     `@User`() user: MyJwtPayload,
     `@Param`('groupId') groupId: string,
-    `@Body`('muted') muted: boolean,
+    `@Body`() dto: ToggleGroupNotificationDto,
   ): Promise<void> {
     await this.groupManagementService.toggleGroupNotification(
       user.sub,
       groupId,
-      muted,
+      dto.muted,
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Patch(':groupId/members/me/notification')
@ApiOperation({
summary: '그룹 알림 설정 토글',
description:
'권한에 관계없이 모든 멤버가 해당 그룹의 알림을 켜고 끌 수 있습니다.',
})
@ApiParam({ name: 'groupId', description: '그룹 ID' })
async toggleGroupNotification(
@User() user: MyJwtPayload,
@Param('groupId') groupId: string,
@Body('muted') muted: boolean,
): Promise<void> {
await this.groupManagementService.toggleGroupNotification(
user.sub,
groupId,
muted,
);
}
`@Patch`(':groupId/members/me/notification')
`@ApiOperation`({
summary: '그룹 알림 설정 토글',
description:
'권한에 관계없이 모든 멤버가 해당 그룹의 알림을 켜고 끌 수 있습니다.',
})
`@ApiParam`({ name: 'groupId', description: '그룹 ID' })
`@ApiBody`({ type: ToggleGroupNotificationDto })
async toggleGroupNotification(
`@User`() user: MyJwtPayload,
`@Param`('groupId') groupId: string,
`@Body`() dto: ToggleGroupNotificationDto,
): Promise<void> {
await this.groupManagementService.toggleGroupNotification(
user.sub,
groupId,
dto.muted,
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/modules/group/controller/group-management.controller.ts` around
lines 205 - 222, Replace the primitive `@Body`('muted') parameter in
toggleGroupNotification with a validation DTO containing a required boolean
muted field, and pass that DTO value to
groupManagementService.toggleGroupNotification. Add the corresponding `@ApiBody`
metadata so the request schema and required field are documented in Swagger.


@UseGuards(GroupRoleGuard)
@GroupRoles(GroupRoleEnum.VIEWER)
@Patch(':groupId/members/me')
Expand Down
5 changes: 4 additions & 1 deletion backend/src/modules/group/dto/get-group-permission.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ import { GroupRoleEnum } from '@/enums/group-role.enum';

export class GetGroupPermissionResponseDto {
@ApiProperty({ enum: GroupRoleEnum })
role: GroupRoleEnum;
role!: GroupRoleEnum;

@ApiProperty()
notificationMuted!: boolean;
}
6 changes: 6 additions & 0 deletions backend/src/modules/group/dto/get-groups.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export class GroupItemDto {

@ApiProperty({ description: '내 권한', enum: GroupRoleEnum, nullable: true })
permission: GroupRoleEnum | null;

@ApiProperty({ description: '알림 음소거 여부' })
notificationMuted: boolean;

@ApiProperty({ description: '읽지 않은 활동 여부' })
hasUnread: boolean;
}

export class GetGroupsResponseDto {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/modules/group/entity/group_member.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export class GroupMember {
@JoinColumn({ name: 'profile_media_id' })
profileMedia?: MediaAsset | null;

@Column({ name: 'notification_muted', type: 'boolean', default: false })
notificationMuted: boolean;

@Column({ name: 'last_read_at', type: 'timestamptz', nullable: true })
lastReadAt?: Date | null;

Expand Down
2 changes: 2 additions & 0 deletions backend/src/modules/group/group.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { GroupActivityActor } from './entity/group-activity-actor.entity';
import { AuthModule } from '../auth/auth.module';
import { PostModule } from '../post/post.module';
import { MediaModule } from '../media/media.module';
import { NotificationModule } from '../notification/notification.module';

@Module({
imports: [
Expand All @@ -49,6 +50,7 @@ import { MediaModule } from '../media/media.module';
AuthModule,
forwardRef(() => PostModule),
MediaModule,
NotificationModule,
],
providers: [
GroupService,
Expand Down
116 changes: 114 additions & 2 deletions backend/src/modules/group/service/group-activity.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository, SelectQueryBuilder, Brackets } from 'typeorm';
import { In, Not, Repository, SelectQueryBuilder, Brackets } from 'typeorm';

import { GroupActivityLog } from '../entity/group-activity-log.entity';
import { GroupActivityActor } from '../entity/group-activity-actor.entity';
Expand All @@ -11,6 +11,7 @@ import {
GroupActivityItemDto,
PaginatedGroupActivityResponseDto,
} from '../dto/group-activity.dto';
import { NotificationService } from '@/modules/notification/notification.service';

type RecordActivityInput = {
groupId: string;
Expand All @@ -20,15 +21,69 @@ type RecordActivityInput = {
meta?: Record<string, unknown> | null;
};

function buildActivityNotification(
actorName: string,
type: GroupActivityType,
meta?: Record<string, unknown> | null,
): { title: string; body: string } | null {
const postTitle = ((meta?.title ?? meta?.afterTitle ?? '') as string) || '';
const titlePart = postTitle ? ` "${postTitle}"` : '';

switch (type) {
case GroupActivityType.POST_CREATE:
return {
title: '새 기록',
body: `${actorName}님이 새 기록${titlePart}을(를) 작성했습니다.`,
};
case GroupActivityType.POST_COLLAB_COMPLETE:
case GroupActivityType.POST_EDIT_COMPLETE:
case GroupActivityType.POST_UPDATE:
return {
title: '기록 수정',
body: `${actorName}님이${titlePart} 기록을 수정했습니다.`,
};
case GroupActivityType.POST_DELETE:
return {
title: '기록 삭제',
body: `${actorName}님이${titlePart} 기록을 삭제했습니다.`,
};
case GroupActivityType.MEMBER_JOIN:
return {
title: '새 멤버',
body: `${actorName}님이 그룹에 참여했습니다.`,
};
case GroupActivityType.MEMBER_LEAVE:
return {
title: '멤버 탈퇴',
body: `${actorName}님이 그룹에서 나갔습니다.`,
};
case GroupActivityType.MEMBER_REMOVE:
return {
title: '멤버 내보내기',
body: '그룹에서 멤버가 내보내졌습니다.',
};
case GroupActivityType.GROUP_NAME_UPDATE:
return {
title: '그룹 이름 변경',
body: `그룹 이름이 "${meta?.afterName as string}"(으)로 변경되었습니다.`,
};
Comment on lines +65 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against undefined in the group name change notification body.

meta?.afterName as string produces undefined at runtime if meta is null or afterName is missing, resulting in a notification body like 그룹 이름이 "undefined"(으)로 변경되었습니다. While the current caller always provides afterName, this is fragile if new callers are added.

🛡️ Proposed fix: add a fallback
     body: `그룹 이름이 "${meta?.afterName as string}"(으)로 변경되었습니다.`,
+    // Replace with:
+    body: `그룹 이름이 "${(meta?.afterName as string) ?? '알 수 없음'}"(으)로 변경되었습니다.`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/modules/group/service/group-activity.service.ts` around lines 65
- 69, Update the GROUP_NAME_UPDATE branch in the group activity notification
builder to provide a safe fallback when meta?.afterName is absent, preventing
"undefined" from appearing in the body while preserving the existing message for
valid names.

default:
return null;
}
}

@Injectable()
export class GroupActivityService {
private readonly logger = new Logger(GroupActivityService.name);

constructor(
@InjectRepository(GroupActivityLog)
private readonly logRepo: Repository<GroupActivityLog>,
@InjectRepository(GroupActivityActor)
private readonly actorRepo: Repository<GroupActivityActor>,
@InjectRepository(GroupMember)
private readonly groupMemberRepo: Repository<GroupMember>,
private readonly notificationService: NotificationService,
) {}

async recordActivity(input: RecordActivityInput): Promise<void> {
Expand Down Expand Up @@ -58,6 +113,63 @@ export class GroupActivityService {
await actorRepo.save(actors);
}
});

void this.dispatchNotification(input, actorIds).catch((e) =>
this.logger.warn('푸시 알림 전송 실패', e),
);
}

private async dispatchNotification(
input: RecordActivityInput,
actorIds: string[],
): Promise<void> {
const actorMembers =
actorIds.length > 0
? await this.groupMemberRepo.find({
where: { groupId: input.groupId, userId: In(actorIds) },
relations: { user: true },
})
: [];

const first = actorMembers[0];
const baseName = first?.nicknameInGroup ?? first?.user?.nickname ?? '멤버';
const extraCount = actorIds.length - 1;
const actorDisplayName =
extraCount > 0 ? `${baseName} 외 ${extraCount}명` : baseName;

const notification = buildActivityNotification(
actorDisplayName,
input.type,
input.meta,
);
if (!notification) return;

const recipients = await this.groupMemberRepo.find({
where:
actorIds.length > 0
? {
groupId: input.groupId,
userId: Not(In(actorIds)),
notificationMuted: false,
}
: { groupId: input.groupId, notificationMuted: false },
select: ['userId'],
});

const recipientIds = recipients.map((m) => m.userId);
if (recipientIds.length === 0) return;

const hasPost = input.refId && input.type !== GroupActivityType.POST_DELETE;

await this.notificationService.sendToUsers(
recipientIds,
notification.title,
notification.body,
{
groupId: input.groupId,
...(hasPost ? { postId: input.refId as string } : {}),
},
);
}

async getGroupActivities(
Expand Down
31 changes: 29 additions & 2 deletions backend/src/modules/group/service/group-management.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,9 +598,36 @@ export class GroupManagementService {
groupId: string,
): Promise<GetGroupPermissionResponseDto> {
const member = await this.groupService.ensureMember(userId, groupId, {
select: { role: true },
select: { role: true, notificationMuted: true },
});
return { role: member.role };
return {
role: member.role,
notificationMuted: Boolean(member.notificationMuted),
};
}

async toggleGroupNotification(
userId: string,
groupId: string,
muted: boolean,
): Promise<void> {
await this.groupService.ensureMember(userId, groupId, {
select: { id: true },
});
await this.groupMemberRepo.update(
{ userId, groupId },
{ notificationMuted: muted },
);
}

async markGroupAsRead(userId: string, groupId: string): Promise<void> {
await this.groupService.ensureMember(userId, groupId, {
select: { id: true },
});
await this.groupMemberRepo.update(
{ userId, groupId },
{ lastReadAt: new Date() },
);
}

/** 그룹 내 내 설정 정보 수정 */
Expand Down
Loading