diff --git a/app/src/app.module.ts b/app/src/app.module.ts index bdc88199..f83031d4 100644 --- a/app/src/app.module.ts +++ b/app/src/app.module.ts @@ -19,6 +19,7 @@ import { JWT_CONFIG } from './config/jwt'; import { RUNTIME_CONFIG } from './config/runtime'; import { MongooseRootModule } from './database/mongoose/database.mongoose.module'; import { DateWrapper } from './dateWrapper/dateWrapper'; +import { FeedModule } from './feed/feed.module'; import { FollowModule } from './follow/follow.module'; import { HealthCheckModule } from './healthcheck/healthcheck.module'; import { LambdaModule } from './lambda/lambda.module'; @@ -107,6 +108,7 @@ export class AppRootModule {} SettingModule, CalculatorModule, FollowModule, + FeedModule, LambdaModule, HealthCheckModule, CacheDecoratorOnReturnModule, diff --git a/app/src/feed/db/feed.database.schema.ts b/app/src/feed/db/feed.database.schema.ts new file mode 100644 index 00000000..7f0e7f18 --- /dev/null +++ b/app/src/feed/db/feed.database.schema.ts @@ -0,0 +1,51 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; +import { UserPreview } from 'src/common/models/common.user.model'; +import { FeedType } from '../dto/feed.dto'; + +/** feed */ + +export type FeedDocument = HydratedDocument; +@Schema({ collection: 'feeds', discriminatorKey: 'kind' }) +export class feed { + @Prop({ type: String, required: true, enum: Object.values(FeedType) }) + type: FeedType; +} + +export const FeedSchema = SchemaFactory.createForClass(feed); + +/** follow feed */ + +export type FollowFeedDocument = HydratedDocument; + +@Schema({ collection: 'feeds' }) +export class followFeed extends feed { + @Prop({ required: true }) + createdAt: Date; + + @Prop({ required: true, type: UserPreview }) + userPreview: UserPreview; + + @Prop({ required: true, type: UserPreview }) + followed: UserPreview; +} + +export const FollowFeedSchema = SchemaFactory.createForClass(followFeed); + +/** location feed */ + +export type LocationFeedDocument = HydratedDocument; + +@Schema({ collection: 'feeds' }) +export class locationFeed extends feed { + @Prop({ required: true }) + createdAt: Date; + + @Prop({ required: true, type: UserPreview }) + userPreview: UserPreview; + + @Prop({ required: true }) + location: string; +} + +export const LocationFeedSchema = SchemaFactory.createForClass(locationFeed); diff --git a/app/src/feed/dto/feed.dto.ts b/app/src/feed/dto/feed.dto.ts new file mode 100644 index 00000000..8615fdd1 --- /dev/null +++ b/app/src/feed/dto/feed.dto.ts @@ -0,0 +1,13 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum FeedType { + FOLLOW = 'FOLLOW', + STATUS_MESSAGE = 'STATUS_MESSAGE', + LOCATION = 'LOCATION', + NEW_MEMBER = 'NEW_MEMBER', + BLACKHOLED_AT = 'BLACKHOLED_AT', + TEAM_STATUS_FINISHED = 'TEAM_STATUS_FINISHED', + EVENT = 'EVENT', +} + +registerEnumType(FeedType, { name: 'FeedType' }); diff --git a/app/src/feed/feed.cache.service.ts b/app/src/feed/feed.cache.service.ts new file mode 100644 index 00000000..f9ddca80 --- /dev/null +++ b/app/src/feed/feed.cache.service.ts @@ -0,0 +1,37 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Cron } from '@nestjs/schedule'; +import { Model } from 'mongoose'; +import { CacheUtilService } from 'src/cache/cache.util.service'; +import { DateWrapper } from 'src/dateWrapper/dateWrapper'; +import { feed } from './db/feed.database.schema'; + +@Injectable() +export class FeedCacheService { + constructor( + @InjectModel(feed.name) + private readonly feedModel: Model, + @Inject(CACHE_MANAGER) + private readonly cacheUtilService: CacheUtilService, + ) {} + + /** + * @description 매일 05:00에 최신 한달간의 피드를 캐싱한다. (ttl: 1일) + * todo test: 1분 간격으로 cron 실행 + */ + @Cron('*/1 * * * *') + async monthlyFeedCache() { + const lastMonth = new DateWrapper().moveMonth(-1).toDate(); + + const lastMonthFeeds = await this.feedModel.aggregate([ + { $match: { createdAt: { $gte: lastMonth } } }, + { $sort: { createdAt: -1 } }, + { $project: { _id: 0, __v: 0 } }, + ]); + + const key = `lastMonthFeeds`; + + await this.cacheUtilService.set(key, lastMonthFeeds, DateWrapper.DAY); + } +} diff --git a/app/src/feed/feed.module.ts b/app/src/feed/feed.module.ts new file mode 100644 index 00000000..00504004 --- /dev/null +++ b/app/src/feed/feed.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { CacheUtilModule } from 'src/cache/cache.util.module'; +import { FollowCacheService } from 'src/follow/follow.cache.service'; +import { PaginationCursorService } from 'src/pagination/cursor/pagination.cursor.service'; +import { FeedSchema, feed } from './db/feed.database.schema'; +import { FeedCacheService } from './feed.cache.service'; +import { FeedResolver } from './feed.resolver'; +import { FeedService } from './feed.service'; + +@Module({ + //imports: [ + // MongooseModule.forFeature([ + // { + // name: feed.name, + // schema: FeedSchema, + // discriminators: [ + // { name: followFeed.name, schema: FollowFeedSchema }, + // { name: locationFeed.name, schema: LocationFeedSchema }, + // ], + // }, + // ]), + //], + imports: [ + MongooseModule.forFeature([{ name: feed.name, schema: FeedSchema }]), + CacheUtilModule, + ], + providers: [ + FeedResolver, + FeedService, + PaginationCursorService, + FeedCacheService, + FollowCacheService, + ], + exports: [ + FeedService, + MongooseModule.forFeature([{ name: feed.name, schema: FeedSchema }]), + ], +}) + +// eslint-disable-next-line +export class FeedModule {} diff --git a/app/src/feed/feed.resolver.ts b/app/src/feed/feed.resolver.ts new file mode 100644 index 00000000..77e8d0c2 --- /dev/null +++ b/app/src/feed/feed.resolver.ts @@ -0,0 +1,20 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyUserId } from 'src/auth/myContext'; +import { StatAuthGuard } from 'src/auth/statAuthGuard'; +import { PaginationCursorArgs } from 'src/pagination/cursor/dtos/pagination.cursor.dto'; +import { FeedService } from './feed.service'; +import { FeedPage } from './model/feed.model'; + +@UseGuards(StatAuthGuard) +@Resolver() +export class FeedResolver { + constructor(private readonly feedService: FeedService) {} + @Query((_returns) => FeedPage) + async getFeed( + @MyUserId() userId: number, + @Args() args: PaginationCursorArgs, + ): Promise { + return await this.feedService.getFeedPaginated({ userId, args }); + } +} diff --git a/app/src/feed/feed.service.ts b/app/src/feed/feed.service.ts new file mode 100644 index 00000000..56c610ef --- /dev/null +++ b/app/src/feed/feed.service.ts @@ -0,0 +1,88 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { CacheUtilService } from 'src/cache/cache.util.service'; +import { FollowCacheService } from 'src/follow/follow.cache.service'; +import { PaginationCursorArgs } from 'src/pagination/cursor/dtos/pagination.cursor.dto'; +import { + CursorExtractor, + PaginationCursorService, +} from 'src/pagination/cursor/pagination.cursor.service'; +import { feed } from './db/feed.database.schema'; +import { FeedEdge, FeedPage, feedUnion, PageInfo } from './model/feed.model'; + +@Injectable() +export class FeedService { + constructor( + @InjectModel(feed.name) + private readonly feedModel: Model, + private readonly paginationCursorService: PaginationCursorService, + private readonly followCacheService: FollowCacheService, + @Inject(CACHE_MANAGER) + private readonly cacheUtilService: CacheUtilService, + ) {} + + async getFeedPaginated({ + userId, + args, + }: { + userId: number; + args: PaginationCursorArgs; + }): Promise { + const feeds = await this.getFeeds(userId); + + console.log(feeds); + + if (args.after) { + const afterIndex = feeds.findIndex( + (feed) => cursorExtractor(feed) === args.after, + ); + feeds.splice(0, afterIndex + 1); + } + + const edges: FeedEdge[] = feeds.map((feed) => ({ + node: feed, + cursor: cursorExtractor(feed), + })); + + const pageInfo: PageInfo = { + hasNextPage: feeds.length > args.first, + endCursor: edges.at(-1)?.cursor, + }; + + return { + edges, + pageInfo, + }; + } + + async getFeeds(userId: number): Promise<(typeof feedUnion)[]> { + const followList = await this.followCacheService.get(userId, 'following'); + + const key = `lastMonthFeeds`; + const feeds = await this.cacheUtilService.get<(typeof feedUnion)[]>(key); + + //cache가 없는 경우 고려 + if (!feeds) { + console.log('cache miss'); + return []; + } + + feeds.filter((feed) => { + const isFollowedUser = followList.some( + (follow) => follow.userPreview.id === feed.userPreview.id, + ); + const isAfterFollow = followList.some( + (follow) => follow.followAt < feed.createdAt, + ); + return isFollowedUser && isAfterFollow; + }); + + return feeds.flat(); + } +} + +const cursorExtractor: CursorExtractor = (doc) => { + return `${doc.userPreview.id.toString()} + ${doc.createdAt.toISOString()}`; +}; diff --git a/app/src/feed/model/feed.model.ts b/app/src/feed/model/feed.model.ts new file mode 100644 index 00000000..f69c3a7e --- /dev/null +++ b/app/src/feed/model/feed.model.ts @@ -0,0 +1,138 @@ +import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; +import { UserPreview } from 'src/common/models/common.user.model'; +import { FeedType } from '../dto/feed.dto'; + +@ObjectType() +export class FeedBase { + @Field() + createdAt: Date; + + @Field((_type) => UserPreview) + userPreview: UserPreview; +} + +@ObjectType() +export class FollowFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.FOLLOW; + + @Field((_type) => UserPreview) + followed: UserPreview; +} + +@ObjectType() +export class LocationFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.LOCATION; + + @Field() + location: string; +} + +@ObjectType() +export class StatusMessageFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.STATUS_MESSAGE; + + @Field() + message: string; +} + +@ObjectType() +export class TeamStatusFinishedFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.TEAM_STATUS_FINISHED; + + @Field() + teamInfo: string; +} + +@ObjectType() +export class EventFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.EVENT; + + @Field() + event: string; +} + +@ObjectType() +export class NewMemberFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.NEW_MEMBER; + + @Field() + memberAt: Date; +} + +@ObjectType() +export class BlackholedAtFeed extends FeedBase { + @Field((_type) => FeedType) + type: FeedType.BLACKHOLED_AT; + + @Field() + blackholedAt: Date; +} + +export const feedUnion = createUnionType({ + name: 'FeedUnion', + types: () => [ + FollowFeed, + LocationFeed, + StatusMessageFeed, + TeamStatusFinishedFeed, + EventFeed, + NewMemberFeed, + BlackholedAtFeed, + ], + resolveType: (value) => { + if ('followed' in value) { + return FollowFeed; + } + if ('location' in value) { + return LocationFeed; + } + if ('message' in value) { + return StatusMessageFeed; + } + if ('teamInfo' in value) { + return TeamStatusFinishedFeed; + } + if ('event' in value) { + return EventFeed; + } + if ('memberAt' in value) { + return NewMemberFeed; + } + if ('blackholedAt' in value) { + return BlackholedAtFeed; + } + }, +}); + +@ObjectType() +export class PageInfo { + @Field() + hasNextPage: boolean; + + @Field({ nullable: true }) + endCursor?: String; +} + +@ObjectType() +export class FeedEdge { + @Field() + cursor: string; + + @Field((_type) => feedUnion) + node: typeof feedUnion; +} + +@ObjectType() +export class FeedPage { + @Field((_type) => [FeedEdge]) + edges: FeedEdge[]; + + @Field() + pageInfo: PageInfo; +} diff --git a/app/src/follow/db/follow.database.schema.ts b/app/src/follow/db/follow.database.schema.ts index d3a010b9..12dd04ed 100644 --- a/app/src/follow/db/follow.database.schema.ts +++ b/app/src/follow/db/follow.database.schema.ts @@ -3,7 +3,7 @@ import { HydratedDocument } from 'mongoose'; export type UserDocument = HydratedDocument; -@Schema({ collection: 'follows' }) +@Schema({ collection: 'temp_follows' }) export class follow { @Prop({ required: true }) userId: number; @@ -13,6 +13,7 @@ export class follow { @Prop({ required: true }) followAt: Date; + //createdAt: Date; } export const FollowSchema = SchemaFactory.createForClass(follow); diff --git a/app/src/follow/follow.cache.service.ts b/app/src/follow/follow.cache.service.ts index 91b1b8ab..ccec3a64 100644 --- a/app/src/follow/follow.cache.service.ts +++ b/app/src/follow/follow.cache.service.ts @@ -37,4 +37,22 @@ export class FollowCacheService { return cachedData; } + + async getByDate( + userId: number, + type: 'follower' | 'following', + time: Date, + ): Promise { + const key = `${userId}:${type}:${FOLLOW_LISTS}`; + + const cachedData = await this.cacheUtilService.get(key); + + if (!cachedData) { + return []; + } + + return cachedData.filter((follow) => { + return follow.followAt > time; + }); + } } diff --git a/app/src/follow/follow.module.ts b/app/src/follow/follow.module.ts index cd067fe0..42129e83 100644 --- a/app/src/follow/follow.module.ts +++ b/app/src/follow/follow.module.ts @@ -2,20 +2,34 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { CursusUserModule } from 'src/api/cursusUser/cursusUser.module'; import { CacheUtilModule } from 'src/cache/cache.util.module'; +import { followFeed, FollowFeedSchema } from 'src/feed/db/feed.database.schema'; +import { FeedModule } from 'src/feed/feed.module'; +import { FeedService } from 'src/feed/feed.service'; +import { PaginationCursorService } from 'src/pagination/cursor/pagination.cursor.service'; import { PaginationIndexModule } from 'src/pagination/index/pagination.index.module'; -import { FollowSchema, follow } from './db/follow.database.schema'; +import { follow, FollowSchema } from './db/follow.database.schema'; import { FollowCacheService } from './follow.cache.service'; import { FollowResolver } from './follow.resolver'; import { FollowService } from './follow.service'; @Module({ imports: [ - MongooseModule.forFeature([{ name: follow.name, schema: FollowSchema }]), + MongooseModule.forFeature([ + { name: follow.name, schema: FollowSchema }, + { name: followFeed.name, schema: FollowFeedSchema }, + ]), + FeedModule, CursusUserModule, PaginationIndexModule, CacheUtilModule, ], - providers: [FollowResolver, FollowService, FollowCacheService], + providers: [ + FollowResolver, + FollowService, + FollowCacheService, + PaginationCursorService, + FeedService, + ], exports: [FollowService, FollowCacheService], }) // eslint-disable-next-line diff --git a/app/src/follow/follow.service.ts b/app/src/follow/follow.service.ts index b97f6147..2c7fa720 100644 --- a/app/src/follow/follow.service.ts +++ b/app/src/follow/follow.service.ts @@ -3,11 +3,13 @@ import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model, SortOrder } from 'mongoose'; import { CursusUserCacheService } from 'src/api/cursusUser/cursusUser.cache.service'; import { - QueryArgs, - QueryOneArgs, findAllAndLean, findOneAndLean, + QueryArgs, + QueryOneArgs, } from 'src/database/mongoose/database.mongoose.query'; +import { followFeed } from 'src/feed/db/feed.database.schema'; +import { FeedType } from 'src/feed/dto/feed.dto'; import { PaginationIndexService } from 'src/pagination/index/pagination.index.service'; import { follow } from './db/follow.database.schema'; import { FollowSortOrder, type FollowPaginatedArgs } from './dto/follow.dto'; @@ -24,6 +26,8 @@ export class FollowService { constructor( @InjectModel(follow.name) private readonly followModel: Model, + @InjectModel(followFeed.name) + private readonly followFeedModel: Model, private readonly cursusUserCacheService: CursusUserCacheService, private readonly paginationIndexService: PaginationIndexService, private readonly followCacheService: FollowCacheService, @@ -86,6 +90,14 @@ export class FollowService { cachedfollowerList.push({ userPreview: user, followAt }); + //todo: feed 실패와 follow 실패 구분 안되는 중 + await this.followFeedModel.create({ + createdAt: followAt, + userPreview: user, + type: FeedType.FOLLOW, + followed: target, + }); + return { userId, followId: targetId, diff --git a/app/src/schema.gql b/app/src/schema.gql index 9149aafc..c4f539cc 100644 --- a/app/src/schema.gql +++ b/app/src/schema.gql @@ -59,6 +59,82 @@ type UserRankingIndexPaginated { pageNumber: Int! } +type PageInfo { + hasNextPage: Boolean! + endCursor: String +} + +type FeedEdge { + cursor: String! + node: FeedUnion! +} + +union FeedUnion = FollowFeed | LocationFeed | StatusMessageFeed | TeamStatusFinishedFeed | EventFeed | NewMemberFeed | BlackholedAtFeed + +type FollowFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + followed: UserPreview! +} + +enum FeedType { + FOLLOW + STATUS_MESSAGE + LOCATION + NEW_MEMBER + BLACKHOLED_AT + TEAM_STATUS_FINISHED + EVENT +} + +type LocationFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + location: String! +} + +type StatusMessageFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + message: String! +} + +type TeamStatusFinishedFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + teamInfo: String! +} + +type EventFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + event: String! +} + +type NewMemberFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + memberAt: DateTime! +} + +type BlackholedAtFeed { + createdAt: DateTime! + userPreview: UserPreview! + type: FeedType! + blackholedAt: DateTime! +} + +type FeedPage { + edges: [FeedEdge!]! + pageInfo: PageInfo! +} + type MyFollow { isFollowing: Boolean! userPreview: UserPreview! @@ -642,6 +718,7 @@ type Query { getIsFollowing(targetId: Int!): Boolean! getFollowerPaginated(pageSize: Int! = 10, pageNumber: Int! = 1, targetId: Int!, sortOrder: FollowSortOrder! = FOLLOW_AT_DESC): MyFollowPaginated! getFollowingPaginated(pageSize: Int! = 10, pageNumber: Int! = 1, targetId: Int!, sortOrder: FollowSortOrder! = FOLLOW_AT_DESC): MyFollowPaginated! + getFeed(after: String, first: Int! = 20): FeedPage! } enum EvalLogSortOrder {