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
1 change: 1 addition & 0 deletions .serena/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/cache
37 changes: 37 additions & 0 deletions .serena/project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
project_name: "study-admin"

languages:
- typescript

encoding: "utf-8"

ignore_all_files_in_gitignore: true

ignored_paths:
- "**/node_modules/**"
- "**/dist/**"
- "**/.next/**"
- "**/packages/shared/drizzle/**"

read_only: false

excluded_tools: []

included_optional_tools: []

fixed_tools: []

base_modes:

default_modes:

initial_prompt: |
This is a blog study automation platform (monorepo with pnpm workspace).
- packages/bot: Discord bot (discord.js v14)
- packages/web: Next.js 14 dashboard (App Router, shadcn/ui)
- packages/shared: Shared DB schema (Drizzle ORM), types, utilities
Tech: TypeScript strict, Supabase PostgreSQL, Supabase Auth (Discord OAuth)

symbol_info_budget:

language_backend:
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화
- **커밋**: 기존 git log 스타일 따름, Co-Authored-By 포함
- **한글 커맨드**: Discord 슬래시 명령어는 한글 (예: `/참가`, `/현황`)
- **Drizzle SQL**: `packages/shared/drizzle/*.sql` 마이그레이션 파일은 로컬 전용 (`.gitignore`에 등록됨, 커밋 금지)
- **다이얼로그**: `window.confirm()`, `window.alert()` 사용 금지 → 커스텀 다이얼로그 컴포넌트 사용 (기존 `DeleteMemberDialog` 패턴 참고)

## 핵심 파일 위치

Expand Down
4 changes: 2 additions & 2 deletions packages/bot/src/schedulers/rss-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export class RssPoller {
const memberService = getMemberService();
const activeMembers = await memberService.getAllByStatus(MemberStatus.ACTIVE);

// Filter to only members with RSS URLs
return activeMembers.filter(member => member.rssUrl);
// Filter to only members with RSS URLs and RSS consent
return activeMembers.filter(member => member.rssUrl && member.rssConsent !== false);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/bot/src/services/score.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SCORE_CONFIG: Record<
[ActivityScoreType.DISCORD_THREAD]: { points: 3, dailyCap: 9 },
[ActivityScoreType.DISCORD_REACTION]: { points: 1, dailyCap: 5 },
[ActivityScoreType.ADMIN_MANUAL]: { points: 0, dailyCap: Infinity },
[ActivityScoreType.POST_VIEW]: { points: 2, dailyCap: 10 },
};

function getTodayDateString(): string {
Expand Down
42 changes: 42 additions & 0 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const ActivityScoreType = {
DISCORD_THREAD: 'discord_thread',
DISCORD_REACTION: 'discord_reaction',
ADMIN_MANUAL: 'admin_manual',
POST_VIEW: 'post_view',
} as const;

export type ActivityScoreTypeValue = (typeof ActivityScoreType)[keyof typeof ActivityScoreType];
Expand Down Expand Up @@ -92,6 +93,7 @@ export const members = pgTable(
interests: text('interests').array(),
resolution: varchar('resolution', { length: 300 }),
onboardingCompleted: boolean('onboarding_completed').default(false),
rssConsent: boolean('rss_consent').default(true),
// 소셜 링크
githubUrl: varchar('github_url', { length: 500 }),
linkedinUrl: varchar('linkedin_url', { length: 500 }),
Expand Down Expand Up @@ -279,6 +281,31 @@ export const activityScores = pgTable(
})
);

/**
* 글 조회 기록 (Post Views)
* 글 조회 점수 중복 방지용
*/
export const postViews = pgTable(
'post_views',
{
id: uuid('id').primaryKey().defaultRandom(),
memberId: uuid('member_id')
.notNull()
.references(() => members.id),
postId: uuid('post_id')
.notNull()
.references(() => posts.id),
viewedAt: timestamp('viewed_at', { withTimezone: true }).defaultNow(),
},
(table) => ({
memberPostUnique: uniqueIndex('post_views_member_post_unique').on(
table.memberId,
table.postId
),
memberIdIdx: index('idx_post_views_member_id').on(table.memberId),
})
);

/**
* 설정 (Config)
* 스터디 설정 키-값 저장소
Expand All @@ -298,6 +325,7 @@ export const membersRelations = relations(members, ({ many }) => ({
attendance: many(attendance),
fines: many(fines),
activityScores: many(activityScores),
postViews: many(postViews),
}));

export const roundsRelations = relations(rounds, ({ many }) => ({
Expand Down Expand Up @@ -346,6 +374,17 @@ export const activityScoresRelations = relations(activityScores, ({ one }) => ({
}),
}));

export const postViewsRelations = relations(postViews, ({ one }) => ({
member: one(members, {
fields: [postViews.memberId],
references: [members.id],
}),
post: one(posts, {
fields: [postViews.postId],
references: [posts.id],
}),
}));

export const curationSourcesRelations = relations(curationSources, ({ many }) => ({
items: many(curationItems),
}));
Expand Down Expand Up @@ -388,5 +427,8 @@ export type NewCurationItem = typeof curationItems.$inferInsert;
export type ActivityScore = typeof activityScores.$inferSelect;
export type NewActivityScore = typeof activityScores.$inferInsert;

export type PostView = typeof postViews.$inferSelect;
export type NewPostView = typeof postViews.$inferInsert;

export type Config = typeof config.$inferSelect;
export type NewConfig = typeof config.$inferInsert;
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.97.0",
Expand Down
15 changes: 12 additions & 3 deletions packages/web/src/app/(admin)/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface Member {
part: string;
blogUrl: string;
rssUrl: string | null;
rssConsent: boolean;
profileImageUrl: string | null;
bio: string | null;
status: string;
Expand Down Expand Up @@ -263,6 +264,9 @@ export default function AdminMembersPage() {
<p className="text-xs text-muted-foreground">{member.discordUsername}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant={member.rssConsent ? 'outline' : 'secondary'} className="text-[10px] px-1.5 py-0">
RSS {member.rssConsent ? 'ON' : 'OFF'}
</Badge>
<Badge variant={MEMBER_STATUS_CONFIG[member.status]?.variant || 'secondary'}>
{MEMBER_STATUS_CONFIG[member.status]?.label || member.status}
</Badge>
Expand Down Expand Up @@ -348,9 +352,14 @@ export default function AdminMembersPage() {
</a>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant={MEMBER_STATUS_CONFIG[member.status]?.variant || 'secondary'}>
{MEMBER_STATUS_CONFIG[member.status]?.label || member.status}
</Badge>
<div className="flex items-center gap-1.5">
<Badge variant={member.rssConsent ? 'outline' : 'secondary'} className="text-[10px] px-1.5 py-0">
RSS {member.rssConsent ? 'ON' : 'OFF'}
</Badge>
<Badge variant={MEMBER_STATUS_CONFIG[member.status]?.variant || 'secondary'}>
{MEMBER_STATUS_CONFIG[member.status]?.label || member.status}
</Badge>
</div>
</TableCell>
<TableCell className="text-right">{member.postCount}</TableCell>
<TableCell className="text-right whitespace-nowrap">{member.attendanceRate}%</TableCell>
Expand Down
Loading