diff --git a/package.json b/package.json index 659758c..d57934b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@oz-adv/backend", - "version": "0.0.2 (2024-11-28.005)", + "version": "0.0.2 (2024-11-28.006)", "description": "Backend for the Oz-Adv project", "type": "module", "scripts": { diff --git a/src/account/account-api-live.mts b/src/account/account-api-live.mts index 498066d..d21b039 100644 --- a/src/account/account-api-live.mts +++ b/src/account/account-api-live.mts @@ -29,6 +29,21 @@ export const AccountApiLive = HttpApiBuilder.group(Api, 'account', (handlers) => accountService.signIn(payload).pipe(withSystemActor), ) .handle('me', () => CurrentAccount) + .handle('findPosts', ({ path, urlParams }) => + accountService.findPosts(urlParams, path.accountId), + ) + .handle('findComments', ({ path, urlParams }) => + accountService.findComments(urlParams, path.accountId), + ) + .handle('findChallenges', ({ path, urlParams }) => + accountService.findChallenges(urlParams, path.accountId), + ) + .handle('findChallengeEvents', ({ path, urlParams }) => + accountService.findAllChallengeEvents(urlParams, path.accountId), + ) + .handle('findLikes', ({ path, urlParams }) => + accountService.findAllLikes(urlParams, path.accountId), + ) .handle('invalidate', ({ headers }) => accountService.invalidate(headers['refresh-token']), ); diff --git a/src/account/account-api.mts b/src/account/account-api.mts index c6ccce1..cd1d4d5 100644 --- a/src/account/account-api.mts +++ b/src/account/account-api.mts @@ -1,11 +1,18 @@ import { Authentication } from '@/auth/authentication.mjs'; import { Unauthorized } from '@/auth/error-403.mjs'; +import { ChallengeEventView } from '@/challenge-event/challenge-event-schema.mjs'; +import { ChallengeView } from '@/challenge/challenge-schema.mjs'; +import { CommentView } from '@/comment/comment-schema.mjs'; import { GeneratingSaltError, HashingPasswordError, } from '@/crypto/crypto-error.mjs'; import { VerifyTokenError } from '@/crypto/token-error.mjs'; import { ServerError } from '@/misc/common-error.mjs'; +import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; +import { PostView } from '@/post/post-schema.mjs'; +import { Tag } from '@/tag/tag-schema.mjs'; import { HttpApiEndpoint, HttpApiGroup, OpenApi } from '@effect/platform'; import { Schema } from 'effect'; import { @@ -16,7 +23,6 @@ import { import { Account, AccountId } from './account-schema.mjs'; import { SignIn } from './sign-in-schema.mjs'; import { SignUp } from './sign-up-schema.mjs'; -import { Tag } from '@/tag/tag-schema.mjs'; export class AccountApi extends HttpApiGroup.make('account') .add( @@ -26,8 +32,8 @@ export class AccountApi extends HttpApiGroup.make('account') accountId: AccountId, }), ) - .addSuccess(Account.json) .addError(AccountNotFound) + .addSuccess(Account.json) .annotateContext( OpenApi.annotations({ title: '계정 조회', @@ -51,14 +57,118 @@ export class AccountApi extends HttpApiGroup.make('account') .annotateContext( OpenApi.annotations({ title: '계정 태그 조회', - description: - '계정의 태그를 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + description: '내 계정의 태그를 조회합니다.', override: { summary: '(사용가능) 계정 태그 조회', }, }), ), ) + .add( + HttpApiEndpoint.get('findChallenges', '/:accountId/challenges') + .setPath( + Schema.Struct({ + accountId: AccountId, + }), + ) + .setUrlParams(FindManyUrlParams) + .addError(AccountNotFound) + .addSuccess(FindManyResultSchema(ChallengeView)) + .annotateContext( + OpenApi.annotations({ + title: '작성한 챌린지 조회', + description: + '이 사용자가 작성한 챌린지를 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + override: { + summary: '(사용가능) 작성한 챌린지 조회', + }, + }), + ), + ) + .add( + HttpApiEndpoint.get('findPosts', '/:accountId/posts') + .setPath( + Schema.Struct({ + accountId: AccountId, + }), + ) + .setUrlParams(FindManyUrlParams) + .addError(AccountNotFound) + .addSuccess(FindManyResultSchema(PostView)) + .annotateContext( + OpenApi.annotations({ + title: '작성한 게시글 조회', + description: + '이 사용자가 작성한 포스트를 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + override: { + summary: '(사용가능) 작성한 게시글 조회', + }, + }), + ), + ) + .add( + HttpApiEndpoint.get('findLikes', '/:accountId/likes') + .setPath( + Schema.Struct({ + accountId: AccountId, + }), + ) + .setUrlParams(FindManyUrlParams) + .addError(AccountNotFound) + .addSuccess(FindManyResultSchema(Schema.Any)) + .annotateContext( + OpenApi.annotations({ + title: '좋아요한 이력 조회', + description: + '이 사용자가 좋아요한 내용들을 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + override: { + summary: '(사용가능) 좋아요한 이력 조회', + }, + }), + ), + ) + .add( + HttpApiEndpoint.get('findComments', '/:accountId/comments') + .setPath( + Schema.Struct({ + accountId: AccountId, + }), + ) + .setUrlParams(FindManyUrlParams) + .addError(AccountNotFound) + .addSuccess(FindManyResultSchema(CommentView.json)) + .annotateContext( + OpenApi.annotations({ + title: '댓글한 이력 조회', + description: + '이 사용자가 댓글단 내용을 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + override: { + summary: '(사용가능) 댓글한 이력 조회', + }, + }), + ), + ) + .add( + HttpApiEndpoint.get('findChallengeEvents', '/:accountId/challenge-events') + .setPath( + Schema.Struct({ + accountId: AccountId, + }), + ) + .setUrlParams(FindManyUrlParams) + .addError(AccountNotFound) + .addSuccess(FindManyResultSchema(ChallengeEventView.json)) + .annotateContext( + OpenApi.annotations({ + title: '참여한 챌린지 이벤트 조회', + description: + '이 사용자가 참여한 챌린지 이벤트를 조회합니다. 계정이 존재하지 않는 경우 404를 반환합니다. 다른 사람의 계정을 조회할 수 있습니다. 로그인하지 않아도 사용할 수 있습니다.', + override: { + summary: '(사용가능) 참여한 챌린지 이벤트 조회', + }, + }), + ), + ) .add( HttpApiEndpoint.patch('updateById', '/:accountId') .setPath( diff --git a/src/account/account-policy.mts b/src/account/account-policy.mts index 7f66b7a..e97dcdf 100644 --- a/src/account/account-policy.mts +++ b/src/account/account-policy.mts @@ -1,17 +1,37 @@ +import { policy } from '@/auth/authorization.mjs'; import { Effect, Layer } from 'effect'; +import { AccountRepo } from './account-repo.mjs'; import { AccountId } from './account-schema.mjs'; -import { policy } from '@/auth/authorization.mjs'; const make = Effect.gen(function* () { + const accountRepo = yield* AccountRepo; + const canUpdate = (toUpdate: AccountId) => policy('account', 'update', (actor) => Effect.succeed(actor.id === toUpdate || actor.role === 'admin'), ); - // const canRead = (toRead: AccountId) => - // policy('account', 'read', (actor) => - // Effect.succeed(actor.id === toRead || actor.role === 'admin'), - // ); + const canRead = (toRead: AccountId) => + policy( + 'account', + 'read', + (actor) => + Effect.gen(function* () { + if (actor.id === toRead || actor.role === 'admin') { + return yield* Effect.succeed(true); + } + const isPrivate = yield* accountRepo.with(toRead, (account) => + Effect.succeed(account.isPrivate), + ); + + if (isPrivate) { + return yield* Effect.succeed(false); + } + + return false; + }), + '대상의 계정이 비공개상태이거나, 유효한 권한이 없습니다.', + ); const canReadSensitive = (toRead: AccountId) => policy('account', 'readSensitive', (actor) => @@ -20,7 +40,7 @@ const make = Effect.gen(function* () { return { canUpdate, - // canRead, + canRead, canReadSensitive, } as const; }); @@ -29,5 +49,7 @@ export class AccountPolicy extends Effect.Tag('Account/AccountPolicy')< AccountPolicy, Effect.Effect.Success >() { - static Live = Layer.effect(AccountPolicy, make); + static layer = Layer.effect(AccountPolicy, make); + + static Live = this.layer.pipe(Layer.provide(AccountRepo.Live)); } diff --git a/src/account/account-service.mts b/src/account/account-service.mts index bfbb8ef..a45ff4e 100644 --- a/src/account/account-service.mts +++ b/src/account/account-service.mts @@ -16,11 +16,22 @@ import { Account, AccountId } from './account-schema.mjs'; import { SignIn } from './sign-in-schema.mjs'; import { SignUp } from './sign-up-schema.mjs'; import { VerifyTokenError } from '@/crypto/token-error.mjs'; +import { ChallengeEventService } from '@/challenge-event/challenge-event-service.mjs'; +import { CommentService } from '@/comment/comment-service.mjs'; +import { LikeService } from '@/like/like-service.mjs'; +import { PostService } from '@/post/post-service.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; +import { ChallengeService } from '@/challenge/challenge-service.mjs'; const make = Effect.gen(function* () { const accountRepo = yield* AccountRepo; const cryptoService = yield* CryptoService; const tokenService = yield* TokenService; + const postService = yield* PostService; + const commentService = yield* CommentService; + const challengeService = yield* ChallengeService; + const challengeEventService = yield* ChallengeEventService; + const likeService = yield* LikeService; const signUp = (signUp: SignUp) => { const program = Effect.gen(function* () { @@ -216,6 +227,34 @@ const make = Effect.gen(function* () { }), ); + const findPosts = (params: FindManyUrlParams, accountId: AccountId) => + postService + .findPosts(params, accountId) + .pipe(Effect.withSpan('AccountService.findPosts')); + + const findComments = (params: FindManyUrlParams, accountId: AccountId) => + commentService + .findAllPossiblyByAccountId(params, accountId) + .pipe(Effect.withSpan('AccountService.findPosts')); + + const findChallenges = (params: FindManyUrlParams, accountId: AccountId) => + challengeService + .findChallenges(params, accountId) + .pipe(Effect.withSpan('AccountService.findChallenges')); + + const findAllChallengeEvents = ( + params: FindManyUrlParams, + accountId: AccountId, + ) => + challengeEventService + .findChallengeEvents(params, accountId) + .pipe(Effect.withSpan('AccountService.findAllChallengeEvents')); + + const findAllLikes = (params: FindManyUrlParams, accountId: AccountId) => + likeService + .findAllLikes(params, accountId) + .pipe(Effect.withSpan('AccountService.findAllLikes')); + return { signUp, signIn, @@ -226,6 +265,11 @@ const make = Effect.gen(function* () { updateAccountById, embellishAccount, invalidate, + findPosts, + findComments, + findChallenges, + findAllChallengeEvents, + findAllLikes, } as const; }); @@ -239,6 +283,11 @@ export class AccountService extends Effect.Tag('AccountService')< Layer.provide(AccountRepo.Live), Layer.provide(CryptoService.Live), Layer.provide(TokenService.Live), + Layer.provide(PostService.Live), + Layer.provide(CommentService.Live), + Layer.provide(LikeService.Live), + Layer.provide(ChallengeService.Live), + Layer.provide(ChallengeEventService.Live), ); static Test = this.layer.pipe(Layer.provideMerge(SqlTest)); diff --git a/src/api.mts b/src/api.mts index 9aa430e..657e92a 100644 --- a/src/api.mts +++ b/src/api.mts @@ -36,6 +36,12 @@ export class Api extends HttpApi.empty OpenApi.annotations({ title: '오즈 6기 심화반 챌린지 서비스를 위한 백엔드', description: `최신변경점: +* 내가, 혹은 다른 누군가가 만든 챌린지 목록을 가져오는 기능 (2024-11-28.006) +* 내가, 혹은 다른 누군가가 참여중인 챌린지 목록을 가져오는 기능 (2024-11-28.006) +* 내가, 혹은 다른 누군가가 참여중인 챌린지 이벤트를 가져오는 기능 (2024-11-28.006) +* 내가, 혹은 다른 누군가가 쓴 글 목록을 가져오는 기능 (2024-11-28.006) +* 내가, 혹은 다른 누군가가 좋아요/싫어요한 글 목록을 가져오는 기능 (2024-11-28.006) +* 내가, 혹은 다른 누군가가 쓴 댓글 목록을 가져오는 기능 (2024-11-28.006) * 챌린지 / 게시글의 태그를 삭제하는 기능 (2024-11-28.005) * 게시글 삭제처리 softDelete로 변경 (2024-11-28.004) * 챌린지 태그연결 api 위치변경: tag -> challenge (2024-11-28.003) @@ -48,12 +54,7 @@ export class Api extends HttpApi.empty * [유저가 참여한 챌린지]와 [유저가 쓴 글], [유저가 만든 챌린지]의 태그를 찾아, 그를 프로필에 표시할 수 있게 지원하는 기능 (2024-11-27.001) 예정변경점: -* 내가 만든 챌린지 목록을 가져오는 기능 -* 내가 참여중인 챌린지 목록을 가져오는 기능 -* 내가 참여중인 챌린지 이벤트를 가져오는 기능 -* 내가 쓴 글 목록을 가져오는 기능 -* 내가 좋아요/싫어요한 글 목록을 가져오는 기능 -* 내가 쓴 댓글 목록을 가져오는 기능 +* 버그 리포트시 대응예정 `, version: version, override: {}, diff --git a/src/challenge-event/challenge-event-repo.mts b/src/challenge-event/challenge-event-repo.mts index 834c872..8db7cfc 100644 --- a/src/challenge-event/challenge-event-repo.mts +++ b/src/challenge-event/challenge-event-repo.mts @@ -10,6 +10,11 @@ import { ChallengeEventView, } from './challenge-event-schema.mjs'; import { FromStringToCoordinate, Meters } from './helper-schema.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; +import { AccountId } from '@/account/account-schema.mjs'; +import { CREATED_AT, DESC } from '@/sql/order-by.mjs'; +import { CommonCountSchema } from '@/misc/common-count-schema.mjs'; +import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; const TABLE_NAME = 'challenge_event'; @@ -23,6 +28,75 @@ const make = Effect.gen(function* () { idColumn: 'id', }); + const findAllChallengeEvents = ( + params: FindManyUrlParams, + accountId?: AccountId, + ) => + Effect.gen(function* () { + const posts = yield* SqlSchema.findAll({ + Request: FindManyUrlParams, + Result: Schema.Struct({ + ...ChallengeEventView.fields, + coordinate: Schema.NullishOr(FromStringToCoordinate), + }), + execute: () => + sql` +SELECT + ce.*, + ST_AsText(${sql('coordinate')}) as coordinate +FROM + challenge_event_participant cep +LEFT JOIN + ${sql(VIEW_NAME)} ce ON cep.challenge_event_id = ce.id +where + ${sql.and( + accountId + ? [sql`cep.account_id = ${accountId}`, sql`ce.is_deleted = false`] + : [sql`ce.is_deleted = false`], + )} +order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} +limit ${params.limit} +offset ${(params.page - 1) * params.limit}`, + })(params); + const { total } = yield* SqlSchema.single({ + Request: FindManyUrlParams, + Result: CommonCountSchema, + execute: () => + sql` +select + count(*) as total +FROM + challenge_event_participant cep +LEFT JOIN + ${sql(VIEW_NAME)} ce ON cep.challenge_event_id = ce.id +where + ${sql.and( + accountId + ? [sql`cep.account_id = ${accountId}`, sql`ce.is_deleted = false`] + : [sql`ce.is_deleted = false`], + )}`, + })(params); + + const ResultSchema = FindManyResultSchema( + Schema.Struct({ + ...ChallengeEventView.fields, + coordinate: Schema.NullishOr(FromStringToCoordinate), + }), + ); + + const result = ResultSchema.make({ + data: posts, + meta: { + total, + page: params.page, + limit: params.limit, + isLastPage: params.page * params.limit + posts.length >= total, + }, + }); + + return result; + }).pipe(Effect.orDie, Effect.withSpan('PostRepo.findAll')); + const findById = (id: ChallengeEventId) => SqlSchema.findOne({ Request: ChallengeEventId, @@ -140,6 +214,7 @@ where ${sql('id')} = ${request.id}; update, with: with_, findAllByChallengeId, + findAllChallengeEvents, getDistanceFromChallengeEvent, } as const; }); diff --git a/src/challenge-event/challenge-event-service.mts b/src/challenge-event/challenge-event-service.mts index 563f446..9f66a3e 100644 --- a/src/challenge-event/challenge-event-service.mts +++ b/src/challenge-event/challenge-event-service.mts @@ -1,4 +1,4 @@ -import { CurrentAccount } from '@/account/account-schema.mjs'; +import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; import { policyRequire } from '@/auth/authorization.mjs'; import { ChallengeId } from '@/challenge/challenge-schema.mjs'; import { Effect, Layer, Option, pipe, Schema } from 'effect'; @@ -14,6 +14,7 @@ import { ChallengeEventCheckResponse, FromStringToCoordinate, } from './helper-schema.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; const DISTANCE_THRESHOLD = 1000; @@ -21,6 +22,11 @@ const make = Effect.gen(function* () { const repo = yield* ChallengeEventRepo; const challengeEventParticipantRepo = yield* ChallengeEventParticipantRepo; + const findChallengeEvents = ( + params: FindManyUrlParams, + accountId?: AccountId, + ) => repo.findAllChallengeEvents(params, accountId); + const findAllByChallengeId = (challengeId: ChallengeId) => repo .findAllByChallengeId(challengeId) @@ -233,6 +239,7 @@ const make = Effect.gen(function* () { return { findById, findAllByChallengeId, + findChallengeEvents, create, update, deleteById, diff --git a/src/challenge/challenge-repo.mts b/src/challenge/challenge-repo.mts index e3d0a2b..796f20a 100644 --- a/src/challenge/challenge-repo.mts +++ b/src/challenge/challenge-repo.mts @@ -8,6 +8,7 @@ import { Effect, Layer, Option, pipe, Schema } from 'effect'; import { ChallengeNotFound } from './challenge-error.mjs'; import { Challenge, ChallengeId, ChallengeView } from './challenge-schema.mjs'; import { Tag } from '@/tag/tag-schema.mjs'; +import { AccountId } from '@/account/account-schema.mjs'; const snakeCase = (str: string) => str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); @@ -44,7 +45,7 @@ WHERE c.id = ${req};`, Effect.withSpan('ChallengeRepo.findTags'), ); - const findAllWithView = (params: FindManyUrlParams) => + const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => Effect.gen(function* () { const challenges = yield* SqlSchema.findAll({ Request: FindManyUrlParams, @@ -52,7 +53,11 @@ WHERE c.id = ${req};`, execute: (req) => sql`select * from ${sql(VIEW_NAME)} -where ${sql('is_deleted')} = false +where ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )} order by ${sql(snakeCase(params.sortBy))} ${sql.unsafe(params.order)} limit ${params.limit} @@ -62,7 +67,11 @@ offset ${(params.page - 1) * params.limit}`, Request: FindManyUrlParams, Result: CommonCountSchema, execute: () => - sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql('is_deleted')} = false`, + sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )}`, })(params); const ResultSchema = FindManyResultSchema(ChallengeView); diff --git a/src/challenge/challenge-service.mts b/src/challenge/challenge-service.mts index d40aba2..5c9920a 100644 --- a/src/challenge/challenge-service.mts +++ b/src/challenge/challenge-service.mts @@ -1,4 +1,4 @@ -import { CurrentAccount } from '@/account/account-schema.mjs'; +import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; import { policyRequire } from '@/auth/authorization.mjs'; import { LikeService } from '@/like/like-service.mjs'; import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; @@ -19,9 +19,9 @@ const make = Effect.gen(function* () { const findByIdFromRepo = (id: ChallengeId) => challengeRepo.findById(id); - const findChallenges = (params: FindManyUrlParams) => + const findChallenges = (params: FindManyUrlParams, accountId?: AccountId) => challengeRepo - .findAllWithView(params) + .findAllWithView(params, accountId) .pipe(Effect.withSpan('ChallengeService.findChallenges')); const findTags = (challengeId: ChallengeId) => diff --git a/src/comment/comment-repo.mts b/src/comment/comment-repo.mts index 5578f16..8ed6371 100644 --- a/src/comment/comment-repo.mts +++ b/src/comment/comment-repo.mts @@ -9,6 +9,7 @@ import { Model, SqlClient, SqlSchema } from '@effect/sql'; import { Effect, Layer, Option, pipe } from 'effect'; import { CommentNotFound } from './comment-error.mjs'; import { Comment, CommentId, CommentView } from './comment-schema.mjs'; +import { AccountId } from '@/account/account-schema.mjs'; const TABLE_NAME = 'comment'; const LIKE_VIEW_NAME = 'comment_like_counts'; @@ -34,18 +35,67 @@ const make = Effect.gen(function* () { idColumn: 'id', }); + const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => + Effect.gen(function* () { + const comments = yield* SqlSchema.findAll({ + Request: FindManyUrlParams, + Result: CommentView, + execute: () => + sql`select * from ${sql(LIKE_VIEW_NAME)} where + ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )} + order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, + })(params); + const { total } = yield* SqlSchema.single({ + Request: FindManyUrlParams, + Result: CommonCountSchema, + execute: () => + sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )}`, + })(params); + + const ResultSchema = FindManyResultSchema(CommentView); + + const result = ResultSchema.make({ + data: comments, + meta: { + total, + page: params.page, + limit: params.limit, + isLastPage: params.page * params.limit + comments.length >= total, + }, + }); + + return result; + }).pipe( + Effect.orDie, + Effect.withSpan('CommentRepo.findAllByPostIdWithView'), + ); + const findAllByPostIdWithView = (postId: PostId, params: FindManyUrlParams) => Effect.gen(function* () { const comments = yield* SqlSchema.findAll({ Request: PostId, Result: CommentView, execute: () => - sql`select * from ${sql(LIKE_VIEW_NAME)} where post_id = ${postId} order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, + sql`select * from ${sql(LIKE_VIEW_NAME)} where + ${sql.and([sql`post_id = ${postId}`, sql`is_deleted = false`])} + order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, })(postId); const { total } = yield* SqlSchema.single({ Request: FindManyUrlParams, Result: CommonCountSchema, - execute: () => sql`select count(*) as total from ${sql(TABLE_NAME)}`, + execute: () => + sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and([ + sql`post_id = ${postId}`, + sql`is_deleted = false`, + ])}`, })(params); const ResultSchema = FindManyResultSchema(CommentView); @@ -107,6 +157,7 @@ const make = Effect.gen(function* () { likeViewRepo: { ...likeViewRepo, findAllByPostId: findAllByPostIdWithView, + findAllWithView, }, commentViewRepo, with: with_, diff --git a/src/comment/comment-schema.mts b/src/comment/comment-schema.mts index 8e45a57..e2d0565 100644 --- a/src/comment/comment-schema.mts +++ b/src/comment/comment-schema.mts @@ -86,6 +86,7 @@ const viewFields = { export class CommentView extends Model.Class('Comment')({ ...viewFields, + postTitle: Schema.String, id: Model.Generated(fields.id), createdAt: CustomDateTimeInsert, updatedAt: CustomDateTimeUpdate, diff --git a/src/comment/comment-service.mts b/src/comment/comment-service.mts index b62c889..c5f9be6 100644 --- a/src/comment/comment-service.mts +++ b/src/comment/comment-service.mts @@ -5,7 +5,7 @@ import { SqlTest } from '@/sql/sql-test.mjs'; import { Effect, Layer, Option, pipe } from 'effect'; import { CommentRepo } from './comment-repo.mjs'; import { Comment, CommentId } from './comment-schema.mjs'; -import { CurrentAccount } from '@/account/account-schema.mjs'; +import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; import { policyRequire } from '@/auth/authorization.mjs'; import { LikeService } from '@/like/like-service.mjs'; import { PostNotFound } from '@/post/post-error.mjs'; @@ -15,6 +15,11 @@ const make = Effect.gen(function* () { const commentRepo = yield* CommentRepo; const postRepo = yield* PostRepo; + const findAllPossiblyByAccountId = ( + params: FindManyUrlParams, + accountId?: AccountId, + ) => commentRepo.likeViewRepo.findAllWithView(params, accountId); + const findAllByPostId = (postId: PostId, params: FindManyUrlParams) => commentRepo.likeViewRepo.findAllByPostId(postId, params); @@ -130,6 +135,7 @@ const make = Effect.gen(function* () { update, deleteById, findAllByPostId, + findAllPossiblyByAccountId, findLikeStatus, findByIdWithView, getCommentCount, diff --git a/src/like/like-repo.mts b/src/like/like-repo.mts index 0586745..bee496e 100644 --- a/src/like/like-repo.mts +++ b/src/like/like-repo.mts @@ -1,15 +1,19 @@ import { AccountId } from '@/account/account-schema.mjs'; +import { ChallengeEventId } from '@/challenge-event/challenge-event-schema.mjs'; +import { ChallengeId } from '@/challenge/challenge-schema.mjs'; +import { CommentId } from '@/comment/comment-schema.mjs'; +import { CommonCountSchema } from '@/misc/common-count-schema.mjs'; +import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; import { makeTestLayer } from '@/misc/test-layer.mjs'; import { PostId } from '@/post/post-schema.mjs'; +import { CREATED_AT, DESC } from '@/sql/order-by.mjs'; import { SqlLive } from '@/sql/sql-live.mjs'; import { Model, SqlClient, SqlSchema } from '@effect/sql'; -import { Effect, Layer, Option, pipe } from 'effect'; +import { Effect, Layer, Option, pipe, Schema } from 'effect'; import { LikeConflict, LikeNotFound } from './like-error.mjs'; import { Like, LikeId, LikeType } from './like-schema.mjs'; import { LikeSelector, likeSelectorsToWhere } from './like-selector-schema.mjs'; -import { CommentId } from '@/comment/comment-schema.mjs'; -import { ChallengeId } from '@/challenge/challenge-schema.mjs'; -import { ChallengeEventId } from '@/challenge-event/challenge-event-schema.mjs'; const LIKE_TABLE = 'like'; @@ -21,6 +25,43 @@ const make = Effect.gen(function* () { idColumn: 'id', }); + const findAllLikes = (params: FindManyUrlParams, accountId?: AccountId) => + Effect.gen(function* () { + const likes = yield* SqlSchema.findAll({ + Request: FindManyUrlParams, + Result: Like, + execute: () => + sql`select * from ${sql(LIKE_TABLE)} where + ${sql.and(accountId ? [sql`account_id = ${accountId}`] : [])} + order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} + limit ${params.limit} + offset ${(params.page - 1) * params.limit}`, + })(params); + + const { total } = yield* SqlSchema.single({ + Request: FindManyUrlParams, + Result: CommonCountSchema, + execute: () => + sql`select count(*) as total from ${sql(LIKE_TABLE)} where ${sql.and( + accountId ? [sql`account_id = ${accountId}`] : [], + )}`, + })(params); + + const ResultSchema = FindManyResultSchema(Schema.Any); + + const result = ResultSchema.make({ + data: likes, + meta: { + total, + page: params.page, + limit: params.limit, + isLastPage: params.page * params.limit + likes.length >= total, + }, + }); + + return result; + }).pipe(Effect.orDie, Effect.withSpan('LikeRepo.findAllLikes')); + const with_ = ( id: LikeId, f: (like: Like) => Effect.Effect, @@ -246,6 +287,7 @@ const make = Effect.gen(function* () { return { ...repo, + findAllLikes, createPostLike, createPostDislike, createCommentLike, diff --git a/src/like/like-service.mts b/src/like/like-service.mts index c466f53..dbbd077 100644 --- a/src/like/like-service.mts +++ b/src/like/like-service.mts @@ -1,4 +1,4 @@ -import { CurrentAccount } from '@/account/account-schema.mjs'; +import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; import { PostId } from '@/post/post-schema.mjs'; import { SqlTest } from '@/sql/sql-test.mjs'; import { Effect, Layer, pipe } from 'effect'; @@ -6,10 +6,14 @@ import { LikeRepo } from './like-repo.mjs'; import { CommentId } from '@/comment/comment-schema.mjs'; import { ChallengeId } from '@/challenge/challenge-schema.mjs'; import { ChallengeEventId } from '@/challenge-event/challenge-event-schema.mjs'; +import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; const make = Effect.gen(function* () { const likeRepo = yield* LikeRepo; + const findAllLikes = (params: FindManyUrlParams, accountId?: AccountId) => + likeRepo.findAllLikes(params, accountId); + const getLikeStatusByPostId = (postId: PostId) => pipe( CurrentAccount, @@ -444,6 +448,8 @@ const make = Effect.gen(function* () { ); return { + findAllLikes, + addLikePostById, removeLikePostById, addDislikePostById, diff --git a/src/post/post-repo.mts b/src/post/post-repo.mts index 1fc9d56..17780a1 100644 --- a/src/post/post-repo.mts +++ b/src/post/post-repo.mts @@ -9,6 +9,7 @@ import { Effect, Layer, Option, pipe } from 'effect'; import { PostNotFound } from './post-error.mjs'; import { Post, PostId, PostView } from './post-schema.mjs'; import { Tag } from '@/tag/tag-schema.mjs'; +import { AccountId } from '@/account/account-schema.mjs'; const TABLE_NAME = 'post'; const VIEW_NAME = 'post_like_counts'; @@ -39,19 +40,31 @@ LEFT JOIN post p ON tt.post_id = p.id WHERE p.id = ${req};`, })(postId).pipe(Effect.orDie, Effect.withSpan('PostRepo.findTags')); - const findAllWithView = (params: FindManyUrlParams) => + const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => Effect.gen(function* () { const posts = yield* SqlSchema.findAll({ Request: FindManyUrlParams, Result: PostView, execute: () => - sql`select * from ${sql(VIEW_NAME)} where ${sql('is_deleted')} = false order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, + sql`select * from ${sql(VIEW_NAME)} where + ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )} + order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} + limit ${params.limit} + offset ${(params.page - 1) * params.limit}`, })(params); const { total } = yield* SqlSchema.single({ Request: FindManyUrlParams, Result: CommonCountSchema, execute: () => - sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql('is_deleted')} = false`, + sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( + accountId + ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] + : [sql`is_deleted = false`], + )}`, })(params); const ResultSchema = FindManyResultSchema(PostView); diff --git a/src/post/post-service.mts b/src/post/post-service.mts index 7911d20..03a1189 100644 --- a/src/post/post-service.mts +++ b/src/post/post-service.mts @@ -1,4 +1,4 @@ -import { CurrentAccount } from '@/account/account-schema.mjs'; +import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; import { policyRequire } from '@/auth/authorization.mjs'; import { LikeService } from '@/like/like-service.mjs'; import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; @@ -16,9 +16,9 @@ const make = Effect.gen(function* () { const findByIdFromRepo = (postId: PostId) => postRepo.findById(postId); - const findPosts = (params: FindManyUrlParams) => + const findPosts = (params: FindManyUrlParams, accountId?: AccountId) => postRepo - .findAllWithView(params) + .findAllWithView(params, accountId) .pipe(Effect.withSpan('PostService.findPosts')); const findByIdWithView = (postId: PostId) => diff --git a/src/sql/migrations/00003_add_views.ts b/src/sql/migrations/00003_add_views.ts index 1f99a97..3c48654 100644 --- a/src/sql/migrations/00003_add_views.ts +++ b/src/sql/migrations/00003_add_views.ts @@ -82,19 +82,23 @@ GROUP BY CREATE VIEW comment_like_counts AS SELECT comment.*, + post.title as post_title, account.username as account_username, COALESCE(SUM(CASE WHEN "like".type = 'like' THEN "like".count ELSE 0 END), 0)::integer AS like_count, COALESCE(SUM(CASE WHEN "like".type = 'dislike' THEN "like".count ELSE 0 END), 0)::integer AS dislike_count, COALESCE(SUM(CASE WHEN "like".type = 'like' THEN "like".count ELSE 0 END), 0)::integer - COALESCE(SUM(CASE WHEN "like".type = 'dislike' THEN "like".count ELSE 0 END), 0)::integer as pure_like_count FROM comment +left join + "post" on post.id = comment.post_id LEFT JOIN "like" ON comment.id = "like".comment_id LEFT JOIN account ON comment.account_id = account.id GROUP BY comment.id, - account.id; + account.id, + post.title; -------------------------------------------------------------------------