@ahhaohho/event-publisher 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # @ahhaohho/event-publisher
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 서비스가 도메인 이벤트("무슨 일이 있었다")를 EventBridge 버스로 발행하는 SDK. 이벤트 이름과 payload 스키마(Zod) 카탈로그가 이 패키지에 있어서 발행자와 소비자(notification-server 캠페인 엔진 등)가 같은 계약을 쓴다.
4
+
5
+ - 설계: 이벤트 기반 범용 메시징 서비스 설계 (Trello 상위 카드 `achs1sX1`, 이 패키지 카드 `QLvlv7As`)
6
+ - 런타임: Node 20+, CommonJS, `zod@^4`
7
+
8
+ ## 발행
9
+
10
+ ```ts
11
+ import { createEventPublisher } from '@ahhaohho/event-publisher';
12
+
13
+ // 서버 기동 시 1회
14
+ export const events = createEventPublisher({ source: 'ao-crew-server' });
15
+ void events.verifyBus(); // 버스 이름이 틀리면 error 로그 (아래 ⚠️ 참고)
16
+
17
+ // 사건 지점에서 — 기다리지 않고 부른다
18
+ void events.publish('crew.featured.selected', {
19
+ galleryId,
20
+ authorUserId,
21
+ postType: 'challenge',
22
+ challengeId,
23
+ result: 'created',
24
+ });
25
+ ```
26
+
27
+ `publish(name, payload, options?)`
28
+
29
+ - envelope(`eventId`, `occurredAt`, `source`, `version`, `actorId`, `subjectUserIds`)를 자동으로 채운다. `actorId`·`subjectUserIds`는 카탈로그가 payload에서 꺼내고, `options`로 덮어쓸 수 있다.
30
+ - payload를 Zod로 검증하고, **스키마에 없는 필드는 버린다**(PII가 실수로 버스에 실리지 않게).
31
+ - 보내기 직전에 envelope 전체를 소비자와 같은 스키마로 다시 검증한다. 옵션으로 덮어쓴 `eventId`(영숫자·`-_.:` 1~128자), `actorId`·`subjectUserIds`(ObjectId)가 틀리면 보내지 않는다 — "발행은 성공, 소비자에서 버려짐"을 막는다.
32
+ - **절대 reject하지 않는다.** 결과는 `{ ok: true, eventId, attempts, skipped? }` 또는 `{ ok: false, reason, error }`로 돌려준다.
33
+ - `reason`: `invalid_payload` | `invalid_envelope` | `unknown_event` | `too_large`(256KB 초과) | `publish_failed`
34
+ - 실패하면 같은 `eventId`로 지수 백오프 재시도(기본 3회, full jitter: 0~min(2s, 200ms·2ⁿ⁻¹)). 권한·설정 오류(AccessDenied, ResourceNotFound 등)는 재시도하지 않는다.
35
+ - 로그에는 이벤트 이름·eventId·오류만 남기고 payload 값은 남기지 않는다.
36
+ - outbox(DB 트랜잭션과 묶은 보장 발행)는 없다. 중요 이벤트에 한해 후속 작업으로 붙인다.
37
+
38
+ ### actorId 규칙
39
+
40
+ `actorId`에는 **JWT로 검증한 사용자 id만** 넣는다. body·query·`user-id` 헤더 값을 쓰지 않는다. 어드민 id를 서비스까지 전달하지 않는 지점(피처드 지정, 어드민 숨김 등)은 `actorId: null`이 된다.
41
+
42
+ ### 설정
43
+
44
+ | 옵션 | 환경변수 | 기본값 |
45
+ |------|----------|--------|
46
+ | `busName` | `EVENT_BUS_NAME` | 없음 → 발행 꺼짐 |
47
+ | `enabled` | `EVENT_PUBLISHER_ENABLED` (`'false'`면 끔) | busName이 있으면 켜짐 |
48
+ | `region` | `AWS_REGION` | `ap-northeast-2` |
49
+ | `credentials` | `EVENT_PUBLISHER_CREDENTIALS` (`default`면 SDK 기본 체인) | `'instance'` — EC2 인스턴스 역할(IMDS) 명시 |
50
+ | `client` | — | 위 자격증명으로 생성 |
51
+ | `logger` | — | console |
52
+ | `retry` | — | `{ maxAttempts: 3, baseDelayMs: 200, maxDelayMs: 2000, timeoutMs: 5000 }` (시도당 제한 시간 — 넘으면 실패로 보고 재시도) |
53
+
54
+ 꺼져 있으면 payload 검증만 하고 `{ ok: true, skipped: 'disabled' }`를 돌려준다. 버스가 만들어지기 전에 코드를 먼저 배포해도 된다. 단, `EVENT_PUBLISHER_ENABLED=false`로 명시적으로 끈 경우가 아니면 `EVENT_BUS_NAME` 누락은 생성 시 warn 로그로 남는다(env 누락으로 조용히 꺼지는 것 방지).
55
+
56
+ > ⚠️ **EventBridge는 없는 버스로의 PutEvents도 성공으로 응답한다** (2026-11-09 실측). `EVENT_BUS_NAME`이 틀리면 이벤트가 조용히 사라진다. 서버 기동 시 `verifyBus()`를 호출해 로그로 확인할 것.
57
+
58
+ ### 자격증명 — 기본 체인을 쓰지 않는다
59
+
60
+ 메시징 권한(`ahhaohho-messaging-{env}-app` 정책)은 인스턴스 역할 `ssm-ec2-role`에만 붙어 있다. 서버 `.env`에는 `AWS_PROFILE=sms-sender`(notification-server staging/prod)나 S3용 `AWS_ACCESS_KEY_ID`(gallery 등)가 있어서, SDK 기본 체인을 쓰면 그 키로 발행하다 prod에서 **AccessDenied**가 난다. dev에는 프로필이 없어 역할로 떨어지므로 dev 테스트로는 잡히지 않는다. 그래서 기본값을 `fromInstanceMetadata()`로 명시한다(4PSMbfSQ 결정, 2026-11-09). EC2 밖 로컬 개발에서는 `EVENT_PUBLISHER_CREDENTIALS=default`를 쓴다.
61
+
62
+ ## 소비
63
+
64
+ ```ts
65
+ import { parseEvent } from '@ahhaohho/event-publisher';
66
+
67
+ const res = parseEvent(sqsMessage.Body); // EventBridge→SQS 본문, 또는 envelope
68
+ if (!res.ok) {
69
+ // 어떤 실패든 삭제하지 말고 DLQ로 보존한다.
70
+ // unknown_event | unsupported_version → 발행자가 더 새 SDK. 소비자 SDK를 올린 뒤 DLQ redrive
71
+ return;
72
+ }
73
+ switch (res.event.name) {
74
+ case 'crew.featured.selected':
75
+ res.event.payload.authorUserId; // 타입이 좁혀진다
76
+ break;
77
+ }
78
+ ```
79
+
80
+ 중복 처리는 `eventId`로 막는다(EventBridge·SQS는 최소 1회 전달).
81
+
82
+ ## EventBridge 매핑 (IaC 규칙 작성용)
83
+
84
+ | EventBridge 필드 | 값 |
85
+ |------------------|-----|
86
+ | `EventBusName` | `EVENT_BUS_NAME` |
87
+ | `Source` | `ahhaohho.{source}` (예: `ahhaohho.ao-crew-server`) |
88
+ | `DetailType` | 이벤트 이름 (예: `crew.featured.selected`) |
89
+ | `Time` | `occurredAt` |
90
+ | `Detail` | envelope JSON 전체 |
91
+
92
+ 규칙 패턴 예: `{ "source": [{ "prefix": "ahhaohho." }], "detail-type": [{ "prefix": "crew." }] }`
93
+
94
+ 발행 서버 IAM 권한(인스턴스 역할): `events:PutEvents`, `events:DescribeEventBus` (버스 ARN 한정). DescribeEventBus가 없으면 `verifyBus()`가 AccessDenied로 false를 돌려준다.
95
+
96
+ 버스 이름(env `EVENT_BUS_NAME`)은 스택 `ahhaohho-messaging-{env}`의 output `EventBusName`이다.
97
+
98
+ ## Envelope
99
+
100
+ ```json
101
+ {
102
+ "eventId": "중복 방지 키 — publish는 UUID v4, Scheduler는 execution-id (영숫자·-_.: 1~128자)",
103
+ "name": "gallery.post.created",
104
+ "version": 1,
105
+ "source": "ao-gallery-server",
106
+ "occurredAt": "2026-11-05T09:00:00.000Z",
107
+ "actorId": "행동한 사용자 (JWT 검증값) | null",
108
+ "subjectUserIds": ["영향받는 사용자"],
109
+ "payload": {}
110
+ }
111
+ ```
112
+
113
+ 대상이 대량인 이벤트(`community.notice.published`)는 `subjectUserIds`를 비우고 소비자가 payload의 그룹 id로 수신자를 정한다.
114
+
115
+ ## 카탈로그
116
+
117
+ payload에는 id와 작은 사실값만 싣는다. 닉네임·댓글 본문·썸네일 같은 표시용 값은 소비자가 렌더 시점에 조회한다(PII·크기·최신성).
118
+
119
+ | 이벤트 | 발행 | 현재 호출 지점 | actor | subjects |
120
+ |--------|------|----------------|-------|----------|
121
+ | `gallery.post.created` | gallery | addGallerys.ts (보상·community webhook) | 작성자 | 작성자 |
122
+ | `gallery.reaction.added` | gallery | galleryReaction.ts | 리액션한 사람 | 게시자 |
123
+ | `gallery.post.hidden` | gallery | adminGallery.service.ts | 어드민(있으면) | 작성자 |
124
+ | `gallery.post.deleted` | gallery | adminGallery.service.ts | 어드민(있으면) | 작성자 |
125
+ | `community.comment.created` | community | galleryReply / galleryComment controller | 댓글 작성자 | 게시자 |
126
+ | `community.comment.liked` | community | galleryReply.controller.ts | 좋아요한 사람 | 댓글 작성자 |
127
+ | `community.reaction.added` | community | reaction.controller.ts | 리액션한 사람 | 게시자 |
128
+ | `community.notice.published` | community | announcement.controller.ts | 작성 관리자(있으면) | (비움 — communityId로 해석) |
129
+ | `community.message.sent` | community | message.controller.ts | 보낸 관리자(있으면) | 수신자 목록 (≤4000, 넘으면 나눠 발행) |
130
+ | `crew.featured.selected` | crew | featured.service.ts | 어드민(있으면) | 작성자 |
131
+ | `reward.level.up` | reward | grant.service.ts | 본인 | 본인 |
132
+ | `membership.consent.approved` | membership | completeParentConsent.ts | null (보호자) | 아이 계정 |
133
+ | `membership.profile.created` | membership | createUserProfileService.ts | 본인 | 본인 |
134
+ | `ai.comment.created` | ai-chatbot | comment.service.ts | 봇 계정 | 게시자 |
135
+ | `app.activity.challenge_viewed` | challenge | 챌린지 상세 API | 본인 | 본인 |
136
+ | `app.activity.gallery_post_viewed` | gallery | 갤러리 상세 API | 본인 | 본인 |
137
+ | `app.activity.post_step_viewed` | (POST /activity 수신 서버) | 앱 신호 | 본인 | 본인 |
138
+ | `campaign.schedule.fired` | notification (Scheduler) | — | null | (비움) |
139
+
140
+ 정확한 필드는 `src/catalog/*.ts` 참조.
141
+
142
+ ### 카탈로그 변경 규칙
143
+
144
+ - optional 필드 추가는 같은 이벤트·같은 `version`에서 한다.
145
+ - 필드 삭제·이름 변경·타입 변경·required 추가처럼 호환 안 되는 변경은 `version`을 올리지 않고 **새 이벤트 이름**(예: `gallery.post.created_v2`)으로 추가한다.
146
+ 1. 새 이름을 카탈로그에 추가하고, 소비자가 구·신 이름을 모두 받도록 먼저 배포
147
+ 2. 발행자를 새 이름으로 전환
148
+ 3. 구 이름 발행이 0이 되면 카탈로그에서 제거
149
+ - 같은 이름의 version을 올리면 롤아웃 중 구·신 발행자 이벤트를 한쪽 소비자가 읽지 못해 유실된다.
150
+ - 닫힌 enum에 값을 추가하는 것도 호환 안 되는 변경이다(구 SDK가 거부). 발행 서비스가 자주 늘리는 값(보상 action, 숨김 사유)은 `z.string()`으로 둔다.
151
+ - 새 이벤트는 `src/catalog/{domain}.ts`에 추가하고 SDK 버전을 올린다. 소비자가 모르는 이벤트는 `unknown_event` → DLQ로 보존된다.
152
+
153
+ ## 개발
154
+
155
+ ```bash
156
+ npm install
157
+ npm test # tsx --test
158
+ npm run typecheck
159
+ npm run build # dist/
160
+ ```
161
+
162
+ 배포: 태그 `v*` push → `.github/workflows/publish.yml` (npm Trusted Publishing). CI publish가 막혀 있으면 로컬 `npm publish --otp`. **패키지는 public이어야 한다** — 서버 CI·박스는 npm 토큰 없이 익명으로 설치한다.
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ export declare const aiEvents: {
3
+ /**
4
+ * 현재 위치: ai-chatbot comment.service.ts postCommentAndNotify → sendNotification(ai_comment).
5
+ * commentId는 ai-chatbot이 알 수 없어(community ai-reply 응답을 버림) optional.
6
+ */
7
+ 'ai.comment.created': import("../envelope").EventDefinition<z.ZodObject<{
8
+ galleryId: z.ZodString;
9
+ botUserId: z.ZodString;
10
+ postOwnerUserId: z.ZodString;
11
+ commentId: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strip>>;
13
+ };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.aiEvents = void 0;
4
+ const zod_1 = require("zod");
5
+ const envelope_1 = require("../envelope");
6
+ exports.aiEvents = {
7
+ /**
8
+ * 현재 위치: ai-chatbot comment.service.ts postCommentAndNotify → sendNotification(ai_comment).
9
+ * commentId는 ai-chatbot이 알 수 없어(community ai-reply 응답을 버림) optional.
10
+ */
11
+ 'ai.comment.created': (0, envelope_1.defineEvent)({
12
+ version: 1,
13
+ publishers: ['ai-chatbot'],
14
+ description: 'AI 봇이 게시물에 댓글 작성',
15
+ schema: zod_1.z.object({
16
+ galleryId: envelope_1.ids.objectId,
17
+ botUserId: envelope_1.ids.userId,
18
+ postOwnerUserId: envelope_1.ids.userId,
19
+ commentId: envelope_1.ids.objectId.optional(),
20
+ }),
21
+ actor: (p) => p.botUserId,
22
+ subjects: (p) => [p.postOwnerUserId],
23
+ }),
24
+ };
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * 앱 활동 신호 (S3 활동 흔적 푸시 / 비활동 트리거용).
4
+ *
5
+ * - 서버가 API 호출에서 직접 아는 신호는 해당 서버가 발행한다 (challenge_viewed, gallery_post_viewed).
6
+ * - 서버가 모르는 화면 단계 신호(post_step_viewed)만 앱이 `POST /activity`로 보내고, 받는 서버가 발행한다.
7
+ * - userId는 항상 JWT 검증값이다. 요청 body/query의 userId를 쓰지 않는다.
8
+ * - 단순 접속(lastActiveAt)은 요청마다 이벤트를 만들지 않고 Redis → DB로 따로 반영한다.
9
+ */
10
+ export declare const appActivityEvents: {
11
+ 'app.activity.challenge_viewed': import("../envelope").EventDefinition<z.ZodObject<{
12
+ userId: z.ZodString;
13
+ challengeId: z.ZodString;
14
+ }, z.core.$strip>>;
15
+ 'app.activity.gallery_post_viewed': import("../envelope").EventDefinition<z.ZodObject<{
16
+ userId: z.ZodString;
17
+ galleryId: z.ZodString;
18
+ communityId: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strip>>;
20
+ 'app.activity.post_step_viewed': import("../envelope").EventDefinition<z.ZodObject<{
21
+ userId: z.ZodString;
22
+ stepId: z.ZodString;
23
+ flowId: z.ZodString;
24
+ challengeId: z.ZodOptional<z.ZodString>;
25
+ }, z.core.$strip>>;
26
+ };
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.appActivityEvents = void 0;
4
+ const zod_1 = require("zod");
5
+ const envelope_1 = require("../envelope");
6
+ /**
7
+ * 앱 활동 신호 (S3 활동 흔적 푸시 / 비활동 트리거용).
8
+ *
9
+ * - 서버가 API 호출에서 직접 아는 신호는 해당 서버가 발행한다 (challenge_viewed, gallery_post_viewed).
10
+ * - 서버가 모르는 화면 단계 신호(post_step_viewed)만 앱이 `POST /activity`로 보내고, 받는 서버가 발행한다.
11
+ * - userId는 항상 JWT 검증값이다. 요청 body/query의 userId를 쓰지 않는다.
12
+ * - 단순 접속(lastActiveAt)은 요청마다 이벤트를 만들지 않고 Redis → DB로 따로 반영한다.
13
+ */
14
+ exports.appActivityEvents = {
15
+ 'app.activity.challenge_viewed': (0, envelope_1.defineEvent)({
16
+ version: 1,
17
+ publishers: ['ao-challenge-server'],
18
+ description: '챌린지 상세 API 호출 (S3 A-2-1 흔적)',
19
+ schema: zod_1.z.object({
20
+ userId: envelope_1.ids.userId,
21
+ challengeId: zod_1.z.string().min(1),
22
+ }),
23
+ actor: (p) => p.userId,
24
+ subjects: (p) => [p.userId],
25
+ }),
26
+ 'app.activity.gallery_post_viewed': (0, envelope_1.defineEvent)({
27
+ version: 1,
28
+ publishers: ['ao-gallery-server'],
29
+ description: '갤러리 게시물 상세 API 호출 (S3 A-3 흔적)',
30
+ schema: zod_1.z.object({
31
+ userId: envelope_1.ids.userId,
32
+ galleryId: envelope_1.ids.objectId,
33
+ communityId: zod_1.z.string().min(1).optional(),
34
+ }),
35
+ actor: (p) => p.userId,
36
+ subjects: (p) => [p.userId],
37
+ }),
38
+ 'app.activity.post_step_viewed': (0, envelope_1.defineEvent)({
39
+ version: 1,
40
+ // POST /activity를 받을 서버는 S3 카드(qSPjswwl)에서 확정 — 우선 notification-server로 둔다.
41
+ publishers: ['notification-server'],
42
+ description: '기록하기 단계 화면 진입 — 앱 신호 (S3 A-2-2 흔적)',
43
+ schema: zod_1.z.object({
44
+ userId: envelope_1.ids.userId,
45
+ stepId: zod_1.z.string().min(1),
46
+ flowId: zod_1.z.string().min(1),
47
+ challengeId: zod_1.z.string().min(1).optional(),
48
+ }),
49
+ actor: (p) => p.userId,
50
+ subjects: (p) => [p.userId],
51
+ }),
52
+ };
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * 캠페인 엔진 내부 이벤트.
4
+ * campaign.schedule.fired는 EventBridge Scheduler가 버스에 넣는다. notification-server가 스케줄을 만들 때
5
+ * Scheduler 입력(Detail)을 이 envelope 형태로 구성하고, eventId에는 `<aws.scheduler.execution-id>`(짧은 hex,
6
+ * UUID 아님 — envelope의 eventId 형식이 이를 허용한다)를 넣는다.
7
+ */
8
+ export declare const campaignEvents: {
9
+ 'campaign.schedule.fired': import("../envelope").EventDefinition<z.ZodObject<{
10
+ campaignId: z.ZodString;
11
+ scheduledAt: z.ZodISODateTime;
12
+ }, z.core.$strip>>;
13
+ };
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.campaignEvents = void 0;
4
+ const zod_1 = require("zod");
5
+ const envelope_1 = require("../envelope");
6
+ /**
7
+ * 캠페인 엔진 내부 이벤트.
8
+ * campaign.schedule.fired는 EventBridge Scheduler가 버스에 넣는다. notification-server가 스케줄을 만들 때
9
+ * Scheduler 입력(Detail)을 이 envelope 형태로 구성하고, eventId에는 `<aws.scheduler.execution-id>`(짧은 hex,
10
+ * UUID 아님 — envelope의 eventId 형식이 이를 허용한다)를 넣는다.
11
+ */
12
+ exports.campaignEvents = {
13
+ 'campaign.schedule.fired': (0, envelope_1.defineEvent)({
14
+ version: 1,
15
+ publishers: ['notification-server'],
16
+ description: 'admin-v2에서 등록한 스케줄 캠페인 발화',
17
+ schema: zod_1.z.object({
18
+ campaignId: envelope_1.ids.objectId,
19
+ /** 예정 발화 시각 (ISO 8601) */
20
+ scheduledAt: zod_1.z.iso.datetime({ offset: true }),
21
+ }),
22
+ actor: () => null,
23
+ subjects: () => [],
24
+ }),
25
+ };
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * 한 이벤트에 직접 싣는 수신자 상한.
4
+ * recipientUserIds는 payload와 envelope.subjectUserIds에 두 번 직렬화된다 (id당 약 27B × 2).
5
+ * 256KB 한도에서 여유를 두고 4000으로 잡는다 — 테스트(catalog.test.ts)가 상한 인원으로 실제 크기를 검증한다.
6
+ * 더 많은 대상은 이벤트를 나눠 발행한다.
7
+ */
8
+ export declare const MAX_INLINE_RECIPIENTS = 4000;
9
+ export declare const communityEvents: {
10
+ /**
11
+ * 현재 위치: ao-community-server galleryReply.controller.ts createReply(커뮤니티 게시물),
12
+ * galleryComment.controller.ts(비커뮤니티 게시물). authorType 'USER' 댓글만 발행한다.
13
+ * postOwnerUserId는 gallery 조회 후 알 수 있으므로 그 뒤에 발행한다.
14
+ */
15
+ 'community.comment.created': import("../envelope").EventDefinition<z.ZodObject<{
16
+ galleryId: z.ZodString;
17
+ commentId: z.ZodString;
18
+ commenterUserId: z.ZodString;
19
+ postOwnerUserId: z.ZodString;
20
+ communityId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
21
+ }, z.core.$strip>>;
22
+ /** 현재 위치: ao-community-server galleryReply.controller.ts toggleCommentLike (좋아요 켤 때만) */
23
+ 'community.comment.liked': import("../envelope").EventDefinition<z.ZodObject<{
24
+ galleryId: z.ZodString;
25
+ commentId: z.ZodString;
26
+ likerUserId: z.ZodString;
27
+ commentOwnerUserId: z.ZodString;
28
+ communityId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
29
+ }, z.core.$strip>>;
30
+ /**
31
+ * 현재 위치: ao-community-server reaction.controller.ts toggleReaction (추가·변경 시만).
32
+ * ⚠️ 현재 컨트롤러는 user-id 헤더 폴백이 있다 — reactorUserId에는 JWT 검증값만 넣을 것.
33
+ */
34
+ 'community.reaction.added': import("../envelope").EventDefinition<z.ZodObject<{
35
+ galleryId: z.ZodString;
36
+ reactorUserId: z.ZodString;
37
+ postOwnerUserId: z.ZodString;
38
+ reactionType: z.ZodNumber;
39
+ isChange: z.ZodDefault<z.ZodBoolean>;
40
+ communityId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
41
+ }, z.core.$strip>>;
42
+ /**
43
+ * 현재 위치: ao-community-server announcement.controller.ts (필독 공지 신규 작성 시).
44
+ * 수신자(커뮤니티 멤버 / 크루 활성 기수 멤버)는 대량이므로 싣지 않는다 — 소비자가 communityId로 해석한다.
45
+ */
46
+ 'community.notice.published': import("../envelope").EventDefinition<z.ZodObject<{
47
+ noticeId: z.ZodString;
48
+ communityId: z.ZodString;
49
+ parentCommunityId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
50
+ isImportant: z.ZodBoolean;
51
+ authorUserId: z.ZodOptional<z.ZodString>;
52
+ }, z.core.$strip>>;
53
+ /** 현재 위치: ao-community-server message.controller.ts → notificationService.sendCommunityMessage */
54
+ 'community.message.sent': import("../envelope").EventDefinition<z.ZodObject<{
55
+ communityId: z.ZodString;
56
+ messageType: z.ZodString;
57
+ recipientUserIds: z.ZodArray<z.ZodString>;
58
+ galleryId: z.ZodOptional<z.ZodString>;
59
+ senderUserId: z.ZodOptional<z.ZodString>;
60
+ }, z.core.$strip>>;
61
+ };
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.communityEvents = exports.MAX_INLINE_RECIPIENTS = void 0;
4
+ const zod_1 = require("zod");
5
+ const envelope_1 = require("../envelope");
6
+ /**
7
+ * 한 이벤트에 직접 싣는 수신자 상한.
8
+ * recipientUserIds는 payload와 envelope.subjectUserIds에 두 번 직렬화된다 (id당 약 27B × 2).
9
+ * 256KB 한도에서 여유를 두고 4000으로 잡는다 — 테스트(catalog.test.ts)가 상한 인원으로 실제 크기를 검증한다.
10
+ * 더 많은 대상은 이벤트를 나눠 발행한다.
11
+ */
12
+ exports.MAX_INLINE_RECIPIENTS = 4000;
13
+ exports.communityEvents = {
14
+ /**
15
+ * 현재 위치: ao-community-server galleryReply.controller.ts createReply(커뮤니티 게시물),
16
+ * galleryComment.controller.ts(비커뮤니티 게시물). authorType 'USER' 댓글만 발행한다.
17
+ * postOwnerUserId는 gallery 조회 후 알 수 있으므로 그 뒤에 발행한다.
18
+ */
19
+ 'community.comment.created': (0, envelope_1.defineEvent)({
20
+ version: 1,
21
+ publishers: ['ao-community-server'],
22
+ description: '게시물에 댓글 작성',
23
+ schema: zod_1.z.object({
24
+ galleryId: envelope_1.ids.objectId,
25
+ commentId: envelope_1.ids.objectId,
26
+ /** 댓글 작성자 (JWT 검증값) */
27
+ commenterUserId: envelope_1.ids.userId,
28
+ postOwnerUserId: envelope_1.ids.userId,
29
+ /** 비커뮤니티 게시물이면 null */
30
+ communityId: zod_1.z.string().min(1).nullable().default(null),
31
+ }),
32
+ actor: (p) => p.commenterUserId,
33
+ subjects: (p) => [p.postOwnerUserId],
34
+ }),
35
+ /** 현재 위치: ao-community-server galleryReply.controller.ts toggleCommentLike (좋아요 켤 때만) */
36
+ 'community.comment.liked': (0, envelope_1.defineEvent)({
37
+ version: 1,
38
+ publishers: ['ao-community-server'],
39
+ description: '댓글 좋아요',
40
+ schema: zod_1.z.object({
41
+ galleryId: envelope_1.ids.objectId,
42
+ commentId: envelope_1.ids.objectId,
43
+ likerUserId: envelope_1.ids.userId,
44
+ commentOwnerUserId: envelope_1.ids.userId,
45
+ communityId: zod_1.z.string().min(1).nullable().default(null),
46
+ }),
47
+ actor: (p) => p.likerUserId,
48
+ subjects: (p) => [p.commentOwnerUserId],
49
+ }),
50
+ /**
51
+ * 현재 위치: ao-community-server reaction.controller.ts toggleReaction (추가·변경 시만).
52
+ * ⚠️ 현재 컨트롤러는 user-id 헤더 폴백이 있다 — reactorUserId에는 JWT 검증값만 넣을 것.
53
+ */
54
+ 'community.reaction.added': (0, envelope_1.defineEvent)({
55
+ version: 1,
56
+ publishers: ['ao-community-server'],
57
+ description: '커뮤니티 게시물에 리액션 추가/변경',
58
+ schema: zod_1.z.object({
59
+ galleryId: envelope_1.ids.objectId,
60
+ reactorUserId: envelope_1.ids.userId,
61
+ postOwnerUserId: envelope_1.ids.userId,
62
+ reactionType: zod_1.z.number().int().min(1).max(5),
63
+ isChange: zod_1.z.boolean().default(false),
64
+ communityId: zod_1.z.string().min(1).nullable().default(null),
65
+ }),
66
+ actor: (p) => p.reactorUserId,
67
+ subjects: (p) => [p.postOwnerUserId],
68
+ }),
69
+ /**
70
+ * 현재 위치: ao-community-server announcement.controller.ts (필독 공지 신규 작성 시).
71
+ * 수신자(커뮤니티 멤버 / 크루 활성 기수 멤버)는 대량이므로 싣지 않는다 — 소비자가 communityId로 해석한다.
72
+ */
73
+ 'community.notice.published': (0, envelope_1.defineEvent)({
74
+ version: 1,
75
+ publishers: ['ao-community-server'],
76
+ description: '커뮤니티 공지 게시',
77
+ schema: zod_1.z.object({
78
+ noticeId: envelope_1.ids.objectId,
79
+ communityId: zod_1.z.string().min(1),
80
+ /** 크루 기수 커뮤니티면 상위 커뮤니티 id */
81
+ parentCommunityId: zod_1.z.string().min(1).nullable().default(null),
82
+ isImportant: zod_1.z.boolean(),
83
+ /** 작성한 관리자 id — JWT 검증값일 때만 넣는다 (현재 컨트롤러는 body 값을 씀) */
84
+ authorUserId: envelope_1.ids.userId.optional(),
85
+ }),
86
+ actor: (p) => p.authorUserId ?? null,
87
+ subjects: () => [],
88
+ }),
89
+ /** 현재 위치: ao-community-server message.controller.ts → notificationService.sendCommunityMessage */
90
+ 'community.message.sent': (0, envelope_1.defineEvent)({
91
+ version: 1,
92
+ publishers: ['ao-community-server'],
93
+ description: '커뮤니티 관리자가 선택 멤버에게 메시지 발송',
94
+ schema: zod_1.z.object({
95
+ communityId: zod_1.z.string().min(1),
96
+ messageType: zod_1.z.string().min(1),
97
+ recipientUserIds: zod_1.z.array(envelope_1.ids.userId).min(1).max(exports.MAX_INLINE_RECIPIENTS),
98
+ galleryId: envelope_1.ids.objectId.optional(),
99
+ /** 보낸 관리자 id — JWT 검증값일 때만 넣는다 (현재 라우트는 인증 없음) */
100
+ senderUserId: envelope_1.ids.userId.optional(),
101
+ }),
102
+ actor: (p) => p.senderUserId ?? null,
103
+ subjects: (p) => p.recipientUserIds,
104
+ }),
105
+ };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ export declare const crewEvents: {
3
+ /**
4
+ * 현재 위치: ao-crew-server featured.service.ts → notification.service.ts sendFeaturedSelectedNotification.
5
+ * result가 'created' | 'reactivated'일 때만 발행 ('already'는 발행하지 않음). authorUserId가 비어 있으면 발행하지 않는다.
6
+ * 파일럿(lMKcsXk9): 기존 HTTP 알림과 병행 발행.
7
+ */
8
+ 'crew.featured.selected': import("../envelope").EventDefinition<z.ZodObject<{
9
+ galleryId: z.ZodString;
10
+ authorUserId: z.ZodString;
11
+ postType: z.ZodEnum<{
12
+ challenge: "challenge";
13
+ common: "common";
14
+ }>;
15
+ challengeId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
16
+ result: z.ZodEnum<{
17
+ created: "created";
18
+ reactivated: "reactivated";
19
+ }>;
20
+ adminUserId: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>>;
22
+ };
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.crewEvents = void 0;
4
+ const zod_1 = require("zod");
5
+ const envelope_1 = require("../envelope");
6
+ exports.crewEvents = {
7
+ /**
8
+ * 현재 위치: ao-crew-server featured.service.ts → notification.service.ts sendFeaturedSelectedNotification.
9
+ * result가 'created' | 'reactivated'일 때만 발행 ('already'는 발행하지 않음). authorUserId가 비어 있으면 발행하지 않는다.
10
+ * 파일럿(lMKcsXk9): 기존 HTTP 알림과 병행 발행.
11
+ */
12
+ 'crew.featured.selected': (0, envelope_1.defineEvent)({
13
+ version: 1,
14
+ publishers: ['ao-crew-server'],
15
+ description: '크루 피처드(작품 선정) 지정',
16
+ schema: zod_1.z.object({
17
+ galleryId: envelope_1.ids.objectId,
18
+ authorUserId: envelope_1.ids.userId,
19
+ postType: zod_1.z.enum(['challenge', 'common']),
20
+ /** 챌린지 id (contentsId). ObjectId가 아닐 수 있다. common이면 null */
21
+ challengeId: zod_1.z.string().min(1).nullable().default(null),
22
+ /** 'reactivated'면 해제됐다가 다시 지정된 것 */
23
+ result: zod_1.z.enum(['created', 'reactivated']),
24
+ /** 지정한 어드민 id — 현재 서비스까지 전달되지 않음. 알 수 있을 때만 */
25
+ adminUserId: envelope_1.ids.userId.optional(),
26
+ }),
27
+ actor: (p) => p.adminUserId ?? null,
28
+ subjects: (p) => [p.authorUserId],
29
+ }),
30
+ };
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ export declare const galleryEvents: {
3
+ /**
4
+ * 현재 위치: ao-gallery-server addGallerys.ts — 보상 지급(challenge_complete/retry/free_post) + community post-created webhook.
5
+ * E11000 중복 요청 경로에서는 발행하지 않는다 (부수효과 없는 경로).
6
+ */
7
+ 'gallery.post.created': import("../envelope").EventDefinition<z.ZodObject<{
8
+ galleryId: z.ZodString;
9
+ authorUserId: z.ZodString;
10
+ type: z.ZodEnum<{
11
+ challenge: "challenge";
12
+ common: "common";
13
+ world: "world";
14
+ }>;
15
+ challengeId: z.ZodNullable<z.ZodString>;
16
+ communityIds: z.ZodDefault<z.ZodArray<z.ZodString>>;
17
+ isPrivate: z.ZodBoolean;
18
+ isRetry: z.ZodDefault<z.ZodBoolean>;
19
+ }, z.core.$strip>>;
20
+ /**
21
+ * 현재 위치: ao-gallery-server galleryReaction.ts notifyReaction (일반 피드 경로).
22
+ * 리액션 추가·변경 시만 발행, 제거 시 발행하지 않는다. 본인 리액션도 발행하되 소비자가 actor===owner면 알림을 건너뛴다.
23
+ */
24
+ 'gallery.reaction.added': import("../envelope").EventDefinition<z.ZodObject<{
25
+ galleryId: z.ZodString;
26
+ reactorUserId: z.ZodString;
27
+ postOwnerUserId: z.ZodString;
28
+ reactionType: z.ZodNumber;
29
+ isChange: z.ZodDefault<z.ZodBoolean>;
30
+ communityId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
31
+ }, z.core.$strip>>;
32
+ /** 현재 위치: ao-gallery-server adminGallery.service.ts hideAdminPost → sendAdminNotice(post_hidden) */
33
+ 'gallery.post.hidden': import("../envelope").EventDefinition<z.ZodObject<{
34
+ galleryId: z.ZodString;
35
+ authorUserId: z.ZodString;
36
+ reason: z.ZodString;
37
+ adminUserId: z.ZodOptional<z.ZodString>;
38
+ notifyAuthor: z.ZodBoolean;
39
+ }, z.core.$strip>>;
40
+ /** 현재 위치: ao-gallery-server adminGallery.service.ts deleteAdminPost → sendAdminNotice(post_deleted) */
41
+ 'gallery.post.deleted': import("../envelope").EventDefinition<z.ZodObject<{
42
+ galleryId: z.ZodString;
43
+ authorUserId: z.ZodString;
44
+ adminUserId: z.ZodOptional<z.ZodString>;
45
+ notifyAuthor: z.ZodBoolean;
46
+ }, z.core.$strip>>;
47
+ };