-
Notifications
You must be signed in to change notification settings - Fork 3
웹/앱 푸시 알림 기능 추가 #302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
웹/앱 푸시 알림 기능 추가 #302
Changes from all commits
0baf38e
cda15c6
7df9311
1b587c3
a056ad9
4ccf4e4
953ab3b
07ed531
1fee6b7
8ddc268
1081210
3c9ce4e
512733a
f3be588
ddb6390
5c66aeb
5af744c
22992e7
4644c70
4562a09
722c612
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(), | ||
| "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"`, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,6 +52,7 @@ | |
| "class-validator": "^0.14.3", | ||
| "cookie-parser": "^1.4.7", | ||
| "dotenv": "^17.2.3", | ||
| "firebase-admin": "^14.1.0", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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:
💡 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.mdRepository: boostcampwm2025/web25-ittda Length of output: 10805 Keep 🤖 Prompt for AI Agents |
||
| "logform": "^2.7.0", | ||
| "luxon": "^3.7.2", | ||
| "nest-winston": "^1.10.2", | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Use a validation DTO for the
🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @UseGuards(GroupRoleGuard) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @GroupRoles(GroupRoleEnum.VIEWER) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Patch(':groupId/members/me') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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'; | ||
|
|
@@ -11,6 +11,7 @@ import { | |
| GroupActivityItemDto, | ||
| PaginatedGroupActivityResponseDto, | ||
| } from '../dto/group-activity.dto'; | ||
| import { NotificationService } from '@/modules/notification/notification.service'; | ||
|
|
||
| type RecordActivityInput = { | ||
| groupId: string; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Guard against
🛡️ Proposed fix: add a fallback body: `그룹 이름이 "${meta?.afterName as string}"(으)로 변경되었습니다.`,
+ // Replace with:
+ body: `그룹 이름이 "${(meta?.afterName as string) ?? '알 수 없음'}"(으)로 변경되었습니다.`,🤖 Prompt for AI Agents |
||
| 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> { | ||
|
|
@@ -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( | ||
|
|
||
There was a problem hiding this comment.
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:
Repository: boostcampwm2025/web25-ittda
Length of output: 165
🏁 Script executed:
Repository: boostcampwm2025/web25-ittda
Length of output: 1953
🏁 Script executed:
Repository: boostcampwm2025/web25-ittda
Length of output: 20019
Create
uuid-osspbefore callinguuid_generate_v4().backend/migrations/1767732378313-init-schema.tsonly enablespgcrypto, so this migration will fail on a clean database unlessuuid-osspis added here or the id default is switched togen_random_uuid().🤖 Prompt for AI Agents