Skip to content
Merged
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
5 changes: 5 additions & 0 deletions packages/bot/src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const logger = pino({
formatters: {
level: (label) => ({ level: label }),
},
// Serialize Error objects properly (pino defaults to {} for non-'err' keys)
serializers: {
error: pino.stdSerializers.err,
err: pino.stdSerializers.err,
},
// ISO 8601 timestamp with timezone offset
timestamp: pino.stdTimeFunctions.isoTime,
// Development: pretty print
Expand Down
31 changes: 21 additions & 10 deletions packages/bot/src/schedulers/rss-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { getMemberService } from '../services/member.service';
import { getPostService } from '../services/post.service';
import { getRssService, type PollResult, type RssFeedItem } from '../services/rss.service';
import { type Member, MemberStatus } from '@blog-study/shared/db';
import logger from '../lib/logger';
Expand Down Expand Up @@ -79,26 +80,36 @@ export class RssPoller {

try {
const rssService = getRssService();
const postService = getPostService();
const items = await rssService.fetchFeed(member.rssUrl);


if (items.length === 0) {
return { memberId: member.id, success: true, newItems: [] };
}

// Batch duplicate check: 1 IN query instead of N individual SELECTs
// postService.create() has its own getByUrl guard as a write-time safety net
const feedUrls = items.map(item => item.link).filter(Boolean);
const existingUrls = await postService.getExistingUrls(feedUrls);
const newItems = items.filter(item => !existingUrls.has(item.link));

return {
memberId: member.id,
success: true,
newItems: items,
newItems,
};
} catch (error) {
// Requirements: 6.5 - Log error and continue processing other feeds
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({
member: member.discordUsername,
error: errorMessage,
error,
}, '📡 [RSS] 멤버 피드 폴링 에러');

return {
memberId: member.id,
success: false,
newItems: [],
error: errorMessage,
error: error instanceof Error ? error.message : String(error),
};
}
}
Expand Down Expand Up @@ -141,13 +152,13 @@ export class RssPoller {
try {
await this.onNewPost(member, result.newItems);
} catch (callbackError) {
const errorMsg = callbackError instanceof Error
? callbackError.message
: String(callbackError);
logger.error({
member: member.discordUsername,
error: errorMsg,
error: callbackError,
}, '📡 [RSS] 콜백 에러');
const errorMsg = callbackError instanceof Error
? callbackError.message
: String(callbackError);
errors.push(`Callback error for ${member.discordUsername}: ${errorMsg}`);
}
}
Expand Down
18 changes: 17 additions & 1 deletion packages/bot/src/services/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Requirements: 6.3, 6.4, 6.6
*/

import { eq, and, count } from 'drizzle-orm';
import { eq, and, count, inArray } from 'drizzle-orm';
import {
getDb,
posts,
Expand Down Expand Up @@ -175,6 +175,22 @@ export class PostService {
return post || null;
}

/**
* Get existing URLs from a list of URLs (batch duplicate check)
* Note: URL is globally unique across all members (schema unique constraint),
* so no memberId scoping is needed here.
*/
async getExistingUrls(urls: string[]): Promise<Set<string>> {
if (urls.length === 0) return new Set();

const results = await this.db
.select({ url: posts.url })
.from(posts)
.where(inArray(posts.url, urls));

return new Set(results.map(r => r.url));
}

/**
* Get all posts
*/
Expand Down
Loading