Merge: 일정 참석자 알림(지정/수정/삭제)
This commit is contained in:
@@ -23,7 +23,10 @@ export type NotificationType =
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
|
||||
| 'member.removed'; // 프로젝트에서 제외됨
|
||||
| 'member.removed' // 프로젝트에서 제외됨
|
||||
| 'schedule.invited' // 일정 참석자로 지정됨
|
||||
| 'schedule.updated' // 내가 참석한 일정이 수정됨
|
||||
| 'schedule.removed'; // 내가 참석한 일정이 삭제됨
|
||||
|
||||
// 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다.
|
||||
@Entity('notifications')
|
||||
|
||||
@@ -114,17 +114,24 @@ export class ScheduleController {
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateScheduleEventDto,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
return this.scheduleService.createEvent(dto, user.id);
|
||||
return this.scheduleService.createEvent(dto, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('events/:id')
|
||||
@ApiOperation({ summary: '일정 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
updateEvent(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleEventDto,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
return this.scheduleService.updateEvent(id, dto);
|
||||
return this.scheduleService.updateEvent(id, dto, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('events/:id')
|
||||
@@ -132,9 +139,13 @@ export class ScheduleController {
|
||||
@ApiOperation({ summary: '일정 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteEvent(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.scheduleService.deleteEvent(id);
|
||||
await this.scheduleService.deleteEvent(id, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { ScheduleCategory } from './entities/schedule-category.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
@@ -11,6 +12,7 @@ import { ScheduleController } from './schedule.controller';
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ScheduleCategory, ScheduleEvent]),
|
||||
UserModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [ScheduleController],
|
||||
providers: [ScheduleService],
|
||||
|
||||
@@ -3,11 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import type { NotificationType } from '../notification/entities/notification.entity';
|
||||
import { ScheduleCategory } from './entities/schedule-category.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
|
||||
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
|
||||
|
||||
// 알림 행위자(누가 일정을 만들었/수정했/삭제했는지)
|
||||
export interface ScheduleActor {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 카테고리/일정 응답(원시) — 표시 음영색은 프론트가 color 로 파생
|
||||
export interface ScheduleCategoryResponse {
|
||||
id: string;
|
||||
@@ -46,6 +54,7 @@ export class ScheduleService {
|
||||
@InjectRepository(ScheduleEvent)
|
||||
private readonly eventRepo: Repository<ScheduleEvent>,
|
||||
private readonly userService: UserService,
|
||||
private readonly notification: NotificationService,
|
||||
) {}
|
||||
|
||||
// ----- 카테고리 -----
|
||||
@@ -115,7 +124,7 @@ export class ScheduleService {
|
||||
|
||||
async createEvent(
|
||||
dto: CreateScheduleEventDto,
|
||||
actorId: string,
|
||||
actor: ScheduleActor,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
@@ -132,15 +141,27 @@ export class ScheduleService {
|
||||
description: dto.description?.trim() || null,
|
||||
allHands: dto.allHands ?? false,
|
||||
attendees: this.attendeeRefs(dto.allHands, dto.attendeeIds),
|
||||
createdBy: { id: actorId } as User,
|
||||
createdBy: { id: actor.id } as User,
|
||||
});
|
||||
const saved = await this.eventRepo.save(event);
|
||||
|
||||
// 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략)
|
||||
await this.notifyEvent(
|
||||
'schedule.invited',
|
||||
this.idsOf(dto.attendeeIds),
|
||||
actor,
|
||||
{
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
},
|
||||
);
|
||||
return this.findEventOrThrow(saved.id);
|
||||
}
|
||||
|
||||
async updateEvent(
|
||||
id: string,
|
||||
dto: Partial<CreateScheduleEventDto>,
|
||||
actor: ScheduleActor,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
@@ -148,6 +169,9 @@ export class ScheduleService {
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
|
||||
// 수정 전 참석자(알림 대상 비교용)
|
||||
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
@@ -170,12 +194,33 @@ export class ScheduleService {
|
||||
event.attendees = this.attendeeRefs(allHands, dto.attendeeIds);
|
||||
}
|
||||
await this.eventRepo.save(event);
|
||||
|
||||
// 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨
|
||||
const newIds = (event.attendees ?? []).map((a) => a.id);
|
||||
const invited = newIds.filter((i) => !oldIds.has(i));
|
||||
const stillThere = newIds.filter((i) => oldIds.has(i));
|
||||
const payload = { eventTitle: event.title, start: event.startDate };
|
||||
await this.notifyEvent('schedule.invited', invited, actor, payload);
|
||||
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
|
||||
|
||||
return this.findEventOrThrow(id);
|
||||
}
|
||||
|
||||
async deleteEvent(id: string): Promise<void> {
|
||||
const res = await this.eventRepo.delete({ id });
|
||||
if (!res.affected) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
async deleteEvent(id: string, actor: ScheduleActor): Promise<void> {
|
||||
// 삭제 전 참석자(알림 대상)를 먼저 확보
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
const recipients = (event.attendees ?? []).map((a) => a.id);
|
||||
|
||||
await this.eventRepo.delete({ id });
|
||||
|
||||
await this.notifyEvent('schedule.removed', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
// 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용.
|
||||
@@ -196,6 +241,27 @@ export class ScheduleService {
|
||||
return [...new Set(ids ?? [])].map((id) => ({ id }) as User);
|
||||
}
|
||||
|
||||
// 중복 제거된 id 배열
|
||||
private idsOf(ids?: string[]): string[] {
|
||||
return [...new Set(ids ?? [])];
|
||||
}
|
||||
|
||||
// 참석자 알림 발송(행위자=본인은 NotificationService 가 자동 제외). 빈 수신자면 생략.
|
||||
private async notifyEvent(
|
||||
type: NotificationType,
|
||||
recipientIds: string[],
|
||||
actor: ScheduleActor,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (!recipientIds.length) return;
|
||||
await this.notification.notify({
|
||||
recipientIds,
|
||||
actorId: actor.id,
|
||||
type,
|
||||
payload: { actorName: actor.name, ...payload },
|
||||
});
|
||||
}
|
||||
|
||||
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
|
||||
const e = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
|
||||
@@ -27,6 +27,8 @@ function toView(n: ApiNotification): NotificationView {
|
||||
projectId && taskSeq !== null
|
||||
? `/projects/${projectId}/tasks/${taskSeq}`
|
||||
: null
|
||||
// 일정 알림용
|
||||
const eventTitle = escapeHtml(pstr(p, 'eventTitle'))
|
||||
|
||||
let tone = ''
|
||||
let html = '새 알림'
|
||||
@@ -95,6 +97,21 @@ function toView(n: ApiNotification): NotificationView {
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${projectName}</b> 프로젝트에서 회원님을 제외했습니다`
|
||||
break
|
||||
case 'schedule.invited':
|
||||
tone = 'accent'
|
||||
to = '/schedule'
|
||||
html = `<b>${actor}</b>님이 <b>${eventTitle}</b> 일정에 회원님을 참석자로 지정했습니다`
|
||||
break
|
||||
case 'schedule.updated':
|
||||
tone = 'blue'
|
||||
to = '/schedule'
|
||||
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 수정했습니다`
|
||||
break
|
||||
case 'schedule.removed':
|
||||
tone = 'red'
|
||||
to = '/schedule'
|
||||
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 삭제했습니다`
|
||||
break
|
||||
}
|
||||
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export type NotificationType =
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
| 'schedule.invited'
|
||||
| 'schedule.updated'
|
||||
| 'schedule.removed'
|
||||
|
||||
// 알림(API 원시) — 표시 문구/링크는 프론트가 type+payload 로 조립
|
||||
export interface ApiNotification {
|
||||
|
||||
Reference in New Issue
Block a user