├── .ebextensions ├── 00_custom.config ├── healthcheck.config └── localtime.config ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .platform └── hooks │ └── prebuild │ └── 00_pnpm_install.sh ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── Procfile ├── README.md ├── package.json ├── patches └── postgres@3.4.5.patch ├── pnpm-lock.yaml ├── rollup.config.js ├── src ├── account │ ├── account-api-live.mts │ ├── account-api.mts │ ├── account-block-schema.mts │ ├── account-error.mts │ ├── account-policy.mts │ ├── account-repo.mts │ ├── account-schema.mts │ ├── account-service.mts │ ├── follow-schema.mts │ ├── sign-in-schema.mts │ └── sign-up-schema.mts ├── api-live.mts ├── api.mts ├── auth │ ├── access-token.mts │ ├── authentication.mts │ ├── authorization.mts │ ├── error-401.mts │ └── error-403.mts ├── challenge-event │ ├── challenge-event-api-live.mts │ ├── challenge-event-api.mts │ ├── challenge-event-error.mts │ ├── challenge-event-participant-error.mts │ ├── challenge-event-participant-repo.mts │ ├── challenge-event-participant-schema.mts │ ├── challenge-event-policy.mts │ ├── challenge-event-repo.mts │ ├── challenge-event-schema.mts │ ├── challenge-event-service.mts │ └── helper-schema.mts ├── challenge │ ├── challenge-api-live.mts │ ├── challenge-api.mts │ ├── challenge-error.mts │ ├── challenge-participant-error.mts │ ├── challenge-participant-repo.mts │ ├── challenge-participant-schema.mts │ ├── challenge-participant-service.mts │ ├── challenge-policy.mts │ ├── challenge-repo.mts │ ├── challenge-schema.mts │ └── challenge-service.mts ├── comment │ ├── comment-api-live.mts │ ├── comment-api.mts │ ├── comment-error.mts │ ├── comment-policy.mts │ ├── comment-repo.mts │ ├── comment-schema.mts │ └── comment-service.mts ├── crypto │ ├── crypto-error.mts │ ├── crypto-service.mts │ ├── token-error.mts │ ├── token-schema.mts │ └── token-service.mts ├── file │ ├── file-api-live.mts │ ├── file-api.mts │ ├── file-error.mts │ ├── file-service.mts │ ├── image-info-schema.mts │ ├── image-path-schema.mts │ └── image-target-schema.mts ├── index.mts ├── like │ ├── like-error.mts │ ├── like-repo.mts │ ├── like-schema.mts │ ├── like-selector-schema.mts │ └── like-service.mts ├── message │ ├── message-channel-member-schema.mts │ ├── message-channel-schema.mts │ └── message-schema.mts ├── misc │ ├── common-count-schema.mts │ ├── common-error.mts │ ├── config-service.mts │ ├── date-schema.mts │ ├── email-schema.mts │ ├── empty-schema.mts │ ├── find-many-result-schema.mts │ ├── find-many-url-params-schema.mts │ ├── security-remove-cookie.mts │ ├── security.mts │ ├── test-layer.mts │ └── uuid-context.mts ├── notification │ └── notification-schema.mts ├── post │ ├── post-api-live.mts │ ├── post-api.mts │ ├── post-error.mts │ ├── post-policy.mts │ ├── post-repo.mts │ ├── post-schema.mts │ └── post-service.mts ├── root-api-live.mts ├── root-api.mts ├── root-service.mts ├── sql │ ├── migrations │ │ ├── 00001_create_base_schema.ts │ │ ├── 00002_add_properties_for_account.ts │ │ ├── 00003_add_views.ts │ │ └── README.md │ ├── order-by.mts │ ├── postgres-client-live.mts │ ├── postgres-migrator-live.mts │ ├── sql-live.mts │ └── sql-test.mts ├── supabase │ └── supabase-service.mts └── tag │ ├── account-interest-tag.mts │ ├── tag-api-live.mts │ ├── tag-api.mts │ ├── tag-error.mts │ ├── tag-policy.mts │ ├── tag-repo.mts │ ├── tag-schema.mts │ ├── tag-service.mts │ └── tag-target-schema.mts ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.src.json ├── tsconfig.test.json └── vitest.config.ts /.ebextensions/00_custom.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/opt/elasticbeanstalk/hooks/appdeploy/pre/00_pnpm_install.sh": 3 | mode: "000755" 4 | owner: root 5 | group: root 6 | content: | 7 | #!/bin/bash 8 | # Skip pnpm install during prebuild phase 9 | if [ -f /opt/elasticbeanstalk/hooks/appdeploy/pre/50npm.sh ]; then 10 | mv /opt/elasticbeanstalk/hooks/appdeploy/pre/50npm.sh /opt/elasticbeanstalk/hooks/appdeploy/pre/50npm.sh.bak 11 | fi -------------------------------------------------------------------------------- /.ebextensions/healthcheck.config: -------------------------------------------------------------------------------- 1 | option_settings: 2 | aws:elasticbeanstalk:application: 3 | Application Healthcheck URL: /api/health 4 | -------------------------------------------------------------------------------- /.ebextensions/localtime.config: -------------------------------------------------------------------------------- 1 | commands: 2 | set_time_zone: 3 | command: sudo ln -f -s /user/share/zoneinfo/Asia/Seoul /etc/localtime -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | env: 2 | NODE_VERSION: '20.x' 3 | TZ: 'Asia/Seoul' 4 | 5 | on: 6 | pull_request: 7 | branches-ignore: 8 | - 'release/**' # This ignores PRs targeting branches that match the pattern, but further filtering might be necessary in jobs 9 | - 'main' 10 | types: [opened, synchronize, reopened] 11 | push: 12 | branches-ignore: 13 | - 'release/**' # This tries to ignore pushes to branches that match the pattern, but further filtering might be necessary in jobs 14 | - 'main' 15 | pull_request_review: 16 | types: [submitted] 17 | 18 | jobs: 19 | install_dependencies: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: latest 27 | - name: Setup Node.js with pnpm cache 28 | id: setup-node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ env.NODE_VERSION }} 32 | cache: 'pnpm' 33 | - name: Install dependencies 34 | run: pnpm install --frozen-lockfile 35 | 36 | # test: 37 | # needs: install_dependencies 38 | # runs-on: ubuntu-latest 39 | # steps: 40 | # - uses: actions/checkout@v4 41 | # - name: Install pnpm 42 | # uses: pnpm/action-setup@v2 43 | # with: 44 | # version: 8 45 | # - name: Use Node.js ${{ env.NODE_VERSION }} 46 | # uses: actions/setup-node@v4 47 | # with: 48 | # node-version: ${{ env.NODE_VERSION }} 49 | # cache: 'pnpm' 50 | # - name: Restore dependencies 51 | # run: pnpm install --no-frozen-lockfile 52 | # - name: Run Tests 53 | # run: pnpm run test 54 | 55 | build: 56 | runs-on: ubuntu-latest 57 | needs: install_dependencies 58 | # needs: test 59 | if: github.event_name == 'push' 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Install pnpm 63 | uses: pnpm/action-setup@v4 64 | with: 65 | version: 8 66 | - name: Use Node.js ${{ env.NODE_VERSION }} 67 | uses: actions/setup-node@v4 68 | with: 69 | node-version: ${{ env.NODE_VERSION }} 70 | cache: 'pnpm' 71 | - name: Restore dependencies 72 | run: pnpm install --no-frozen-lockfile 73 | - name: Build 74 | run: pnpm run build 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | env: 2 | NODE_VERSION: '20.x' 3 | TZ: 'Asia/Seoul' 4 | 5 | on: 6 | push: 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: 11 | - 'main' 12 | 13 | jobs: 14 | build_and_deploy: 15 | runs-on: ubuntu-latest 16 | # if: contains(github.ref, 'refs/heads/release/') 17 | steps: 18 | - uses: actions/checkout@v4 19 | # - name: extract branch part 20 | # id: extract_release_environment 21 | # run: | 22 | # BRANCH_NAME="${GITHUB_REF#refs/heads/}" # Strip refs/heads/ 23 | # IFS='/' read -ra PARTS <<< "$BRANCH_NAME" # Split into array 24 | # if [ ${#PARTS[@]} -gt 1 ]; then 25 | # echo "::set-output name=release_environment::${PARTS[1]}" 26 | # else 27 | # echo "Branch does not have a second part." 28 | # echo "::set-output name=release_environment::" 29 | # fi 30 | # env: 31 | # GITHUB_REF: ${{ github.ref }} 32 | - name: Install pnpm 33 | uses: pnpm/action-setup@v4 34 | with: 35 | version: latest 36 | - name: Use Node.js ${{ env.NODE_VERSION }} 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ env.NODE_VERSION }} 40 | - name: Restore dependencies 41 | run: pnpm install --no-frozen-lockfile 42 | - name: Build 43 | run: pnpm run build 44 | - name: Prevent npm install on Elastic Beanstalk 45 | run: | 46 | rm -rf ./node_modules 47 | mkdir node_modules 48 | touch node_modules/.gitkeep 49 | - name: Zip 50 | run: zip -r artifact.zip ./dist package.json Procfile ./.platform ./.ebextensions ./node_modules 51 | - name: Upload zip as artifact 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: artifact 55 | path: artifact.zip 56 | - name: Get current time 57 | uses: 1466587594/get-current-time@v2 58 | id: current-time 59 | with: 60 | format: YYYY-MM-DDTHH-mm-ss 61 | utcOffset: '+09:00' 62 | - name: Deploy to EB 63 | uses: einaregilsson/beanstalk-deploy@v22 64 | with: 65 | aws_access_key: ${{ secrets.AWS_ACCESSKEY }} 66 | aws_secret_key: ${{ secrets.AWS_SECRETKEY }} 67 | application_name: ozadv 68 | environment_name: Ozadv-env-1 69 | version_label: github-action-${{steps.current-time.outputs.formattedTime}} 70 | region: ap-northeast-2 71 | deployment_package: artifact.zip 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node,react,turbo,macos,windows,linux,yarn,visualstudiocode,terraform 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,react,turbo,macos,windows,linux,yarn,visualstudiocode,terraform 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Node ### 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | lerna-debug.log* 60 | .pnpm-debug.log* 61 | 62 | # Diagnostic reports (https://nodejs.org/api/report.html) 63 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 64 | 65 | # Runtime data 66 | pids 67 | *.pid 68 | *.seed 69 | *.pid.lock 70 | 71 | # Directory for instrumented libs generated by jscoverage/JSCover 72 | lib-cov 73 | 74 | # Coverage directory used by tools like istanbul 75 | coverage 76 | *.lcov 77 | 78 | # nyc test coverage 79 | .nyc_output 80 | 81 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 82 | .grunt 83 | 84 | # Bower dependency directory (https://bower.io/) 85 | bower_components 86 | 87 | # node-waf configuration 88 | .lock-wscript 89 | 90 | # Compiled binary addons (https://nodejs.org/api/addons.html) 91 | build/Release 92 | 93 | # Dependency directories 94 | node_modules/ 95 | jspm_packages/ 96 | 97 | # Snowpack dependency directory (https://snowpack.dev/) 98 | web_modules/ 99 | 100 | # TypeScript cache 101 | *.tsbuildinfo 102 | 103 | # Optional npm cache directory 104 | .npm 105 | 106 | # Optional eslint cache 107 | .eslintcache 108 | 109 | # Optional stylelint cache 110 | .stylelintcache 111 | 112 | # Microbundle cache 113 | .rpt2_cache/ 114 | .rts2_cache_cjs/ 115 | .rts2_cache_es/ 116 | .rts2_cache_umd/ 117 | 118 | # Optional REPL history 119 | .node_repl_history 120 | 121 | # Output of 'npm pack' 122 | *.tgz 123 | 124 | # Yarn Integrity file 125 | .yarn-integrity 126 | 127 | # dotenv environment variable files 128 | .env 129 | .env.development.local 130 | .env.test.local 131 | .env.production.local 132 | .env.local 133 | 134 | # parcel-bundler cache (https://parceljs.org/) 135 | .cache 136 | .parcel-cache 137 | 138 | # Next.js build output 139 | .next 140 | out 141 | 142 | # Nuxt.js build / generate output 143 | .nuxt 144 | dist 145 | 146 | # Gatsby files 147 | .cache/ 148 | # Comment in the public line in if your project uses Gatsby and not Next.js 149 | # https://nextjs.org/blog/next-9-1#public-directory-support 150 | # public 151 | 152 | # vuepress build output 153 | .vuepress/dist 154 | 155 | # vuepress v2.x temp and cache directory 156 | .temp 157 | 158 | # Docusaurus cache and generated files 159 | .docusaurus 160 | 161 | # Serverless directories 162 | .serverless/ 163 | 164 | # FuseBox cache 165 | .fusebox/ 166 | 167 | # DynamoDB Local files 168 | .dynamodb/ 169 | 170 | # TernJS port file 171 | .tern-port 172 | 173 | # Stores VSCode versions used for testing VSCode extensions 174 | .vscode-test 175 | 176 | # yarn v2 177 | .yarn/cache 178 | .yarn/unplugged 179 | .yarn/build-state.yml 180 | .yarn/install-state.gz 181 | .pnp.* 182 | 183 | ### Node Patch ### 184 | # Serverless Webpack directories 185 | .webpack/ 186 | 187 | # Optional stylelint cache 188 | 189 | # SvelteKit build / generate output 190 | .svelte-kit 191 | 192 | ### react ### 193 | .DS_* 194 | **/*.backup.* 195 | **/*.back.* 196 | 197 | node_modules 198 | 199 | *.sublime* 200 | 201 | psd 202 | thumb 203 | sketch 204 | 205 | ### Terraform ### 206 | # Local .terraform directories 207 | **/.terraform/* 208 | 209 | # .tfstate files 210 | *.tfstate 211 | *.tfstate.* 212 | 213 | # Crash log files 214 | crash.log 215 | crash.*.log 216 | 217 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 218 | # password, private keys, and other secrets. These should not be part of version 219 | # control as they are data points which are potentially sensitive and subject 220 | # to change depending on the environment. 221 | *.tfvars 222 | *.tfvars.json 223 | 224 | # Ignore override files as they are usually used to override resources locally and so 225 | # are not checked in 226 | override.tf 227 | override.tf.json 228 | *_override.tf 229 | *_override.tf.json 230 | 231 | # Include override files you do wish to add to version control using negated pattern 232 | # !example_override.tf 233 | 234 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 235 | # example: *tfplan* 236 | 237 | # Ignore CLI configuration files 238 | .terraformrc 239 | terraform.rc 240 | 241 | ### Turbo ### 242 | # Turborepo task cache 243 | .turbo 244 | 245 | ### VisualStudioCode ### 246 | .vscode/* 247 | !.vscode/settings.json 248 | !.vscode/tasks.json 249 | !.vscode/launch.json 250 | !.vscode/extensions.json 251 | !.vscode/*.code-snippets 252 | 253 | # Local History for Visual Studio Code 254 | .history/ 255 | 256 | # Built Visual Studio Code Extensions 257 | *.vsix 258 | 259 | ### VisualStudioCode Patch ### 260 | # Ignore all local history of files 261 | .history 262 | .ionide 263 | 264 | ### Windows ### 265 | # Windows thumbnail cache files 266 | Thumbs.db 267 | Thumbs.db:encryptable 268 | ehthumbs.db 269 | ehthumbs_vista.db 270 | 271 | # Dump file 272 | *.stackdump 273 | 274 | # Folder config file 275 | [Dd]esktop.ini 276 | 277 | # Recycle Bin used on file shares 278 | $RECYCLE.BIN/ 279 | 280 | # Windows Installer files 281 | *.cab 282 | *.msi 283 | *.msix 284 | *.msm 285 | *.msp 286 | 287 | # Windows shortcuts 288 | *.lnk 289 | 290 | ### yarn ### 291 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 292 | 293 | .yarn/* 294 | !.yarn/releases 295 | !.yarn/patches 296 | !.yarn/plugins 297 | !.yarn/sdks 298 | !.yarn/versions 299 | 300 | # if you are NOT using Zero-installs, then: 301 | # comment the following lines 302 | !.yarn/cache 303 | 304 | # and uncomment the following lines 305 | # .pnp.* 306 | 307 | artifact.zip 308 | 309 | # End of https://www.toptal.com/developers/gitignore/api/node,react,turbo,macos,windows,linux,yarn,visualstudiocode,terraform -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shared-workspace-lockfile=false -------------------------------------------------------------------------------- /.platform/hooks/prebuild/00_pnpm_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Skip npm install 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | 5 | # Ignore all HTML files: 6 | **/*.html 7 | 8 | # Ignore all files in the node_modules directory 9 | **/.git 10 | **/.svn 11 | **/.hg 12 | **/node_modules 13 | 14 | # Ignore all files in the dist directory 15 | **/dist 16 | 17 | # Ignore all config files 18 | pnpm-lock.yaml 19 | tsconfig.json 20 | tsconfig.build.json 21 | tsconfig.base.json 22 | tsconfig.test.json 23 | tsconfig.src.json 24 | vitest.config.ts 25 | rollup.config.js 26 | Procfile 27 | .gitignore 28 | **/.platform 29 | **/.vscode 30 | **/.platform 31 | **/.github 32 | **/.ebextensions -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "trailingComma": "all" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: NO_COLOR=true node dist/index.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Effect Backend 2 | . 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oz-adv/backend", 3 | "version": "0.0.2 (2024-11-28.006)", 4 | "description": "Backend for the Oz-Adv project", 5 | "type": "module", 6 | "scripts": { 7 | "start": "node --env-file=.env dist/index.js", 8 | "build": "rm -rf ./dist && rollup -c", 9 | "watch": "rollup -cw", 10 | "dev": "tsx --env-file=.env --watch src/index.mts", 11 | "test": "vitest", 12 | "format": "prettier --write .", 13 | "tsc": "tsc" 14 | }, 15 | "dependencies": { 16 | "@effect/experimental": "^0.30.12", 17 | "@effect/opentelemetry": "^0.39.2", 18 | "@effect/platform": "^0.69.11", 19 | "@effect/platform-node": "^0.64.12", 20 | "@effect/schema": "^0.75.5", 21 | "@effect/sql": "^0.18.12", 22 | "@effect/sql-pg": "^0.18.7", 23 | "@supabase/storage-js": "^2.7.1", 24 | "@supabase/supabase-js": "^2.46.1", 25 | "effect": "^3.10.6", 26 | "jose": "^5.9.6", 27 | "postgres": "^3.4.5", 28 | "undici": "^6.20.1" 29 | }, 30 | "devDependencies": { 31 | "@effect/language-service": "^0.2.0", 32 | "@effect/vitest": "^0.13.2", 33 | "@rollup/plugin-alias": "^5.1.1", 34 | "@rollup/plugin-commonjs": "^28.0.1", 35 | "@rollup/plugin-json": "^6.1.0", 36 | "@rollup/plugin-multi-entry": "^6.0.1", 37 | "@rollup/plugin-node-resolve": "^15.3.0", 38 | "@rollup/plugin-swc": "^0.4.0", 39 | "@rollup/plugin-typescript": "^12.1.1", 40 | "@swc-node/core": "^1.13.3", 41 | "@swc/cli": "0.4.1-nightly.20240914", 42 | "@swc/core": "^1.7.39", 43 | "@types/node": "^22.7.9", 44 | "@types/uuid": "^10.0.0", 45 | "prettier": "^3.3.3", 46 | "rollup": "^4.24.0", 47 | "tsx": "^4.19.1", 48 | "typescript": "^5.6.3", 49 | "vitest": "^2.1.3" 50 | }, 51 | "pnpm": { 52 | "patchedDependencies": { 53 | "postgres@3.4.5": "patches/postgres@3.4.5.patch" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /patches/postgres@3.4.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/cf/src/large.js b/cf/src/large.js 2 | index 8ae150ddfd478d5f490cc0978eea0698fb29ed6d..85b3a87330e6ddf709ef58dbd33a40d70fd4e989 100644 3 | --- a/cf/src/large.js 4 | +++ b/cf/src/large.js 5 | @@ -3,7 +3,7 @@ import Stream from 'node:stream' 6 | export default function largeObject(sql, oid, mode = 0x00020000 | 0x00040000) { 7 | return new Promise(async(resolve, reject) => { 8 | await sql.begin(async sql => { 9 | - let finish 10 | + let finish; 11 | !oid && ([{ oid }] = await sql`select lo_creat(-1) as oid`) 12 | const [{ fd }] = await sql`select lo_open(${ oid }, ${ mode }) as fd` 13 | 14 | diff --git a/cjs/src/large.js b/cjs/src/large.js 15 | index 281b088a06f35fe52021da6b4255998cdecbfc3c..21866c7081d1732ad22e9a3ef020791fad6da8db 100644 16 | --- a/cjs/src/large.js 17 | +++ b/cjs/src/large.js 18 | @@ -3,7 +3,7 @@ const Stream = require('stream') 19 | module.exports = largeObject;function largeObject(sql, oid, mode = 0x00020000 | 0x00040000) { 20 | return new Promise(async(resolve, reject) => { 21 | await sql.begin(async sql => { 22 | - let finish 23 | + let finish; 24 | !oid && ([{ oid }] = await sql`select lo_creat(-1) as oid`) 25 | const [{ fd }] = await sql`select lo_open(${ oid }, ${ mode }) as fd` 26 | 27 | diff --git a/src/large.js b/src/large.js 28 | index f46329677cfba356d447ad163c9c1502d8d16370..858c44b9c6ed7fb4519e23e3e9aab242b6f8d37a 100644 29 | --- a/src/large.js 30 | +++ b/src/large.js 31 | @@ -3,7 +3,7 @@ import Stream from 'stream' 32 | export default function largeObject(sql, oid, mode = 0x00020000 | 0x00040000) { 33 | return new Promise(async(resolve, reject) => { 34 | await sql.begin(async sql => { 35 | - let finish 36 | + let finish; 37 | !oid && ([{ oid }] = await sql`select lo_creat(-1) as oid`) 38 | const [{ fd }] = await sql`select lo_open(${ oid }, ${ mode }) as fd` 39 | 40 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import swc from '@rollup/plugin-swc'; 4 | import alias from '@rollup/plugin-alias'; 5 | import path from 'node:path'; 6 | import { fileURLToPath } from 'url'; 7 | import multi from '@rollup/plugin-multi-entry'; 8 | import json from '@rollup/plugin-json'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const projectRootDir = path.resolve(__dirname); 13 | 14 | export default [ 15 | { 16 | input: './src/index.mts', 17 | external: ['fs', 'path'], // Treat Node built-ins as external 18 | output: { 19 | dir: './dist', 20 | format: 'es', 21 | sourcemap: false, 22 | }, 23 | plugins: [ 24 | resolve({ 25 | extensions: [ 26 | '.mjs', 27 | '.js', 28 | '.jsx', 29 | '.json', 30 | '.sass', 31 | '.scss', 32 | '.mts', 33 | '.ts', 34 | ], 35 | }), 36 | alias({ 37 | entries: [ 38 | { find: '@', replacement: path.resolve(projectRootDir, 'src') }, 39 | ], 40 | }), 41 | commonjs({ 42 | sourceMap: false, 43 | }), // Convert CommonJS to ES modules 44 | json({ preferConst: true }), 45 | swc(), 46 | ], 47 | }, 48 | { 49 | input: { 50 | include: ['src/sql/migrations/**/*.mts'], 51 | }, 52 | output: { 53 | dir: './dist/migrations', 54 | format: 'commonjs', 55 | sourcemap: false, 56 | }, 57 | plugins: [ 58 | multi(), 59 | resolve({ 60 | extensions: [ 61 | '.mjs', 62 | '.js', 63 | '.jsx', 64 | '.json', 65 | '.sass', 66 | '.scss', 67 | '.mts', 68 | '.ts', 69 | ], 70 | }), 71 | alias({ 72 | entries: [ 73 | { find: '@', replacement: path.resolve(projectRootDir, 'src') }, 74 | ], 75 | }), 76 | commonjs({ 77 | sourceMap: false, 78 | }), // Convert CommonJS to ES modules 79 | swc({ 80 | swc: { 81 | sourceMaps: false, 82 | }, 83 | }), 84 | ], 85 | }, 86 | ]; 87 | -------------------------------------------------------------------------------- /src/account/account-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { policyUse, withSystemActor } from '@/auth/authorization.mjs'; 4 | import { HttpApiBuilder } from '@effect/platform'; 5 | import { Effect, Layer } from 'effect'; 6 | import { AccountPolicy } from './account-policy.mjs'; 7 | import { CurrentAccount } from './account-schema.mjs'; 8 | import { AccountService } from './account-service.mjs'; 9 | 10 | export const AccountApiLive = HttpApiBuilder.group(Api, 'account', (handlers) => 11 | Effect.gen(function* () { 12 | const accountService = yield* AccountService; 13 | const accountPolicy = yield* AccountPolicy; 14 | 15 | return handlers 16 | .handle('signUp', ({ payload }) => 17 | accountService.signUp(payload).pipe(withSystemActor), 18 | ) 19 | .handle('findById', ({ path }) => 20 | accountService.findAccountById(path.accountId), 21 | ) 22 | .handle('findTags', ({ path }) => accountService.findTags(path.accountId)) 23 | .handle('updateById', ({ path, payload }) => 24 | accountService 25 | .updateAccountById(path.accountId, payload) 26 | .pipe(policyUse(accountPolicy.canUpdate(path.accountId))), 27 | ) 28 | .handle('signIn', ({ payload }) => 29 | accountService.signIn(payload).pipe(withSystemActor), 30 | ) 31 | .handle('me', () => CurrentAccount) 32 | .handle('findPosts', ({ path, urlParams }) => 33 | accountService.findPosts(urlParams, path.accountId), 34 | ) 35 | .handle('findComments', ({ path, urlParams }) => 36 | accountService.findComments(urlParams, path.accountId), 37 | ) 38 | .handle('findChallenges', ({ path, urlParams }) => 39 | accountService.findChallenges(urlParams, path.accountId), 40 | ) 41 | .handle('findChallengeEvents', ({ path, urlParams }) => 42 | accountService.findAllChallengeEvents(urlParams, path.accountId), 43 | ) 44 | .handle('findLikes', ({ path, urlParams }) => 45 | accountService.findAllLikes(urlParams, path.accountId), 46 | ) 47 | .handle('invalidate', ({ headers }) => 48 | accountService.invalidate(headers['refresh-token']), 49 | ); 50 | }), 51 | ).pipe( 52 | Layer.provide(AuthenticationLive), 53 | Layer.provide(AccountService.Live), 54 | Layer.provide(AccountPolicy.Live), 55 | ); 56 | -------------------------------------------------------------------------------- /src/account/account-block-schema.mts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomDateTimeInsert, 3 | CustomDateTimeUpdate, 4 | } from '@/misc/date-schema.mjs'; 5 | import { Model } from '@effect/sql'; 6 | import { Schema } from 'effect'; 7 | import { AccountId } from './account-schema.mjs'; 8 | 9 | export const AccountBlockId = Schema.String.pipe( 10 | Schema.brand('AccountBlockId'), 11 | ); 12 | 13 | export type AccountBlockId = typeof AccountBlockId.Type; 14 | 15 | export class AccountBlock extends Model.Class('AccountBlock')({ 16 | id: Model.Generated(AccountBlockId), 17 | blockerAccountId: AccountId, 18 | blockedAccountId: AccountId, 19 | isDeleted: Schema.Boolean, 20 | createdAt: CustomDateTimeInsert, 21 | updatedAt: CustomDateTimeUpdate, 22 | }) {} 23 | -------------------------------------------------------------------------------- /src/account/account-error.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | import { AccountId } from './account-schema.mjs'; 3 | import { HttpApiSchema } from '@effect/platform'; 4 | import { Email } from '@/misc/email-schema.mjs'; 5 | 6 | export class AccountNotFound extends Schema.TaggedError()( 7 | 'AccountNotFound', 8 | { id: AccountId }, 9 | HttpApiSchema.annotations({ 10 | status: 404, 11 | title: 'Account Not Found', 12 | description: 'ID에 해당하는 계정이 존재하지 않습니다.', 13 | }), 14 | ) {} 15 | 16 | export class AccountByEmailNotFound extends Schema.TaggedError()( 17 | 'AccountByEmailNotFound', 18 | { email: Email }, 19 | HttpApiSchema.annotations({ 20 | status: 404, 21 | title: 'Account Not Found', 22 | description: 'Email에 해당하는 계정이 존재하지 않습니다.', 23 | }), 24 | ) {} 25 | 26 | export class AccountAlreadyExists extends Schema.TaggedError()( 27 | 'AccountAlreadyExists', 28 | { email: Email }, 29 | HttpApiSchema.annotations({ 30 | status: 409, 31 | title: 'Account Already Exists', 32 | description: '이미 존재하는 계정입니다.', 33 | }), 34 | ) {} 35 | 36 | export class InvalidPassword extends Schema.TaggedError()( 37 | 'InvalidPassword', 38 | {}, 39 | HttpApiSchema.annotations({ 40 | status: 400, 41 | title: 'Invalid Password', 42 | description: '비밀번호가 올바르지 않거나, 계정이 존재하지 않습니다.', 43 | }), 44 | ) {} 45 | -------------------------------------------------------------------------------- /src/account/account-policy.mts: -------------------------------------------------------------------------------- 1 | import { policy } from '@/auth/authorization.mjs'; 2 | import { Effect, Layer } from 'effect'; 3 | import { AccountRepo } from './account-repo.mjs'; 4 | import { AccountId } from './account-schema.mjs'; 5 | 6 | const make = Effect.gen(function* () { 7 | const accountRepo = yield* AccountRepo; 8 | 9 | const canUpdate = (toUpdate: AccountId) => 10 | policy('account', 'update', (actor) => 11 | Effect.succeed(actor.id === toUpdate || actor.role === 'admin'), 12 | ); 13 | 14 | const canRead = (toRead: AccountId) => 15 | policy( 16 | 'account', 17 | 'read', 18 | (actor) => 19 | Effect.gen(function* () { 20 | if (actor.id === toRead || actor.role === 'admin') { 21 | return yield* Effect.succeed(true); 22 | } 23 | const isPrivate = yield* accountRepo.with(toRead, (account) => 24 | Effect.succeed(account.isPrivate), 25 | ); 26 | 27 | if (isPrivate) { 28 | return yield* Effect.succeed(false); 29 | } 30 | 31 | return false; 32 | }), 33 | '대상의 계정이 비공개상태이거나, 유효한 권한이 없습니다.', 34 | ); 35 | 36 | const canReadSensitive = (toRead: AccountId) => 37 | policy('account', 'readSensitive', (actor) => 38 | Effect.succeed(actor.id === toRead || actor.role === 'admin'), 39 | ); 40 | 41 | return { 42 | canUpdate, 43 | canRead, 44 | canReadSensitive, 45 | } as const; 46 | }); 47 | 48 | export class AccountPolicy extends Effect.Tag('Account/AccountPolicy')< 49 | AccountPolicy, 50 | Effect.Effect.Success 51 | >() { 52 | static layer = Layer.effect(AccountPolicy, make); 53 | 54 | static Live = this.layer.pipe(Layer.provide(AccountRepo.Live)); 55 | } 56 | -------------------------------------------------------------------------------- /src/account/account-repo.mts: -------------------------------------------------------------------------------- 1 | import { Email } from '@/misc/email-schema.mjs'; 2 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 3 | import { SqlLive } from '@/sql/sql-live.mjs'; 4 | import { Tag } from '@/tag/tag-schema.mjs'; 5 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 6 | import { Context, Effect, Layer, Option, pipe } from 'effect'; 7 | import { AccountNotFound } from './account-error.mjs'; 8 | import { Account, AccountId } from './account-schema.mjs'; 9 | 10 | const make = Effect.gen(function* () { 11 | const sql = yield* SqlClient.SqlClient; 12 | const repo = yield* Model.makeRepository(Account, { 13 | tableName: 'account', 14 | spanPrefix: 'AccountRepo', 15 | idColumn: 'id', 16 | }); 17 | 18 | const findByEmail = (email: Email) => 19 | SqlSchema.findOne({ 20 | Request: Email, 21 | Result: Account, 22 | execute: (key) => sql`select * from account where email = ${key}`, 23 | })(email).pipe(Effect.orDie, Effect.withSpan('AccountRepo.findByEmail')); 24 | 25 | const findTags = (accountId: AccountId) => 26 | SqlSchema.findAll({ 27 | Request: AccountId, 28 | Result: Tag, 29 | execute: (req) => sql` 30 | SELECT DISTINCT t.* 31 | FROM tag t 32 | left join tag_target tt on tt.tag_id = t.id 33 | left join challenge_participant cp on tt.challenge_id = cp.challenge_id 34 | LEFT JOIN post p ON tt.post_id = p.id 35 | LEFT JOIN challenge c ON tt.challenge_id = c.id 36 | WHERE p.account_id = ${req} 37 | OR c.account_id = ${req};`, 38 | })(accountId).pipe(Effect.orDie, Effect.withSpan('AccountRepo.findTags')); 39 | 40 | const updateById = ( 41 | existing: Account, 42 | target: Partial, 43 | ) => 44 | repo.update({ 45 | ...existing, 46 | ...target, 47 | updatedAt: undefined, 48 | }); 49 | 50 | const with_ = ( 51 | id: AccountId, 52 | f: (account: Account) => Effect.Effect, 53 | ): Effect.Effect => { 54 | return pipe( 55 | repo.findById(id), 56 | Effect.flatMap( 57 | Option.match({ 58 | onNone: () => new AccountNotFound({ id }), 59 | onSome: Effect.succeed, 60 | }), 61 | ), 62 | Effect.flatMap(f), 63 | sql.withTransaction, 64 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 65 | ); 66 | }; 67 | 68 | return { 69 | ...repo, 70 | findByEmail, 71 | findTags, 72 | updateById, 73 | with: with_, 74 | } as const; 75 | }); 76 | 77 | export class AccountRepo extends Context.Tag('Account/AccountRepo')< 78 | AccountRepo, 79 | Effect.Effect.Success 80 | >() { 81 | static Live = Layer.effect(AccountRepo, make).pipe(Layer.provide(SqlLive)); 82 | static Test = makeTestLayer(AccountRepo)({}); 83 | } 84 | -------------------------------------------------------------------------------- /src/account/account-schema.mts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomDateTimeInsert, 3 | CustomDateTimeUpdate, 4 | } from '@/misc/date-schema.mjs'; 5 | import { Email } from '@/misc/email-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Context, Schema } from 'effect'; 8 | 9 | export const AccountId = Schema.String.pipe(Schema.brand('AccountId')); 10 | 11 | export type AccountId = typeof AccountId.Type; 12 | 13 | export class Account extends Model.Class('Account')({ 14 | id: Model.Generated(AccountId), 15 | email: Email, 16 | passwordHash: Model.Sensitive(Schema.String), 17 | passwordSalt: Model.Sensitive(Schema.String), 18 | profileImageUrl: Schema.NullishOr(Schema.String), 19 | mainLanguage: Schema.NullishOr(Schema.String), 20 | nationality: Schema.NullishOr(Schema.String), 21 | bio: Schema.NullishOr(Schema.String), 22 | externalUrls: Schema.NullishOr(Schema.Array(Schema.String)), 23 | isEmailVerified: Schema.NullishOr(Schema.Boolean), 24 | isPrivate: Schema.NullishOr(Schema.Boolean), 25 | role: Schema.Literal('admin', 'user').annotations({ 26 | default: 'user', 27 | }), 28 | username: Schema.NullishOr(Schema.String), 29 | birthday: Schema.NullishOr(Schema.Date), 30 | createdAt: CustomDateTimeInsert, 31 | updatedAt: CustomDateTimeUpdate, 32 | }) {} 33 | 34 | export class CurrentAccount extends Context.Tag('CurrentAccount')< 35 | CurrentAccount, 36 | Account 37 | >() {} 38 | -------------------------------------------------------------------------------- /src/account/follow-schema.mts: -------------------------------------------------------------------------------- 1 | import { Model } from '@effect/sql'; 2 | import { Schema } from 'effect'; 3 | import { AccountId } from './account-schema.mjs'; 4 | 5 | export const FollowId = Schema.String.pipe(Schema.brand('FollowId')); 6 | 7 | export type FollowId = typeof FollowId.Type; 8 | 9 | export class Follow extends Model.Class('Follow')({ 10 | id: Model.Generated(FollowId), 11 | followerId: AccountId, 12 | followingId: AccountId, 13 | isFollowAccepted: Schema.Boolean, 14 | isFollowRequested: Schema.Boolean, 15 | isDeleted: Schema.Boolean, 16 | createdAt: Schema.DateTimeUtc, 17 | updatedAt: Schema.DateTimeUtc, 18 | }) {} 19 | -------------------------------------------------------------------------------- /src/account/sign-in-schema.mts: -------------------------------------------------------------------------------- 1 | import { Email } from '@/misc/email-schema.mjs'; 2 | import { Schema } from 'effect'; 3 | 4 | export const SignIn = Schema.Struct({ 5 | email: Email, 6 | password: Schema.String.pipe( 7 | Schema.annotations({ 8 | title: 'Password', 9 | description: 'A password', 10 | default: 'p@ss0wrd', 11 | }), 12 | ), 13 | }); 14 | 15 | export type SignIn = typeof SignIn.Type; 16 | -------------------------------------------------------------------------------- /src/account/sign-up-schema.mts: -------------------------------------------------------------------------------- 1 | import { Email } from '@/misc/email-schema.mjs'; 2 | import { Schema } from 'effect'; 3 | 4 | export const SignUp = Schema.Struct({ 5 | email: Email, 6 | password: Schema.String.pipe( 7 | Schema.annotations({ 8 | title: 'Password', 9 | description: 'A password', 10 | default: 'p@ss0wrd', 11 | }), 12 | ), 13 | confirmPassword: Schema.String.pipe( 14 | Schema.annotations({ 15 | title: 'Password', 16 | description: 'A password', 17 | default: 'p@ss0wrd', 18 | }), 19 | ), 20 | username: Schema.String.pipe( 21 | Schema.minLength(1), 22 | Schema.trimmed(), 23 | Schema.annotations({ 24 | title: 'Username', 25 | description: '유저이름, 최소 1글자 이상, 앞뒤 공백은 제거하여야 합니다.', 26 | default: 'user123', 27 | }), 28 | ), 29 | }).pipe( 30 | Schema.filter((input) => { 31 | if (input.password !== input.confirmPassword) { 32 | return { 33 | path: ['confirmPassword'], 34 | message: 'Passwords do not match', 35 | }; 36 | } 37 | }), 38 | Schema.annotations({ 39 | title: 'Sign Up', 40 | description: 'Sign up for an account', 41 | jsonSchema: { 42 | required: ['email', 'password', 'confirmPassword'], 43 | }, 44 | }), 45 | ); 46 | 47 | export type SignUp = typeof SignUp.Type; 48 | -------------------------------------------------------------------------------- /src/api-live.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiBuilder } from '@effect/platform'; 2 | import { Layer } from 'effect'; 3 | import { AccountApiLive } from './account/account-api-live.mjs'; 4 | import { Api } from './api.mjs'; 5 | import { ChallengeEventApiLive } from './challenge-event/challenge-event-api-live.mjs'; 6 | import { ChallengeApiLive } from './challenge/challenge-api-live.mjs'; 7 | import { CommentApiLive } from './comment/comment-api-live.mjs'; 8 | import { FileApiLive } from './file/file-api-live.mjs'; 9 | import { PostApiLive } from './post/post-api-live.mjs'; 10 | import { RootApiLive } from './root-api-live.mjs'; 11 | import { TagApiLive } from './tag/tag-api-live.mjs'; 12 | 13 | export const ApiLive = HttpApiBuilder.api(Api).pipe( 14 | Layer.provide([ 15 | RootApiLive, 16 | AccountApiLive, 17 | PostApiLive, 18 | CommentApiLive, 19 | FileApiLive, 20 | ChallengeApiLive, 21 | ChallengeEventApiLive, 22 | TagApiLive, 23 | ]), 24 | ); 25 | -------------------------------------------------------------------------------- /src/api.mts: -------------------------------------------------------------------------------- 1 | import { FileSystem, HttpApi, OpenApi } from '@effect/platform'; 2 | import { NodeContext } from '@effect/platform-node'; 3 | import { Effect } from 'effect'; 4 | import { AccountApi } from './account/account-api.mjs'; 5 | import { ChallengeEventApi } from './challenge-event/challenge-event-api.mjs'; 6 | import { ChallengeApi } from './challenge/challenge-api.mjs'; 7 | import { CommentApi } from './comment/comment-api.mjs'; 8 | import { FileApi } from './file/file-api.mjs'; 9 | import { PostApi } from './post/post-api.mjs'; 10 | import { RootApi } from './root-api.mjs'; 11 | import { TagApi } from './tag/tag-api.mjs'; 12 | 13 | const program = Effect.provide( 14 | Effect.gen(function* () { 15 | const fs = yield* FileSystem.FileSystem; 16 | const content = yield* fs.readFileString('./package.json', 'utf8'); 17 | const packageJson = JSON.parse(content); 18 | 19 | return yield* Effect.succeed(packageJson.version as string); 20 | }), 21 | NodeContext.layer, 22 | ); 23 | 24 | const version = await Effect.runPromise(program); 25 | 26 | export class Api extends HttpApi.empty 27 | .add(RootApi) 28 | .add(AccountApi) 29 | .add(PostApi) 30 | .add(CommentApi) 31 | .add(FileApi) 32 | .add(ChallengeApi) 33 | .add(ChallengeEventApi) 34 | .add(TagApi) 35 | .annotateContext( 36 | OpenApi.annotations({ 37 | title: '오즈 6기 심화반 챌린지 서비스를 위한 백엔드', 38 | description: `최신변경점: 39 | * 내가, 혹은 다른 누군가가 만든 챌린지 목록을 가져오는 기능 (2024-11-28.006) 40 | * 내가, 혹은 다른 누군가가 참여중인 챌린지 목록을 가져오는 기능 (2024-11-28.006) 41 | * 내가, 혹은 다른 누군가가 참여중인 챌린지 이벤트를 가져오는 기능 (2024-11-28.006) 42 | * 내가, 혹은 다른 누군가가 쓴 글 목록을 가져오는 기능 (2024-11-28.006) 43 | * 내가, 혹은 다른 누군가가 좋아요/싫어요한 글 목록을 가져오는 기능 (2024-11-28.006) 44 | * 내가, 혹은 다른 누군가가 쓴 댓글 목록을 가져오는 기능 (2024-11-28.006) 45 | * 챌린지 / 게시글의 태그를 삭제하는 기능 (2024-11-28.005) 46 | * 게시글 삭제처리 softDelete로 변경 (2024-11-28.004) 47 | * 챌린지 태그연결 api 위치변경: tag -> challenge (2024-11-28.003) 48 | * 게시글 태그연결 api 위치변경: tag -> post (2024-11-28.003) 49 | * 챌린지 태그조회 api 위치변경: tag -> challenge (2024-11-28.002) 50 | * 게시글 태그조회 api 위치변경: tag -> post (2024-11-28.002) 51 | * 특정 유저의 태그조회 api 위치변경: tag -> account (2024-11-28.002) 52 | * 챌린지 / 게시글 isDeleted 반영 (2024-11-28.001) 53 | * 태그 db에 색을 받을 수있게 함 (2024-11-27.002) 54 | * [유저가 참여한 챌린지]와 [유저가 쓴 글], [유저가 만든 챌린지]의 태그를 찾아, 그를 프로필에 표시할 수 있게 지원하는 기능 (2024-11-27.001) 55 | 56 | 예정변경점: 57 | * 버그 리포트시 대응예정 58 | `, 59 | version: version, 60 | override: {}, 61 | }), 62 | ) {} 63 | -------------------------------------------------------------------------------- /src/auth/access-token.mts: -------------------------------------------------------------------------------- 1 | import { Redacted, Schema } from 'effect'; 2 | 3 | export const AccessTokenString = Schema.String.pipe( 4 | Schema.brand('AccessToken'), 5 | ); 6 | export const AccessToken = Schema.Redacted(AccessTokenString); 7 | export type AccessToken = typeof AccessToken.Type; 8 | 9 | export const accessTokenFromString = (token: string): AccessToken => 10 | Redacted.make(AccessTokenString.make(token)); 11 | 12 | export const accessTokenFromRedacted = ( 13 | token: Redacted.Redacted, 14 | ): AccessToken => token as AccessToken; 15 | -------------------------------------------------------------------------------- /src/auth/authentication.mts: -------------------------------------------------------------------------------- 1 | import { AccountRepo } from '@/account/account-repo.mjs'; 2 | import { CurrentAccount } from '@/account/account-schema.mjs'; 3 | import { TokenService } from '@/crypto/token-service.mjs'; 4 | import { HttpApiMiddleware, HttpApiSecurity } from '@effect/platform'; 5 | import { Effect, Layer, Option, Redacted } from 'effect'; 6 | import { Unauthenticated } from './error-401.mjs'; 7 | 8 | export class Authentication extends HttpApiMiddleware.Tag()( 9 | 'Authentication', 10 | { 11 | failure: Unauthenticated, 12 | provides: CurrentAccount, 13 | security: { 14 | bearerHeader: HttpApiSecurity.bearer, 15 | }, 16 | }, 17 | ) {} 18 | 19 | export const AuthenticationLive = Layer.effect( 20 | Authentication, 21 | 22 | Effect.gen(function* () { 23 | yield* Effect.log('creating Authorization middleware'); 24 | 25 | // return the security handlers 26 | return Authentication.of({ 27 | bearerHeader: (serializedToken) => 28 | Effect.provide( 29 | Effect.gen(function* () { 30 | const tokenService = yield* TokenService; 31 | const accountRepo = yield* AccountRepo; 32 | const decoded = yield* tokenService.verifyToken( 33 | Redacted.value(serializedToken), 34 | ); 35 | 36 | const maybeAccount = yield* accountRepo.findByEmail(decoded.sub); 37 | 38 | const account = yield* Option.match(maybeAccount, { 39 | onNone: () => 40 | Effect.fail( 41 | new Unauthenticated({ 42 | message: 'Token Account not found', 43 | }), 44 | ), 45 | onSome: (account) => Effect.succeed(account), 46 | }); 47 | 48 | return account; 49 | }), 50 | Layer.merge(AccountRepo.Live, TokenService.Live), 51 | ).pipe( 52 | Effect.catchAll((error) => 53 | Effect.fail( 54 | new Unauthenticated({ 55 | message: 'Token Account not found', 56 | }), 57 | ), 58 | ), 59 | ), 60 | }); 61 | }), 62 | ); 63 | -------------------------------------------------------------------------------- /src/auth/authorization.mts: -------------------------------------------------------------------------------- 1 | import { Account, CurrentAccount } from '@/account/account-schema.mjs'; 2 | import { Effect } from 'effect'; 3 | import { Unauthorized } from './error-403.mjs'; 4 | 5 | export const TypeId: unique symbol = Symbol.for( 6 | 'Domain/Policy/AuthorizedActor', 7 | ); 8 | 9 | export type TypeId = typeof TypeId; 10 | 11 | export interface AuthorizedActor 12 | extends Account { 13 | readonly [TypeId]: { 14 | readonly _Entity: Entity; 15 | readonly _Action: Action; 16 | }; 17 | } 18 | 19 | export const authorizedActor = (user: Account): AuthorizedActor => 20 | user as any; 21 | 22 | export const policy = ( 23 | entity: Entity, 24 | action: Action, 25 | f: (actor: Account) => Effect.Effect, 26 | cause?: string, 27 | ): Effect.Effect< 28 | AuthorizedActor, 29 | E | Unauthorized, 30 | R | CurrentAccount 31 | > => 32 | Effect.flatMap(CurrentAccount, (actor) => 33 | Effect.flatMap(f(actor), (can) => 34 | can 35 | ? Effect.succeed(authorizedActor(actor)) 36 | : Effect.fail( 37 | new Unauthorized({ 38 | actorId: actor.id, 39 | entity, 40 | action, 41 | cause, 42 | }), 43 | ), 44 | ), 45 | ); 46 | 47 | export const policyCompose = 48 | , E, R>( 49 | that: Effect.Effect, 50 | ) => 51 | , E2, R2>( 52 | self: Effect.Effect, 53 | ): Effect.Effect => 54 | Effect.zipRight(self, that) as any; 55 | 56 | export const policyUse = 57 | , E, R>( 58 | policy: Effect.Effect, 59 | ) => 60 | ( 61 | effect: Effect.Effect, 62 | ): Effect.Effect | R> => 63 | policy.pipe(Effect.zipRight(effect)) as any; 64 | 65 | export const policyRequire = 66 | ( 67 | _entity: Entity, 68 | _action: Action, 69 | ) => 70 | ( 71 | effect: Effect.Effect, 72 | ): Effect.Effect> => 73 | effect; 74 | 75 | export const withSystemActor = ( 76 | effect: Effect.Effect, 77 | ): Effect.Effect>> => effect as any; 78 | -------------------------------------------------------------------------------- /src/auth/error-401.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | 4 | export class Unauthenticated extends Schema.TaggedError()( 5 | 'Unauthenticated', 6 | { 7 | message: Schema.String, 8 | }, 9 | HttpApiSchema.annotations({ status: 401 }), 10 | ) {} 11 | -------------------------------------------------------------------------------- /src/auth/error-403.mts: -------------------------------------------------------------------------------- 1 | import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; 2 | import { HttpApiSchema } from '@effect/platform'; 3 | import { Effect, Predicate, Schema } from 'effect'; 4 | 5 | export class Unauthorized extends Schema.TaggedError()( 6 | 'Unauthorized', 7 | { 8 | actorId: AccountId, 9 | entity: Schema.String, 10 | action: Schema.String, 11 | cause: Schema.NullishOr(Schema.String), 12 | }, 13 | HttpApiSchema.annotations({ status: 403 }), 14 | ) { 15 | get message() { 16 | return `Actor (${this.actorId}) is not authorized to perform action "${this.action}" on entity "${this.entity}"`; 17 | } 18 | 19 | static is(u: unknown): u is Unauthorized { 20 | return Predicate.isTagged(u, 'Unauthorized'); 21 | } 22 | 23 | static refail(entity: string, action: string) { 24 | return ( 25 | effect: Effect.Effect, 26 | ): Effect.Effect => 27 | Effect.catchIf( 28 | effect, 29 | (e) => !Unauthorized.is(e), 30 | () => 31 | Effect.flatMap( 32 | CurrentAccount, 33 | (actor) => 34 | new Unauthorized({ 35 | actorId: actor.id, 36 | entity, 37 | action, 38 | cause: null, 39 | }), 40 | ), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { HttpApiBuilder } from '@effect/platform'; 4 | import { Effect, Layer } from 'effect'; 5 | import { ChallengeEventService } from './challenge-event-service.mjs'; 6 | import { policyUse } from '@/auth/authorization.mjs'; 7 | import { ChallengeEventPolicy } from './challenge-event-policy.mjs'; 8 | 9 | export const ChallengeEventApiLive = HttpApiBuilder.group( 10 | Api, 11 | 'challenge-event', 12 | (handlers) => 13 | Effect.gen(function* () { 14 | const challengeEventService = yield* ChallengeEventService; 15 | const challengeEventPolicy = yield* ChallengeEventPolicy; 16 | 17 | return handlers 18 | .handle('findAll', ({ path }) => 19 | challengeEventService.findAllByChallengeId(path.challengeId), 20 | ) 21 | .handle('findById', ({ path }) => 22 | challengeEventService.findById(path.challengeEventId), 23 | ) 24 | .handle('create', ({ path, payload }) => 25 | challengeEventService 26 | .create(path.challengeId, payload) 27 | .pipe( 28 | policyUse( 29 | challengeEventPolicy.canCreate(path.challengeId, payload), 30 | ), 31 | ), 32 | ) 33 | .handle('updateById', ({ path, payload }) => 34 | challengeEventService 35 | .update(path.challengeEventId, payload) 36 | .pipe( 37 | policyUse(challengeEventPolicy.canUpdate(path.challengeEventId)), 38 | ), 39 | ) 40 | .handle('deleteById', ({ path }) => 41 | challengeEventService 42 | .deleteById(path.challengeEventId) 43 | .pipe( 44 | policyUse(challengeEventPolicy.canDelete(path.challengeEventId)), 45 | ), 46 | ) 47 | .handle('check', ({ path, payload }) => 48 | challengeEventService 49 | .check(path.challengeId, path.challengeEventId, payload) 50 | .pipe( 51 | policyUse(challengeEventPolicy.canCheck(path.challengeEventId)), 52 | ), 53 | ) 54 | .handle('getChecks', ({ path }) => 55 | challengeEventService.getChecks(path.challengeEventId), 56 | ); 57 | }), 58 | ).pipe( 59 | Layer.provide(AuthenticationLive), 60 | Layer.provide(ChallengeEventService.Live), 61 | Layer.provide(ChallengeEventPolicy.Live), 62 | ); 63 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-api.mts: -------------------------------------------------------------------------------- 1 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 2 | import { HttpApiEndpoint, HttpApiGroup, OpenApi } from '@effect/platform'; 3 | import { Schema } from 'effect'; 4 | import { 5 | ChallengeEvent, 6 | ChallengeEventId, 7 | ChallengeEventView, 8 | } from './challenge-event-schema.mjs'; 9 | import { Authentication } from '@/auth/authentication.mjs'; 10 | import { ChallengeNotFound } from '@/challenge/challenge-error.mjs'; 11 | import { Unauthorized } from '@/auth/error-403.mjs'; 12 | import { 13 | ChallengeEventCheckRequestLocationBadRequest, 14 | ChallengeEventNotFound, 15 | } from './challenge-event-error.mjs'; 16 | import { 17 | ChallengeEventCheckRequest, 18 | ChallengeEventCheckResponse, 19 | } from './helper-schema.mjs'; 20 | import { ChallengeEventParticipant } from './challenge-event-participant-schema.mjs'; 21 | 22 | export class ChallengeEventApi extends HttpApiGroup.make('challenge-event') 23 | .add( 24 | HttpApiEndpoint.get('findAll', '/:challengeId/events') 25 | .setPath( 26 | Schema.Struct({ 27 | challengeId: ChallengeId, 28 | }), 29 | ) 30 | .addError(ChallengeNotFound) 31 | .addSuccess(Schema.Array(ChallengeEventView.json)) 32 | .annotateContext( 33 | OpenApi.annotations({ 34 | description: 35 | '(사용가능) 챌린지 이벤트 목록을 조회합니다. 페이지와 한 페이지당 이벤트 수를 지정할 수 있습니다.', 36 | override: { 37 | summary: '(사용가능) 챌린지 이벤트 목록 조회', 38 | }, 39 | }), 40 | ), 41 | ) 42 | .add( 43 | HttpApiEndpoint.get('findById', '/:challengeId/events/:challengeEventId') 44 | .setPath( 45 | Schema.Struct({ 46 | challengeId: ChallengeId, 47 | challengeEventId: ChallengeEventId, 48 | }), 49 | ) 50 | .addError(ChallengeNotFound) 51 | .addError(ChallengeEventNotFound) 52 | .addSuccess(ChallengeEventView.json) 53 | .annotateContext( 54 | OpenApi.annotations({ 55 | description: 56 | '(사용가능) 챌린지 이벤트를 조회합니다. 챌린지 이벤트가 존재하지 않는 경우 404를 반환합니다.', 57 | override: { 58 | summary: '(사용가능) 단일 챌린지 이벤트 조회', 59 | }, 60 | }), 61 | ), 62 | ) 63 | .add( 64 | HttpApiEndpoint.post('create', '/:challengeId/events') 65 | .middleware(Authentication) 66 | .setPath( 67 | Schema.Struct({ 68 | challengeId: ChallengeId, 69 | }), 70 | ) 71 | .setPayload(ChallengeEvent.jsonCreate) 72 | .addError(Unauthorized) 73 | .addError(ChallengeNotFound) 74 | .addSuccess(ChallengeEvent.json) 75 | .annotateContext( 76 | OpenApi.annotations({ 77 | description: `(사용가능) 챌린지 이벤트를 생성합니다. 78 | * 챌린지 작성자와 이벤트 생성자가 일치해야 합니다. 그렇지 않으면 Unauthorized 에러가 납니다. 79 | 80 | * checkType은 'location', 'duration', 'manual' 중 하나여야 합니다. 81 | 82 | * manual 이벤트의 경우 참가자가 임의로 이벤트를 완료할 수 있습니다. 83 | 84 | * duration 이벤트의 경우 startDatetime과 endDatetime을 넣어주셔야 합니다. 이벤트 참가자가 저 시간 안에 체크를 진행할 경우 그 참가자는 이벤트를 완료한 것으로 인정됩니다. 85 | 86 | * location 이벤트의 경우 coordinate를 넣어주셔야 합니다. location 이벤트가 아닌데도 coordinate를 넣으면 무시됩니다. 87 | 88 | * 이벤트 참가자가 저 좌표 근처(1km이내)에서 체크를 진행할 경우 그 참가자는 이벤트를 완료한 것으로 인정됩니다. 89 | 90 | * coordinate는 [위도, 경도] 순으로 넣어주셔야 합니다. 91 | 92 | * 위도는 -90 ~ 90, 경도는 -180 ~ 180 사이의 값이어야 합니다. 예를들어 김포공항은 [37.5585, 126.7906] 입니다.`, 93 | override: { 94 | summary: '(사용가능) 챌린지 이벤트 생성', 95 | }, 96 | }), 97 | ), 98 | ) 99 | .add( 100 | HttpApiEndpoint.patch( 101 | 'updateById', 102 | '/:challengeId/events/:challengeEventId', 103 | ) 104 | .middleware(Authentication) 105 | .setPath( 106 | Schema.Struct({ 107 | challengeId: ChallengeId, 108 | challengeEventId: ChallengeEventId, 109 | }), 110 | ) 111 | .setPayload( 112 | Schema.partialWith(ChallengeEvent.jsonUpdate, { exact: true }), 113 | ) 114 | .addError(Unauthorized) 115 | .addError(ChallengeEventNotFound) 116 | .addSuccess(ChallengeEvent.json) 117 | .annotateContext( 118 | OpenApi.annotations({ 119 | title: '(사용가능) 챌린지 이벤트 수정 API', 120 | override: { 121 | summary: '(사용가능) 챌린지 이벤트 수정', 122 | }, 123 | }), 124 | ), 125 | ) 126 | .add( 127 | HttpApiEndpoint.del('deleteById', '/:challengeId/events/:challengeEventId') 128 | .middleware(Authentication) 129 | .setPath( 130 | Schema.Struct({ 131 | challengeId: ChallengeId, 132 | challengeEventId: ChallengeEventId, 133 | }), 134 | ) 135 | .addError(Unauthorized) 136 | .addError(ChallengeEventNotFound) 137 | .annotateContext( 138 | OpenApi.annotations({ 139 | title: '(사용가능) 챌린지 이벤트 삭제 API', 140 | description: ` 141 | * 주의: 이 API는 챌린지 이벤트 자체를 완전 삭제하지 않고, isDeleted를 true로 변경합니다. (soft delete) 142 | 143 | * 삭제된 이벤트는 챌린지 참가자에게 삭제되었음을 알려야합니다. 144 | 145 | * 전체 순위 등을 계산할 때 삭제된 이벤트는 제외해야합니다. 146 | `, 147 | override: { 148 | summary: '(사용가능) 챌린지 이벤트 삭제', 149 | }, 150 | }), 151 | ), 152 | ) 153 | .add( 154 | HttpApiEndpoint.post( 155 | 'check', 156 | '/:challengeId/events/:challengeEventId/check', 157 | ) 158 | .middleware(Authentication) 159 | .setPath( 160 | Schema.Struct({ 161 | challengeId: ChallengeId, 162 | challengeEventId: ChallengeEventId, 163 | }), 164 | ) 165 | .setPayload(ChallengeEventCheckRequest) 166 | .addError(Unauthorized) 167 | .addError(ChallengeEventNotFound) 168 | .addError(ChallengeEventCheckRequestLocationBadRequest) 169 | .addSuccess(ChallengeEventCheckResponse) 170 | .annotateContext( 171 | OpenApi.annotations({ 172 | title: '(사용가능) 챌린지 이벤트 체크 API', 173 | description: 174 | '(사용가능) 챌린지 참가자가 이벤트를 진행중인지 체크하고 챌린지 상황을 업데이트합니다.', 175 | override: { 176 | summary: '(사용가능) 챌린지 이벤트 체크', 177 | }, 178 | }), 179 | ), 180 | ) 181 | .add( 182 | HttpApiEndpoint.get( 183 | 'getChecks', 184 | '/:challengeId/events/:challengeEventId/check', 185 | ) 186 | .middleware(Authentication) 187 | .setPath( 188 | Schema.Struct({ 189 | challengeId: ChallengeId, 190 | challengeEventId: ChallengeEventId, 191 | }), 192 | ) 193 | .addError(Unauthorized) 194 | .addError(ChallengeEventNotFound) 195 | .addSuccess(Schema.Array(ChallengeEventParticipant.json)) 196 | .annotateContext( 197 | OpenApi.annotations({ 198 | title: '(사용가능) 챌린지 이벤트 참가 조회 API', 199 | description: 200 | '(사용가능) 해당 챌린지 이벤트에 참가중인 사용자의 현황을 조회합니다.', 201 | override: { 202 | summary: '(사용가능) 챌린지 이벤트 참가 조회', 203 | }, 204 | }), 205 | ), 206 | ) 207 | .prefix('/api/challenges') 208 | .annotateContext( 209 | OpenApi.annotations({ 210 | title: '(사용가능) 챌린지 이벤트 API', 211 | }), 212 | ) {} 213 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-error.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | import { ChallengeEventId } from './challenge-event-schema.mjs'; 3 | import { AccountId } from '@/account/account-schema.mjs'; 4 | import { HttpApiSchema } from '@effect/platform'; 5 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 6 | 7 | export class ChallengeEventNotFound extends Schema.TaggedError()( 8 | 'ChallengeEventNotFound', 9 | { 10 | id: Schema.NullishOr(ChallengeEventId), 11 | accountId: Schema.NullishOr(AccountId), 12 | challengeId: Schema.NullishOr(ChallengeId), 13 | }, 14 | HttpApiSchema.annotations({ 15 | status: 404, 16 | title: 'Challenge Event Not Found', 17 | description: 'ID에 해당하는 챌린지 이벤트가 존재하지 않습니다.', 18 | }), 19 | ) {} 20 | 21 | export class ChallengeEventCheckRequestLocationBadRequest extends Schema.TaggedError()( 22 | 'ChallengeEventCheckRequestLocationBadRequest', 23 | {}, 24 | HttpApiSchema.annotations({ 25 | status: 400, 26 | title: 'Challenge Event Location Not Found', 27 | description: '챌린지 이벤트의 체크요청에서 위치 정보가 올바르지 않습니다.', 28 | }), 29 | ) {} 30 | 31 | export class ChallengeEventCheckRequestDateBadRequest extends Schema.TaggedError()( 32 | 'ChallengeEventCheckRequestDateBadRequest', 33 | {}, 34 | HttpApiSchema.annotations({ 35 | status: 400, 36 | title: 'Challenge Event Date Not Found', 37 | description: '챌린지 이벤트의 체크요청에서 날짜 정보가 올바르지 않습니다.', 38 | }), 39 | ) {} 40 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-participant-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { ChallengeEventParticipantId } from './challenge-event-participant-schema.mjs'; 4 | import { AccountId } from '@/account/account-schema.mjs'; 5 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 6 | import { ChallengeEventId } from './challenge-event-schema.mjs'; 7 | 8 | export class ChallengeEventParticipantNotFound extends Schema.TaggedError()( 9 | 'ChallengeEventParticipantNotFound', 10 | { 11 | id: Schema.NullishOr(ChallengeEventParticipantId), 12 | accountId: Schema.NullishOr(AccountId), 13 | challengeId: Schema.NullishOr(ChallengeId), 14 | challengeEventId: Schema.NullishOr(ChallengeEventId), 15 | }, 16 | HttpApiSchema.annotations({ 17 | status: 404, 18 | title: 'Challenge Event Participant Not Found', 19 | description: 'ID에 해당하는 챌린지 이벤트 참가자가 존재하지 않습니다.', 20 | }), 21 | ) {} 22 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-participant-repo.mts: -------------------------------------------------------------------------------- 1 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 2 | import { Option, Effect, Layer, pipe, Schema } from 'effect'; 3 | import { 4 | ChallengeEventParticipant, 5 | ChallengeEventParticipantId, 6 | } from './challenge-event-participant-schema.mjs'; 7 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 8 | import { ChallengeEventParticipantNotFound } from './challenge-event-participant-error.mjs'; 9 | import { SqlLive } from '@/sql/sql-live.mjs'; 10 | import { AccountId } from '@/account/account-schema.mjs'; 11 | import { ChallengeEventId } from './challenge-event-schema.mjs'; 12 | 13 | const TABLE_NAME = 'challenge_event_participant'; 14 | 15 | const make = Effect.gen(function* () { 16 | const sql = yield* SqlClient.SqlClient; 17 | const repo = yield* Model.makeRepository(ChallengeEventParticipant, { 18 | tableName: TABLE_NAME, 19 | spanPrefix: 'ChallengeEventParticipantRepo', 20 | idColumn: 'id', 21 | }); 22 | 23 | const findByTarget = (target: { 24 | accountId: AccountId; 25 | challengeEventId: ChallengeEventId; 26 | }) => 27 | SqlSchema.findOne({ 28 | Request: Schema.Struct({ 29 | accountId: Schema.String, 30 | challengeEventId: Schema.String, 31 | }), 32 | Result: ChallengeEventParticipant, 33 | execute: (request) => 34 | sql`select * from ${sql(TABLE_NAME)} where account_id = ${request.accountId} and challenge_event_id = ${request.challengeEventId}`, 35 | })(target).pipe( 36 | Effect.orDie, 37 | Effect.withSpan( 38 | 'ChallengeEventParticipantRepo.findByAccountIdAndChallengeEventId', 39 | ), 40 | ); 41 | 42 | const findAllByChallengeEventId = (challengeEventId: ChallengeEventId) => 43 | SqlSchema.findAll({ 44 | Request: ChallengeEventId, 45 | Result: ChallengeEventParticipant, 46 | execute: (request) => 47 | sql`select * from ${sql(TABLE_NAME)} where challenge_event_id = ${request}`, 48 | })(challengeEventId).pipe( 49 | Effect.orDie, 50 | Effect.withSpan( 51 | 'ChallengeEventParticipantRepo.findAllByChallengeEventId', 52 | ), 53 | ); 54 | 55 | const upsert = (participant: typeof ChallengeEventParticipant.insert.Type) => 56 | SqlSchema.single({ 57 | Request: ChallengeEventParticipant.insert, 58 | Result: ChallengeEventParticipant, 59 | execute: (request) => 60 | sql`insert into ${sql(TABLE_NAME)} ${sql.insert(request)} on conflict (challenge_event_id, account_id) do update set ${sql.update(request).returning('*')}`, 61 | })(participant).pipe( 62 | Effect.orDie, 63 | Effect.withSpan('ChallengeEventParticipantRepo.upsert'), 64 | ); 65 | 66 | const with_ = ( 67 | id: ChallengeEventParticipantId, 68 | f: (participant: ChallengeEventParticipant) => Effect.Effect, 69 | ): Effect.Effect => 70 | pipe( 71 | repo.findById(id), 72 | Effect.flatMap( 73 | Option.match({ 74 | onNone: () => 75 | new ChallengeEventParticipantNotFound({ 76 | id, 77 | challengeEventId: null, 78 | accountId: null, 79 | challengeId: null, 80 | }), 81 | onSome: Effect.succeed, 82 | }), 83 | ), 84 | Effect.flatMap(f), 85 | sql.withTransaction, 86 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 87 | ); 88 | 89 | return { 90 | ...repo, 91 | findByTarget, 92 | findAllByChallengeEventId, 93 | upsert, 94 | with: with_, 95 | } as const; 96 | }); 97 | 98 | export class ChallengeEventParticipantRepo extends Effect.Tag( 99 | 'ChallengeEventParticipantRepo', 100 | )>() { 101 | static layer = Layer.effect(ChallengeEventParticipantRepo, make); 102 | 103 | static Live = this.layer.pipe(Layer.provide(SqlLive)); 104 | 105 | static Test = makeTestLayer(ChallengeEventParticipantRepo)({}); 106 | } 107 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-participant-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Schema } from 'effect'; 8 | import { ChallengeEventId } from './challenge-event-schema.mjs'; 9 | 10 | export const ChallengeEventParticipantId = Schema.String.pipe( 11 | Schema.brand('ChallengeEventParticipantId'), 12 | ); 13 | 14 | export type ChallengeEventParticipantId = 15 | typeof ChallengeEventParticipantId.Type; 16 | 17 | export class ChallengeEventParticipant extends Model.Class( 18 | 'ChallengeEventParticipant', 19 | )({ 20 | id: Model.Generated(ChallengeEventParticipantId), 21 | accountId: AccountId, 22 | challengeEventId: ChallengeEventId, 23 | isChecked: Schema.Boolean, 24 | createdAt: CustomDateTimeInsert, 25 | updatedAt: CustomDateTimeUpdate, 26 | }) {} 27 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-policy.mts: -------------------------------------------------------------------------------- 1 | import { policy } from '@/auth/authorization.mjs'; 2 | import { ChallengeParticipantRepo } from '@/challenge/challenge-participant-repo.mjs'; 3 | import { ChallengeRepo } from '@/challenge/challenge-repo.mjs'; 4 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 5 | import { Effect, Layer, Option, pipe } from 'effect'; 6 | import { ChallengeEventRepo } from './challenge-event-repo.mjs'; 7 | import { ChallengeEvent, ChallengeEventId } from './challenge-event-schema.mjs'; 8 | 9 | const make = Effect.gen(function* () { 10 | const challengeEventRepo = yield* ChallengeEventRepo; 11 | const challengeRepo = yield* ChallengeRepo; 12 | const challengeParticipantRepo = yield* ChallengeParticipantRepo; 13 | 14 | const canCreate = ( 15 | challengeId: ChallengeId, 16 | toCreate: typeof ChallengeEvent.jsonCreate.Type, 17 | ) => 18 | policy( 19 | 'challenge-event', 20 | 'create', 21 | (actor) => 22 | challengeRepo.with(challengeId, (challenge) => 23 | Effect.succeed( 24 | actor.id === challenge.accountId || actor.role === 'admin', 25 | ), 26 | ), 27 | '챌린지 작성자나 관리자만 챌린지 이벤트를 생성할 수 있습니다.', 28 | ); 29 | 30 | const canRead = (id: ChallengeEventId) => 31 | policy( 32 | 'challenge-event', 33 | 'read', 34 | (_actor) => Effect.succeed(true), 35 | '모두가 챌린지 이벤트를 읽을 수 있습니다', 36 | ); 37 | 38 | const canUpdate = (id: ChallengeEventId) => 39 | policy( 40 | 'challenge-event', 41 | 'update', 42 | (actor) => 43 | pipe( 44 | challengeEventRepo.with(id, (challengeEvent) => 45 | Effect.succeed( 46 | actor.id === challengeEvent.accountId || actor.role === 'admin', 47 | ), 48 | ), 49 | ), 50 | '챌린지 이벤트 작성자나 관리자만 챌린지 이벤트를 수정할 수 있습니다.', 51 | ); 52 | 53 | const canDelete = (id: ChallengeEventId) => 54 | policy( 55 | 'challenge-event', 56 | 'delete', 57 | (actor) => 58 | pipe( 59 | challengeEventRepo.with(id, (challengeEvent) => 60 | Effect.succeed( 61 | actor.id === challengeEvent.accountId || actor.role === 'admin', 62 | ), 63 | ), 64 | ), 65 | '챌린지 이벤트 작성자나 관리자만 챌린지 이벤트를 삭제할 수 있습니다.', 66 | ); 67 | 68 | const canCheck = (id: ChallengeEventId) => 69 | policy( 70 | 'challenge-event', 71 | 'check', 72 | (actor) => 73 | Effect.gen(function* () { 74 | const maybeChallengeEvent = yield* challengeEventRepo.findById(id); 75 | 76 | if (Option.isNone(maybeChallengeEvent)) { 77 | return yield* Effect.succeed(false); 78 | } 79 | 80 | const challengeEvent = maybeChallengeEvent.value; 81 | 82 | const maybeParticipant = 83 | yield* challengeParticipantRepo.findParticipantByTarget({ 84 | accountId: actor.id, 85 | challengeId: challengeEvent.challengeId, 86 | }); 87 | 88 | if (Option.isNone(maybeParticipant)) { 89 | return yield* Effect.succeed(false); 90 | } 91 | 92 | return yield* Effect.succeed(true); 93 | }), 94 | '챌린지 참가자만 챌린지 이벤트를 체크할 수 있습니다.', 95 | ); 96 | 97 | return { 98 | canCreate, 99 | canRead, 100 | canUpdate, 101 | canDelete, 102 | canCheck, 103 | } as const; 104 | }); 105 | 106 | export class ChallengeEventPolicy extends Effect.Tag( 107 | 'ChallengeEvent/ChallengeEventPolicy', 108 | )>() { 109 | static layer = Layer.effect(ChallengeEventPolicy, make); 110 | static Live = this.layer.pipe( 111 | Layer.provide(ChallengeEventRepo.Live), 112 | Layer.provide(ChallengeRepo.Live), 113 | Layer.provide(ChallengeParticipantRepo.Live), 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/challenge-event/challenge-event-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | DateTimeFromDate, 6 | } from '@/misc/date-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | import { ChallengeId } from '../challenge/challenge-schema.mjs'; 10 | import { 11 | CoordinateForFrontend, 12 | FromStringToCoordinate, 13 | } from './helper-schema.mjs'; 14 | 15 | export const ChallengeEventId = Schema.String.pipe( 16 | Schema.brand('ChallengeEventId'), 17 | ); 18 | 19 | export type ChallengeEventId = typeof ChallengeEventId.Type; 20 | 21 | export class ChallengeEvent extends Model.Class( 22 | 'ChallengeEvent', 23 | )({ 24 | id: Model.Generated(ChallengeEventId), 25 | checkType: Schema.Literal('location', 'duration', 'manual', 'other'), 26 | title: Schema.String, 27 | description: Schema.String, 28 | accountId: Model.Sensitive(AccountId), 29 | challengeId: Model.Sensitive(ChallengeId), 30 | isDeleted: Schema.Boolean.pipe( 31 | Schema.annotations({ 32 | description: 33 | '챌린지 이벤트가 삭제되었는지 여부 (사용하지 않습니다; 추후 확장성을 위해 만들어둠)', 34 | default: false, 35 | }), 36 | ), 37 | isPublished: Schema.Boolean.pipe( 38 | Schema.annotations({ 39 | description: 40 | '챌린지 이벤트가 챌린지 참가자에게 공개되었는지 여부 (사용하지 않습니다; 추후 확장성을 위해 만들어둠)', 41 | default: false, 42 | }), 43 | ), 44 | isFinished: Schema.Boolean.pipe( 45 | Schema.annotations({ 46 | description: 47 | '챌린지 이벤트가 종료되었는지 여부 (사용하지 않습니다; 추후 확장성을 위해 만들어둠)', 48 | default: false, 49 | }), 50 | ), 51 | startDatetime: Schema.NullishOr(DateTimeFromDate), 52 | endDatetime: Schema.NullishOr(DateTimeFromDate), 53 | coordinate: Model.Field({ 54 | select: Schema.NullishOr(FromStringToCoordinate.to), 55 | insert: Schema.NullishOr(FromStringToCoordinate.from), 56 | update: Schema.NullishOr(FromStringToCoordinate.from), 57 | json: Schema.NullishOr(FromStringToCoordinate.to), 58 | jsonCreate: Schema.NullishOr(CoordinateForFrontend), 59 | jsonUpdate: Schema.NullishOr(CoordinateForFrontend), 60 | }), 61 | createdAt: CustomDateTimeInsert, 62 | updatedAt: CustomDateTimeUpdate, 63 | }) {} 64 | 65 | export class ChallengeEventView extends Model.Class( 66 | 'ChallengeEventView', 67 | )({ 68 | ...ChallengeEvent.fields, 69 | totalParticipants: Model.FieldExcept( 70 | 'update', 71 | 'insert', 72 | 'jsonUpdate', 73 | 'jsonCreate', 74 | )( 75 | Schema.Number.pipe( 76 | Schema.nonNegative(), 77 | Schema.annotations({ 78 | default: 0, 79 | description: 80 | '이 챌린지에 참여한 유저의 수 (이벤트에 참여한 유저의 수가 아님!)', 81 | }), 82 | ), 83 | ), 84 | challengeEventCheckedParticipantsCount: Model.FieldExcept( 85 | 'update', 86 | 'insert', 87 | 'jsonUpdate', 88 | 'jsonCreate', 89 | )( 90 | Schema.Number.pipe( 91 | Schema.nonNegative(), 92 | Schema.annotations({ 93 | default: 0, 94 | description: '이 이벤트를 완료한 유저의 수', 95 | }), 96 | ), 97 | ), 98 | challengeEventCheckedParticipantsFraction: Model.FieldExcept( 99 | 'update', 100 | 'insert', 101 | 'jsonUpdate', 102 | 'jsonCreate', 103 | )( 104 | Schema.Number.pipe( 105 | Schema.nonNegative(), 106 | Schema.annotations({ 107 | default: 0, 108 | description: 109 | '이 이벤트를 완료한 유저의 비율 (분모: 챌린지 참가자 수 = totalParticipants)', 110 | }), 111 | ), 112 | ), 113 | }) {} 114 | -------------------------------------------------------------------------------- /src/challenge-event/helper-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const CoordinateForFrontend = Schema.Tuple( 4 | Schema.Number, 5 | Schema.Number, 6 | ).pipe( 7 | Schema.annotations({ 8 | description: '위도, 경도; 경도가 -이면 영국 서쪽, +이면 영국 동쪽부터', 9 | example: [37.1234, 127.1234], 10 | jsonSchema: { 11 | type: 'array', 12 | items: { type: 'number', minimum: -180, maximum: 180 }, 13 | minItems: 2, 14 | maxItems: 2, 15 | default: [37.1234, 127.1234], 16 | }, 17 | }), 18 | ); 19 | 20 | export const FromStringToCoordinate = Schema.transform( 21 | Schema.String, 22 | CoordinateForFrontend, 23 | { 24 | strict: true, 25 | decode: (fromA) => { 26 | // POINT(경도 위도) 형태로 들어옴 27 | const match = fromA.match(/POINT\(([^ ]+) ([^ ]+)\)/); 28 | if (!match) { 29 | throw new Error('Invalid coordinate format'); 30 | } 31 | 32 | const [longitude, latitude] = [Number(match[1]), Number(match[2])]; 33 | // FE에 줄 때는 위도(latitude), 경도(longitude) 순으로 줘야 함 34 | return [latitude, longitude] as const; 35 | }, 36 | encode: (toA) => { 37 | // FE로부터 받아왔을 때 위도(latitude), 경도(longitude) 순으로 받음 38 | const [latitude, longitude] = toA; 39 | // DB에 넣을때는 경도(longitude), 위도(latitude) 순서로 넣어야 함 40 | return `POINT(${longitude} ${latitude})`; 41 | }, 42 | }, 43 | ); 44 | 45 | export const ChallengeEventCheckRequest = Schema.Struct({ 46 | checkType: Schema.Literal('location', 'duration', 'manual', 'other'), 47 | location: Schema.NullishOr(CoordinateForFrontend), 48 | }); 49 | 50 | export const ChallengeEventCheckResponse = Schema.Struct({ 51 | result: Schema.Literal('success', 'fail'), 52 | message: Schema.NullishOr(Schema.String), 53 | }); 54 | 55 | export const Meters = Schema.Number.pipe(Schema.brand('Meters')); 56 | 57 | export type Meters = typeof Meters.Type; 58 | -------------------------------------------------------------------------------- /src/challenge/challenge-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { HttpApiBuilder } from '@effect/platform'; 3 | import { Effect, Layer } from 'effect'; 4 | import { ChallengeService } from './challenge-service.mjs'; 5 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 6 | import { policyUse } from '@/auth/authorization.mjs'; 7 | import { ChallengePolicy } from './challenge-policy.mjs'; 8 | import { ChallengeParticipantService } from './challenge-participant-service.mjs'; 9 | import { TagPolicy } from '@/tag/tag-policy.mjs'; 10 | 11 | export const ChallengeApiLive = HttpApiBuilder.group( 12 | Api, 13 | 'challenge', 14 | (handlers) => 15 | Effect.gen(function* () { 16 | const challengeService = yield* ChallengeService; 17 | const challengeParticipantService = yield* ChallengeParticipantService; 18 | const challengePolicy = yield* ChallengePolicy; 19 | const tagPolicy = yield* TagPolicy; 20 | 21 | return handlers 22 | .handle('findAll', ({ urlParams }) => 23 | challengeService.findChallenges(urlParams), 24 | ) 25 | .handle('findById', ({ path }) => 26 | challengeService.findByIdWithView(path.challengeId), 27 | ) 28 | .handle('findTags', ({ path }) => 29 | challengeService.findTags(path.challengeId), 30 | ) 31 | .handle('addTags', ({ path, payload }) => 32 | challengeService 33 | .addTags({ challengeId: path.challengeId, names: payload.names }) 34 | .pipe(policyUse(tagPolicy.canConnectChallenge(path.challengeId))), 35 | ) 36 | .handle('deleteTag', ({ path }) => 37 | challengeService 38 | .deleteTag({ 39 | challengeId: path.challengeId, 40 | tagId: path.tagId, 41 | }) 42 | .pipe(policyUse(tagPolicy.canConnectChallenge(path.challengeId))), 43 | ) 44 | .handle('create', ({ payload }) => 45 | challengeService 46 | .create(payload) 47 | .pipe(policyUse(challengePolicy.canCreate(payload))), 48 | ) 49 | .handle('updateById', ({ path, payload }) => 50 | challengeService 51 | .updateById(path.challengeId, payload) 52 | .pipe(policyUse(challengePolicy.canUpdate(path.challengeId))), 53 | ) 54 | .handle('deleteById', ({ path }) => 55 | challengeService 56 | .deleteById(path.challengeId) 57 | .pipe(policyUse(challengePolicy.canDelete(path.challengeId))), 58 | ) 59 | .handle('findLikeStatus', ({ path }) => 60 | challengeService.findLikeStatus(path.challengeId), 61 | ) 62 | .handle('likeChallengeById', ({ path }) => 63 | challengeService 64 | .addLikeChallengeById(path.challengeId) 65 | .pipe(policyUse(challengePolicy.canLike(path.challengeId))), 66 | ) 67 | .handle('removeLikeChallengeById', ({ path }) => 68 | challengeService 69 | .removeLikeChallengeById(path.challengeId) 70 | .pipe(policyUse(challengePolicy.canLike(path.challengeId))), 71 | ) 72 | .handle('dislikeChallengeById', ({ path }) => 73 | challengeService 74 | .addDislikeChallengeById(path.challengeId) 75 | .pipe(policyUse(challengePolicy.canDislike(path.challengeId))), 76 | ) 77 | .handle('removeDislikeChallengeById', ({ path }) => 78 | challengeService 79 | .removeDislikeChallengeById(path.challengeId) 80 | .pipe(policyUse(challengePolicy.canDislike(path.challengeId))), 81 | ) 82 | .handle('getChallengeMembers', ({ path }) => 83 | challengeParticipantService.getChallengeMembers(path.challengeId), 84 | ) 85 | .handle('joinChallengeById', ({ path }) => 86 | challengeParticipantService 87 | .join(path.challengeId) 88 | .pipe(policyUse(challengePolicy.canJoin(path.challengeId))), 89 | ) 90 | .handle('leaveChallengeById', ({ path }) => 91 | challengeParticipantService 92 | .leave(path.challengeId) 93 | .pipe(policyUse(challengePolicy.canJoin(path.challengeId))), 94 | ); 95 | }), 96 | ).pipe( 97 | Layer.provide(AuthenticationLive), 98 | Layer.provide(ChallengeService.Live), 99 | Layer.provide(ChallengeParticipantService.Live), 100 | Layer.provide(ChallengePolicy.Live), 101 | Layer.provide(TagPolicy.Live), 102 | ); 103 | -------------------------------------------------------------------------------- /src/challenge/challenge-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { ChallengeId } from './challenge-schema.mjs'; 4 | 5 | export class ChallengeNotFound extends Schema.TaggedError()( 6 | 'ChallengeNotFound', 7 | { id: ChallengeId }, 8 | HttpApiSchema.annotations({ 9 | status: 404, 10 | title: 'Challenge Not Found', 11 | description: 'ID에 해당하는 챌린지가 존재하지 않습니다.', 12 | }), 13 | ) {} 14 | -------------------------------------------------------------------------------- /src/challenge/challenge-participant-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { ChallengeParticipantId } from './challenge-participant-schema.mjs'; 4 | import { AccountId } from '@/account/account-schema.mjs'; 5 | import { ChallengeId } from './challenge-schema.mjs'; 6 | 7 | export class ChallengeParticipantNotFound extends Schema.TaggedError()( 8 | 'ChallengeParticipantNotFound', 9 | { id: ChallengeParticipantId }, 10 | HttpApiSchema.annotations({ 11 | status: 404, 12 | description: '챌린지 참가자를 찾을 수 없습니다.', 13 | }), 14 | ) {} 15 | 16 | export class ChallengeParticipantTargetNotFound extends Schema.TaggedError()( 17 | 'ChallengeParticipantTargetNotFound', 18 | { 19 | accountId: Schema.NullishOr(AccountId), 20 | challengeId: Schema.NullishOr(ChallengeId), 21 | }, 22 | HttpApiSchema.annotations({ 23 | status: 404, 24 | description: '해당 챌린지 참가자를 찾을 수 없습니다.', 25 | }), 26 | ) {} 27 | 28 | export class ChallengeParticipantConflict extends Schema.TaggedError()( 29 | 'ChallengeParticipantConflict', 30 | { accountId: AccountId, challengeId: ChallengeId }, 31 | HttpApiSchema.annotations({ 32 | status: 409, 33 | description: '챌린지 참가가 이미 존재합니다.', 34 | }), 35 | ) {} 36 | -------------------------------------------------------------------------------- /src/challenge/challenge-participant-repo.mts: -------------------------------------------------------------------------------- 1 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 2 | import { SqlLive } from '@/sql/sql-live.mjs'; 3 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 4 | import { Effect, Layer, Option, pipe, Schema } from 'effect'; 5 | import { 6 | ChallengeParticipantConflict, 7 | ChallengeParticipantNotFound, 8 | } from './challenge-participant-error.mjs'; 9 | import { 10 | ChallengeParticipant, 11 | ChallengeParticipantId, 12 | } from './challenge-participant-schema.mjs'; 13 | import { Account } from '@/account/account-schema.mjs'; 14 | import { ChallengeId } from './challenge-schema.mjs'; 15 | 16 | const TABLE_NAME = 'challenge_participant'; 17 | 18 | const Target = ChallengeParticipant.pipe( 19 | Schema.pick('accountId', 'challengeId'), 20 | ); 21 | 22 | const make = Effect.gen(function* () { 23 | const sql = yield* SqlClient.SqlClient; 24 | 25 | const repo = yield* Model.makeRepository(ChallengeParticipant, { 26 | tableName: TABLE_NAME, 27 | spanPrefix: 'ChallengeParticipantRepo', 28 | idColumn: 'id', 29 | }); 30 | 31 | const findParticipantByTarget = (target: typeof Target.Type) => 32 | SqlSchema.findOne({ 33 | Request: Target, 34 | Result: ChallengeParticipant, 35 | execute: (req) => 36 | sql`select * from ${sql(TABLE_NAME)} where account_id = ${req.accountId} and challenge_id = ${req.challengeId}`, 37 | })(target).pipe( 38 | Effect.orDie, 39 | Effect.withSpan('ChallengeParticipantRepo.findParticipantByTarget'), 40 | ); 41 | 42 | const findParticipantsByChallengeId = (challengeId: ChallengeId) => 43 | SqlSchema.findAll({ 44 | Request: ChallengeId, 45 | Result: Account, 46 | execute: (req) => 47 | sql`select challenge_participant.challenge_id as challenge_id, account.* from ${sql(TABLE_NAME)} left join account on account.id = challenge_participant.account_id where challenge_participant.challenge_id = ${req}`, 48 | })(challengeId).pipe( 49 | Effect.orDie, 50 | Effect.withSpan('ChallengeParticipantRepo.findParticipantsByChallengeId'), 51 | ); 52 | 53 | const with_ = ( 54 | id: ChallengeParticipantId, 55 | f: (participant: ChallengeParticipant) => Effect.Effect, 56 | ): Effect.Effect => 57 | pipe( 58 | repo.findById(id), 59 | Effect.flatMap( 60 | Option.match({ 61 | onSome: Effect.succeed, 62 | onNone: () => new ChallengeParticipantNotFound({ id }), 63 | }), 64 | ), 65 | Effect.flatMap(f), 66 | sql.withTransaction, 67 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 68 | ); 69 | 70 | const withoutTarget_ = ( 71 | target: typeof Target.Type, 72 | f: (result: boolean) => Effect.Effect, 73 | ): Effect.Effect => 74 | pipe( 75 | findParticipantByTarget(target), 76 | Effect.flatMap( 77 | Option.match({ 78 | onSome: () => new ChallengeParticipantConflict(target), 79 | onNone: () => Effect.succeed(true), 80 | }), 81 | ), 82 | Effect.flatMap(f), 83 | sql.withTransaction, 84 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 85 | ); 86 | 87 | const withTarget_ = ( 88 | target: typeof Target.Type, 89 | f: (participant: ChallengeParticipant) => Effect.Effect, 90 | ): Effect.Effect => 91 | pipe( 92 | findParticipantByTarget(target), 93 | Effect.flatMap( 94 | Option.match({ 95 | onSome: Effect.succeed, 96 | onNone: () => 97 | new ChallengeParticipantNotFound({ 98 | id: ChallengeParticipantId.make(''), 99 | }), 100 | }), 101 | ), 102 | Effect.flatMap(f), 103 | sql.withTransaction, 104 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 105 | ); 106 | 107 | return { 108 | ...repo, 109 | findParticipantsByChallengeId, 110 | findParticipantByTarget, 111 | with: with_, 112 | withTarget: withTarget_, 113 | withoutTarget: withoutTarget_, 114 | } as const; 115 | }); 116 | 117 | export class ChallengeParticipantRepo extends Effect.Tag( 118 | 'ChallengeParticipantRepo', 119 | )>() { 120 | static layer = Layer.effect(ChallengeParticipantRepo, make); 121 | static Live = this.layer.pipe(Layer.provide(SqlLive)); 122 | static Test = makeTestLayer(ChallengeParticipantRepo)({}); 123 | } 124 | -------------------------------------------------------------------------------- /src/challenge/challenge-participant-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Schema } from 'effect'; 8 | import { ChallengeId } from './challenge-schema.mjs'; 9 | 10 | export const ChallengeParticipantId = Schema.String.pipe( 11 | Schema.brand('ChallengeParticipantId'), 12 | ); 13 | 14 | export type ChallengeParticipantId = typeof ChallengeParticipantId.Type; 15 | 16 | export class ChallengeParticipant extends Model.Class( 17 | 'ChallengeParticipant', 18 | )({ 19 | id: Model.Generated(ChallengeParticipantId), 20 | accountId: AccountId, 21 | challengeId: ChallengeId, 22 | isDeleted: Schema.Boolean, 23 | isFinished: Schema.Boolean, 24 | isWinner: Schema.Boolean, 25 | createdAt: CustomDateTimeInsert, 26 | updatedAt: CustomDateTimeUpdate, 27 | }) {} 28 | -------------------------------------------------------------------------------- /src/challenge/challenge-participant-service.mts: -------------------------------------------------------------------------------- 1 | import { CurrentAccount } from '@/account/account-schema.mjs'; 2 | import { policyRequire } from '@/auth/authorization.mjs'; 3 | import { Effect, Layer, pipe } from 'effect'; 4 | import { ChallengeParticipantRepo } from './challenge-participant-repo.mjs'; 5 | import { 6 | ChallengeParticipant, 7 | ChallengeParticipantId, 8 | } from './challenge-participant-schema.mjs'; 9 | import { ChallengeId } from './challenge-schema.mjs'; 10 | import { SqlTest } from '@/sql/sql-test.mjs'; 11 | import { ChallengeRepo } from './challenge-repo.mjs'; 12 | 13 | const make = Effect.gen(function* () { 14 | const repo = yield* ChallengeParticipantRepo; 15 | const challengeRepo = yield* ChallengeRepo; 16 | 17 | const findIdFromRepo = (id: ChallengeParticipantId) => repo.findById(id); 18 | 19 | const join = (challengeId: ChallengeId) => 20 | pipe( 21 | CurrentAccount, 22 | Effect.flatMap(({ id: accountId }) => 23 | repo.withoutTarget( 24 | { 25 | accountId, 26 | challengeId, 27 | }, 28 | () => 29 | repo.insert( 30 | ChallengeParticipant.insert.make({ 31 | challengeId, 32 | accountId, 33 | isDeleted: false, 34 | isFinished: false, 35 | isWinner: false, 36 | updatedAt: undefined, 37 | createdAt: undefined, 38 | }), 39 | ), 40 | ), 41 | ), 42 | Effect.withSpan('ChallengeParticipantService.join'), 43 | policyRequire('challenge', 'join'), 44 | ); 45 | 46 | const leave = (challengeId: ChallengeId) => 47 | pipe( 48 | CurrentAccount, 49 | Effect.flatMap(({ id: accountId }) => 50 | repo.withTarget( 51 | { 52 | accountId, 53 | challengeId, 54 | }, 55 | (participant) => repo.delete(participant.id), 56 | ), 57 | ), 58 | Effect.withSpan('ChallengeParticipantService.leave'), 59 | policyRequire('challenge', 'join'), 60 | ); 61 | 62 | const getChallengeMembers = (challengeId: ChallengeId) => 63 | challengeRepo 64 | .with(challengeId, (challenge) => 65 | repo.findParticipantsByChallengeId(challenge.id), 66 | ) 67 | .pipe(Effect.withSpan('ChallengeParticipantService.getChallengeMembers')); 68 | 69 | return { 70 | findIdFromRepo, 71 | getChallengeMembers, 72 | join, 73 | leave, 74 | } as const; 75 | }); 76 | 77 | export class ChallengeParticipantService extends Effect.Tag( 78 | 'ChallengeParticipantService', 79 | )>() { 80 | static layer = Layer.effect(ChallengeParticipantService, make); 81 | 82 | static Live = this.layer.pipe( 83 | Layer.provide(ChallengeParticipantRepo.Live), 84 | Layer.provide(ChallengeRepo.Live), 85 | ); 86 | 87 | static Test = this.layer.pipe(Layer.provideMerge(SqlTest)); 88 | } 89 | -------------------------------------------------------------------------------- /src/challenge/challenge-policy.mts: -------------------------------------------------------------------------------- 1 | import { Effect, Layer, pipe } from 'effect'; 2 | import { ChallengeRepo } from './challenge-repo.mjs'; 3 | import { Challenge, ChallengeId } from './challenge-schema.mjs'; 4 | import { policy } from '@/auth/authorization.mjs'; 5 | 6 | const make = Effect.gen(function* () { 7 | const challengeRepo = yield* ChallengeRepo; 8 | 9 | const canCreate = (_toCreate: typeof Challenge.jsonCreate.Type) => 10 | policy('challenge', 'create', (actor) => Effect.succeed(true)); 11 | 12 | const canRead = (id: ChallengeId) => 13 | policy('challenge', 'read', (_actor) => Effect.succeed(true)); 14 | 15 | const canUpdate = (id: ChallengeId) => 16 | policy( 17 | 'challenge', 18 | 'update', 19 | (actor) => 20 | pipe( 21 | challengeRepo.with(id, (challenge) => 22 | pipe( 23 | Effect.succeed( 24 | actor.id === challenge.accountId || actor.role === 'admin', 25 | ), 26 | ), 27 | ), 28 | ), 29 | '챌린지 작성자나 관리자만 챌린지를 수정할 수 있습니다.', 30 | ); 31 | 32 | const canDelete = (id: ChallengeId) => 33 | policy( 34 | 'challenge', 35 | 'delete', 36 | (actor) => 37 | pipe( 38 | challengeRepo.with(id, (challenge) => 39 | pipe( 40 | Effect.succeed( 41 | actor.id === challenge.accountId || actor.role === 'admin', 42 | ), 43 | ), 44 | ), 45 | ), 46 | '챌린지 작성자나 관리자만 챌린지를 삭제할 수 있습니다.', 47 | ); 48 | 49 | const canLike = (toLike: ChallengeId) => 50 | policy( 51 | 'challenge', 52 | 'like', 53 | (actor) => 54 | pipe( 55 | challengeRepo.with(toLike, (challenge) => 56 | pipe(Effect.succeed(actor.id !== challenge.accountId)), 57 | ), 58 | ), 59 | '챌린지 작성자는 챌린지를 좋아요 할 수 없습니다.', 60 | ); 61 | 62 | const canDislike = (toDislike: ChallengeId) => 63 | policy( 64 | 'challenge', 65 | 'dislike', 66 | (actor) => 67 | pipe( 68 | challengeRepo.with(toDislike, (challenge) => 69 | pipe(Effect.succeed(actor.id !== challenge.accountId)), 70 | ), 71 | ), 72 | '챌린지 작성자는 챌린지를 좋아요 취소할 수 없습니다.', 73 | ); 74 | 75 | const canJoin = (_toJoin: ChallengeId) => 76 | policy('challenge', 'join', () => Effect.succeed(true)); 77 | 78 | return { 79 | canCreate, 80 | canRead, 81 | canUpdate, 82 | canDelete, 83 | canLike, 84 | canDislike, 85 | canJoin, 86 | } as const; 87 | }); 88 | 89 | export class ChallengePolicy extends Effect.Tag('Challenge/ChallengePolicy')< 90 | ChallengePolicy, 91 | Effect.Effect.Success 92 | >() { 93 | static layer = Layer.effect(ChallengePolicy, make); 94 | static Live = this.layer.pipe(Layer.provide(ChallengeRepo.Live)); 95 | } 96 | -------------------------------------------------------------------------------- /src/challenge/challenge-repo.mts: -------------------------------------------------------------------------------- 1 | import { CommonCountSchema } from '@/misc/common-count-schema.mjs'; 2 | import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; 3 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 4 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 5 | import { SqlLive } from '@/sql/sql-live.mjs'; 6 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 7 | import { Effect, Layer, Option, pipe, Schema } from 'effect'; 8 | import { ChallengeNotFound } from './challenge-error.mjs'; 9 | import { Challenge, ChallengeId, ChallengeView } from './challenge-schema.mjs'; 10 | import { Tag } from '@/tag/tag-schema.mjs'; 11 | import { AccountId } from '@/account/account-schema.mjs'; 12 | 13 | const snakeCase = (str: string) => 14 | str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); 15 | 16 | const TABLE_NAME = 'challenge'; 17 | const VIEW_NAME = 'challenge_like_counts'; 18 | 19 | const make = Effect.gen(function* () { 20 | const sql = yield* SqlClient.SqlClient; 21 | const repo = yield* Model.makeRepository(Challenge, { 22 | tableName: TABLE_NAME, 23 | spanPrefix: 'ChallengeRepo', 24 | idColumn: 'id', 25 | }); 26 | 27 | const viewRepo = yield* Model.makeRepository(ChallengeView, { 28 | tableName: VIEW_NAME, 29 | spanPrefix: 'ChallengeViewRepo', 30 | idColumn: 'id', 31 | }); 32 | 33 | const findTags = (challengeId: ChallengeId) => 34 | SqlSchema.findAll({ 35 | Request: ChallengeId, 36 | Result: Tag, 37 | execute: (req) => sql` 38 | SELECT DISTINCT t.* 39 | FROM tag t 40 | left join tag_target tt on tt.tag_id = t.id 41 | LEFT JOIN challenge c ON tt.challenge_id = c.id 42 | WHERE c.id = ${req};`, 43 | })(challengeId).pipe( 44 | Effect.orDie, 45 | Effect.withSpan('ChallengeRepo.findTags'), 46 | ); 47 | 48 | const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => 49 | Effect.gen(function* () { 50 | const challenges = yield* SqlSchema.findAll({ 51 | Request: FindManyUrlParams, 52 | Result: ChallengeView, 53 | execute: (req) => 54 | sql`select * 55 | from ${sql(VIEW_NAME)} 56 | where ${sql.and( 57 | accountId 58 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 59 | : [sql`is_deleted = false`], 60 | )} 61 | order by ${sql(snakeCase(params.sortBy))} 62 | ${sql.unsafe(params.order)} 63 | limit ${params.limit} 64 | offset ${(params.page - 1) * params.limit}`, 65 | })(params); 66 | const { total } = yield* SqlSchema.single({ 67 | Request: FindManyUrlParams, 68 | Result: CommonCountSchema, 69 | execute: () => 70 | sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( 71 | accountId 72 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 73 | : [sql`is_deleted = false`], 74 | )}`, 75 | })(params); 76 | 77 | const ResultSchema = FindManyResultSchema(ChallengeView); 78 | 79 | const result = ResultSchema.make({ 80 | data: challenges, 81 | meta: { 82 | total, 83 | page: params.page, 84 | limit: params.limit, 85 | isLastPage: params.page * params.limit + challenges.length >= total, 86 | }, 87 | }); 88 | 89 | return result; 90 | }).pipe(Effect.orDie, Effect.withSpan('ChallengeRepo.findAll')); 91 | 92 | const insert = (challenge: typeof Challenge.insert.Type) => 93 | SqlSchema.single({ 94 | Request: Challenge.insert, 95 | Result: Schema.Any, 96 | execute: (request) => 97 | sql`insert into ${sql(TABLE_NAME)} ${sql 98 | .insert(request) 99 | .returning('*')}`, 100 | })(challenge).pipe(Effect.orDie); 101 | 102 | const with_ = ( 103 | id: ChallengeId, 104 | f: (post: Challenge) => Effect.Effect, 105 | ): Effect.Effect => { 106 | return pipe( 107 | repo.findById(id), 108 | Effect.flatMap( 109 | Option.match({ 110 | onNone: () => new ChallengeNotFound({ id }), 111 | onSome: Effect.succeed, 112 | }), 113 | ), 114 | Effect.flatMap(f), 115 | sql.withTransaction, 116 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 117 | ); 118 | }; 119 | 120 | const withView_ = ( 121 | id: ChallengeId, 122 | f: (post: ChallengeView) => Effect.Effect, 123 | ): Effect.Effect => { 124 | return pipe( 125 | viewRepo.findById(id), 126 | Effect.flatMap( 127 | Option.match({ 128 | onNone: () => new ChallengeNotFound({ id }), 129 | onSome: Effect.succeed, 130 | }), 131 | ), 132 | Effect.flatMap(f), 133 | sql.withTransaction, 134 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 135 | ); 136 | }; 137 | 138 | return { 139 | ...repo, 140 | insert, 141 | viewRepo, 142 | findAllWithView, 143 | findTags, 144 | with: with_, 145 | withView: withView_, 146 | } as const; 147 | }); 148 | 149 | export class ChallengeRepo extends Effect.Tag('ChallengeRepo')< 150 | ChallengeRepo, 151 | Effect.Effect.Success 152 | >() { 153 | static Live = Layer.effect(ChallengeRepo, make).pipe(Layer.provide(SqlLive)); 154 | static Test = makeTestLayer(ChallengeRepo)({}); 155 | } 156 | -------------------------------------------------------------------------------- /src/challenge/challenge-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Schema } from 'effect'; 8 | 9 | export const ChallengeId = Schema.String.pipe(Schema.brand('ChallengeId')); 10 | 11 | export type ChallengeId = typeof ChallengeId.Type; 12 | const today = new Date().toISOString().split('T')[0]; 13 | const twoWeeksLater = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) 14 | .toISOString() 15 | .split('T')[0]; 16 | export class Challenge extends Model.Class('Challenge')({ 17 | id: Model.Generated(ChallengeId), 18 | title: Schema.String, 19 | description: Schema.String, 20 | challengeImageUrl: Schema.NullishOr(Schema.String), 21 | type: Schema.String.pipe( 22 | Schema.annotations({ 23 | description: '챌린지의 종류', 24 | examples: ['self-check', 'time-based', 'event-based', 'etc'], 25 | default: 'self-check', 26 | }), 27 | ), 28 | startDate: Schema.NullishOr(Schema.Any).pipe( 29 | Schema.annotations({ 30 | description: '챌린지 시작일', 31 | default: today, 32 | }), 33 | ), 34 | endDate: Schema.NullishOr(Schema.Any).pipe( 35 | Schema.annotations({ 36 | description: '챌린지 종료일', 37 | default: twoWeeksLater, 38 | }), 39 | ), 40 | 41 | accountId: Model.Sensitive(AccountId), 42 | isDeleted: Schema.Boolean.annotations({ 43 | default: false, 44 | }), 45 | isPublished: Schema.Boolean.annotations({ 46 | default: false, 47 | }), 48 | isFinished: Schema.Boolean.annotations({ 49 | default: false, 50 | }), 51 | createdAt: CustomDateTimeInsert, 52 | updatedAt: CustomDateTimeUpdate, 53 | }) {} 54 | 55 | export class ChallengeView extends Model.Class('ChallengeView')({ 56 | ...Challenge.fields, 57 | createdAt: Schema.Any, 58 | updatedAt: Schema.Any, 59 | accountUsername: Model.FieldExcept( 60 | 'update', 61 | 'insert', 62 | 'jsonUpdate', 63 | 'jsonCreate', 64 | )( 65 | Schema.NullishOr( 66 | Schema.String.pipe( 67 | Schema.annotations({ 68 | description: '이 챌린지을 쓴 유저의 username', 69 | }), 70 | ), 71 | ), 72 | ), 73 | likeCount: Model.FieldExcept( 74 | 'update', 75 | 'insert', 76 | 'jsonUpdate', 77 | 'jsonCreate', 78 | )( 79 | Schema.Number.pipe( 80 | Schema.int(), 81 | Schema.nonNegative(), 82 | Schema.annotations({ 83 | default: 0, 84 | description: '이 챌린지에 달린 좋아요의 수', 85 | }), 86 | ), 87 | ), 88 | dislikeCount: Model.FieldExcept( 89 | 'update', 90 | 'insert', 91 | 'jsonUpdate', 92 | 'jsonCreate', 93 | )( 94 | Schema.Number.pipe( 95 | Schema.int(), 96 | Schema.nonNegative(), 97 | Schema.annotations({ 98 | default: 0, 99 | description: '이 챌린지에 달린 싫어요의 수', 100 | }), 101 | ), 102 | ), 103 | pureLikeCount: Model.FieldExcept( 104 | 'update', 105 | 'insert', 106 | 'jsonUpdate', 107 | 'jsonCreate', 108 | )( 109 | Schema.Number.pipe( 110 | Schema.int(), 111 | Schema.nonNegative(), 112 | Schema.annotations({ 113 | default: 0, 114 | description: '이 챌린지에 달린 순 좋아요의 수', 115 | }), 116 | ), 117 | ), 118 | controversialCount: Model.FieldExcept( 119 | 'update', 120 | 'insert', 121 | 'jsonUpdate', 122 | 'jsonCreate', 123 | )( 124 | Schema.Number.pipe( 125 | Schema.int(), 126 | Schema.nonNegative(), 127 | Schema.annotations({ 128 | default: 0, 129 | description: '이 챌린지에 달린 like + dislike 수', 130 | }), 131 | ), 132 | ), 133 | challengeEventCount: Model.FieldExcept( 134 | 'update', 135 | 'insert', 136 | 'jsonUpdate', 137 | 'jsonCreate', 138 | )( 139 | Schema.Number.pipe( 140 | Schema.int(), 141 | Schema.nonNegative(), 142 | Schema.annotations({ 143 | default: 0, 144 | description: '이 챌린지에 달린 이벤트의 수', 145 | }), 146 | ), 147 | ), 148 | challengeParticipantCount: Model.FieldExcept( 149 | 'update', 150 | 'insert', 151 | 'jsonUpdate', 152 | 'jsonCreate', 153 | )( 154 | Schema.Number.pipe( 155 | Schema.int(), 156 | Schema.nonNegative(), 157 | Schema.annotations({ 158 | default: 0, 159 | description: '이 챌린지에 참여한 유저의 수', 160 | }), 161 | ), 162 | ), 163 | challengeEventCheckedParticipantsFraction: Model.FieldExcept( 164 | 'update', 165 | 'insert', 166 | 'jsonUpdate', 167 | 'jsonCreate', 168 | )( 169 | Schema.Number.pipe( 170 | Schema.nonNegative(), 171 | Schema.annotations({ 172 | default: 0, 173 | description: '이 챌린지에 참여한 유저 중 이벤트를 완료한 유저의 비율', 174 | }), 175 | ), 176 | ), 177 | }) {} 178 | -------------------------------------------------------------------------------- /src/challenge/challenge-service.mts: -------------------------------------------------------------------------------- 1 | import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; 2 | import { policyRequire } from '@/auth/authorization.mjs'; 3 | import { LikeService } from '@/like/like-service.mjs'; 4 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 5 | import { SqlTest } from '@/sql/sql-test.mjs'; 6 | import { Effect, Layer, pipe } from 'effect'; 7 | import { ChallengeRepo } from './challenge-repo.mjs'; 8 | import { Challenge, ChallengeId } from './challenge-schema.mjs'; 9 | import { TagService } from '@/tag/tag-service.mjs'; 10 | import { TagId } from '@/tag/tag-schema.mjs'; 11 | 12 | const make = Effect.gen(function* () { 13 | const challengeRepo = yield* ChallengeRepo; 14 | const likeService = yield* LikeService; 15 | const tagService = yield* TagService; 16 | 17 | const findByIdWithView = (id: ChallengeId) => 18 | challengeRepo.withView(id, (challenge) => Effect.succeed(challenge)); 19 | 20 | const findByIdFromRepo = (id: ChallengeId) => challengeRepo.findById(id); 21 | 22 | const findChallenges = (params: FindManyUrlParams, accountId?: AccountId) => 23 | challengeRepo 24 | .findAllWithView(params, accountId) 25 | .pipe(Effect.withSpan('ChallengeService.findChallenges')); 26 | 27 | const findTags = (challengeId: ChallengeId) => 28 | challengeRepo 29 | .findTags(challengeId) 30 | .pipe(Effect.withSpan('ChallengeService.findTags')); 31 | 32 | const addTags = (payload: { 33 | challengeId: ChallengeId; 34 | names: readonly string[]; 35 | }) => tagService.connectChallengeByNames(payload); 36 | 37 | const deleteTag = (payload: { challengeId: ChallengeId; tagId: TagId }) => 38 | tagService.deleteChallengeTagConnection(payload); 39 | 40 | const create = (challenge: typeof Challenge.jsonCreate.Type) => 41 | pipe( 42 | CurrentAccount, 43 | Effect.flatMap(({ id: accountId }) => 44 | challengeRepo.insert( 45 | Challenge.insert.make({ 46 | ...challenge, 47 | accountId, 48 | updatedAt: undefined, 49 | createdAt: undefined, 50 | }), 51 | ), 52 | ), 53 | Effect.withSpan('ChallengeService.createChallenge'), 54 | policyRequire('challenge', 'create'), 55 | Effect.flatMap((challenge) => findByIdWithView(challenge.id)), 56 | ); 57 | 58 | const updateById = ( 59 | challengeId: ChallengeId, 60 | challenge: Partial, 61 | ) => 62 | challengeRepo.with(challengeId, (existing) => 63 | pipe( 64 | challengeRepo.update({ 65 | ...existing, 66 | ...challenge, 67 | updatedAt: undefined, 68 | }), 69 | Effect.withSpan('ChallengeService.updateChallenge'), 70 | policyRequire('challenge', 'update'), 71 | Effect.flatMap((challenge) => findByIdWithView(challenge.id)), 72 | ), 73 | ); 74 | 75 | const deleteById = (id: ChallengeId) => 76 | challengeRepo.with(id, (challenge) => 77 | pipe( 78 | challengeRepo.update({ 79 | ...challenge, 80 | isDeleted: true, 81 | updatedAt: undefined, 82 | }), 83 | Effect.withSpan('ChallengeService.deleteById'), 84 | policyRequire('challenge', 'delete'), 85 | ), 86 | ); 87 | 88 | const findLikeStatus = (challengeId: ChallengeId) => 89 | pipe(likeService.getLikeStatusByChallengeId(challengeId)); 90 | 91 | const addLikeChallengeById = (challengeId: ChallengeId) => 92 | challengeRepo.with(challengeId, (challenge) => 93 | likeService.addLikeChallengeById(challenge.id).pipe( 94 | Effect.withSpan('ChallengeService.addLikeChallengeById'), 95 | Effect.flatMap(() => findByIdWithView(challenge.id)), 96 | ), 97 | ); 98 | 99 | const removeLikeChallengeById = (challengeId: ChallengeId) => 100 | challengeRepo.with(challengeId, (challenge) => 101 | likeService.removeLikeChallengeById(challenge.id).pipe( 102 | Effect.withSpan('ChallengeService.removeLikeChallengeById'), 103 | Effect.flatMap(() => findByIdWithView(challenge.id)), 104 | ), 105 | ); 106 | 107 | const addDislikeChallengeById = (challengeId: ChallengeId) => 108 | challengeRepo.with(challengeId, (challenge) => 109 | likeService.addDislikeChallengeById(challenge.id).pipe( 110 | Effect.withSpan('ChallengeService.addDislikeChallengeById'), 111 | policyRequire('challenge', 'dislike'), 112 | Effect.flatMap(() => findByIdWithView(challenge.id)), 113 | ), 114 | ); 115 | 116 | const removeDislikeChallengeById = (challengeId: ChallengeId) => 117 | challengeRepo.with(challengeId, (challenge) => 118 | likeService.removeDislikeChallengeById(challenge.id).pipe( 119 | Effect.withSpan('ChallengeService.removeDislikeChallengeById'), 120 | policyRequire('challenge', 'dislike'), 121 | Effect.flatMap(() => findByIdWithView(challenge.id)), 122 | ), 123 | ); 124 | 125 | return { 126 | findByIdWithView, 127 | findByIdFromRepo, 128 | findChallenges, 129 | findTags, 130 | addTags, 131 | deleteTag, 132 | findLikeStatus, 133 | addLikeChallengeById, 134 | removeLikeChallengeById, 135 | addDislikeChallengeById, 136 | removeDislikeChallengeById, 137 | create, 138 | updateById, 139 | deleteById, 140 | } as const; 141 | }); 142 | 143 | export class ChallengeService extends Effect.Tag('ChallengeService')< 144 | ChallengeService, 145 | Effect.Effect.Success 146 | >() { 147 | static layer = Layer.effect(ChallengeService, make); 148 | 149 | static Live = this.layer.pipe( 150 | Layer.provide(ChallengeRepo.Live), 151 | Layer.provide(LikeService.Live), 152 | Layer.provide(TagService.Live), 153 | ); 154 | 155 | static Test = this.layer.pipe(Layer.provideMerge(SqlTest)); 156 | } 157 | -------------------------------------------------------------------------------- /src/comment/comment-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { policyUse, withSystemActor } from '@/auth/authorization.mjs'; 4 | import { HttpApiBuilder } from '@effect/platform'; 5 | import { Effect, Layer } from 'effect'; 6 | import { CommentPolicy } from './comment-policy.mjs'; 7 | import { CommentService } from './comment-service.mjs'; 8 | 9 | export const CommentApiLive = HttpApiBuilder.group(Api, 'comment', (handlers) => 10 | Effect.gen(function* () { 11 | const commentService = yield* CommentService; 12 | const commentPolicy = yield* CommentPolicy; 13 | 14 | return handlers 15 | .handle('findAll', ({ path, urlParams }) => 16 | commentService.findAllByPostId(path.postId, urlParams), 17 | ) 18 | .handle('findById', ({ path }) => 19 | commentService.findByIdWithView(path.commentId), 20 | ) 21 | .handle('getCommentCount', ({ path }) => 22 | commentService.getCommentCount(path.postId), 23 | ) 24 | .handle('create', ({ path, payload }) => 25 | commentService.create(path.postId, payload).pipe(withSystemActor), 26 | ) 27 | .handle('updateById', ({ path, payload }) => 28 | commentService 29 | .update(path.postId, path.commentId, payload) 30 | .pipe(policyUse(commentPolicy.canUpdate(path.commentId))), 31 | ) 32 | .handle('deleteById', ({ path }) => 33 | commentService 34 | .deleteById(path.commentId) 35 | .pipe(policyUse(commentPolicy.canDelete(path.commentId))), 36 | ) 37 | .handle('findLikeStatus', ({ path }) => 38 | commentService.findLikeStatus(path.commentId), 39 | ) 40 | .handle('likeCommentById', ({ path }) => 41 | commentService 42 | .addLikeCommentById(path.commentId) 43 | .pipe(policyUse(commentPolicy.canLike(path.commentId))), 44 | ) 45 | .handle('removeLikeCommentById', ({ path }) => 46 | commentService 47 | .removeLikeCommentById(path.commentId) 48 | .pipe(policyUse(commentPolicy.canLike(path.commentId))), 49 | ) 50 | .handle('dislikeCommentById', ({ path }) => 51 | commentService 52 | .addDislikeCommentById(path.commentId) 53 | .pipe(policyUse(commentPolicy.canDislike(path.commentId))), 54 | ) 55 | .handle('removeDislikeCommentById', ({ path }) => 56 | commentService 57 | .removeDislikeCommentById(path.commentId) 58 | .pipe(policyUse(commentPolicy.canDislike(path.commentId))), 59 | ); 60 | }), 61 | ).pipe( 62 | Layer.provide(AuthenticationLive), 63 | Layer.provide(CommentService.Live), 64 | Layer.provide(CommentPolicy.Live), 65 | ); 66 | -------------------------------------------------------------------------------- /src/comment/comment-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { CommentId } from './comment-schema.mjs'; 4 | 5 | export class CommentNotFound extends Schema.TaggedError()( 6 | 'CommentNotFound', 7 | { id: CommentId }, 8 | HttpApiSchema.annotations({ 9 | status: 404, 10 | title: 'Comment Not Found', 11 | description: 'ID에 해당하는 댓글이 존재하지 않습니다.', 12 | }), 13 | ) {} 14 | -------------------------------------------------------------------------------- /src/comment/comment-policy.mts: -------------------------------------------------------------------------------- 1 | import { Effect, Layer, pipe } from 'effect'; 2 | import { CommentRepo } from './comment-repo.mjs'; 3 | import { Comment, CommentId } from './comment-schema.mjs'; 4 | import { policy } from '@/auth/authorization.mjs'; 5 | 6 | const make = Effect.gen(function* () { 7 | const commentRepo = yield* CommentRepo; 8 | 9 | const canCreate = (_toCreate: typeof Comment.jsonCreate.Type) => 10 | policy('comment', 'create', (actor) => Effect.succeed(true)); 11 | 12 | const canUpdate = (id: CommentId) => 13 | policy( 14 | 'comment', 15 | 'update', 16 | (actor) => 17 | pipe( 18 | commentRepo.with(id, (comment) => 19 | pipe( 20 | Effect.succeed( 21 | actor.id === comment.accountId || actor.role === 'admin', 22 | ), 23 | ), 24 | ), 25 | ), 26 | '댓글 작성자나 관리자만 댓글을 수정할 수 있습니다.', 27 | ); 28 | 29 | const canDelete = (id: CommentId) => 30 | policy( 31 | 'comment', 32 | 'delete', 33 | (actor) => 34 | pipe( 35 | commentRepo.with(id, (comment) => 36 | pipe( 37 | Effect.succeed( 38 | actor.id === comment.accountId || actor.role === 'admin', 39 | ), 40 | ), 41 | ), 42 | ), 43 | '댓글 작성자나 관리자만 댓글을 삭제할 수 있습니다.', 44 | ); 45 | 46 | const canLike = (toLike: CommentId) => 47 | policy( 48 | 'comment', 49 | 'like', 50 | (actor) => 51 | pipe( 52 | commentRepo.with(toLike, (comment) => 53 | pipe(Effect.succeed(actor.id !== comment.accountId)), 54 | ), 55 | ), 56 | '댓글 작성자는 좋아요를 누를 수 없습니다.', 57 | ); 58 | 59 | const canDislike = (toDislike: CommentId) => 60 | policy( 61 | 'comment', 62 | 'dislike', 63 | (actor) => 64 | pipe( 65 | commentRepo.with(toDislike, (comment) => 66 | pipe(Effect.succeed(actor.id !== comment.accountId)), 67 | ), 68 | ), 69 | '댓글 작성자는 싫어요를 누를 수 없습니다.', 70 | ); 71 | 72 | return { 73 | canCreate, 74 | canUpdate, 75 | canDelete, 76 | canLike, 77 | canDislike, 78 | } as const; 79 | }); 80 | 81 | export class CommentPolicy extends Effect.Tag('Comment/CommentPolicy')< 82 | CommentPolicy, 83 | Effect.Effect.Success 84 | >() { 85 | static layer = Layer.effect(CommentPolicy, make); 86 | 87 | static Live = this.layer.pipe(Layer.provide(CommentRepo.Live)); 88 | } 89 | -------------------------------------------------------------------------------- /src/comment/comment-repo.mts: -------------------------------------------------------------------------------- 1 | import { CommonCountSchema } from '@/misc/common-count-schema.mjs'; 2 | import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; 3 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 4 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 5 | import { PostCommentView, PostId } from '@/post/post-schema.mjs'; 6 | import { CREATED_AT, DESC } from '@/sql/order-by.mjs'; 7 | import { SqlLive } from '@/sql/sql-live.mjs'; 8 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 9 | import { Effect, Layer, Option, pipe } from 'effect'; 10 | import { CommentNotFound } from './comment-error.mjs'; 11 | import { Comment, CommentId, CommentView } from './comment-schema.mjs'; 12 | import { AccountId } from '@/account/account-schema.mjs'; 13 | 14 | const TABLE_NAME = 'comment'; 15 | const LIKE_VIEW_NAME = 'comment_like_counts'; 16 | const POST_COMMENT_VIEW_NAME = 'post_like_counts'; 17 | 18 | const make = Effect.gen(function* () { 19 | const sql = yield* SqlClient.SqlClient; 20 | const repo = yield* Model.makeRepository(Comment, { 21 | tableName: TABLE_NAME, 22 | spanPrefix: 'CommentRepo', 23 | idColumn: 'id', 24 | }); 25 | 26 | const likeViewRepo = yield* Model.makeRepository(CommentView, { 27 | tableName: LIKE_VIEW_NAME, 28 | spanPrefix: 'CommentViewRepo', 29 | idColumn: 'id', 30 | }); 31 | 32 | const commentViewRepo = yield* Model.makeRepository(PostCommentView, { 33 | tableName: POST_COMMENT_VIEW_NAME, 34 | spanPrefix: 'PostCommentViewRepo', 35 | idColumn: 'id', 36 | }); 37 | 38 | const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => 39 | Effect.gen(function* () { 40 | const comments = yield* SqlSchema.findAll({ 41 | Request: FindManyUrlParams, 42 | Result: CommentView, 43 | execute: () => 44 | sql`select * from ${sql(LIKE_VIEW_NAME)} where 45 | ${sql.and( 46 | accountId 47 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 48 | : [sql`is_deleted = false`], 49 | )} 50 | order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, 51 | })(params); 52 | const { total } = yield* SqlSchema.single({ 53 | Request: FindManyUrlParams, 54 | Result: CommonCountSchema, 55 | execute: () => 56 | sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( 57 | accountId 58 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 59 | : [sql`is_deleted = false`], 60 | )}`, 61 | })(params); 62 | 63 | const ResultSchema = FindManyResultSchema(CommentView); 64 | 65 | const result = ResultSchema.make({ 66 | data: comments, 67 | meta: { 68 | total, 69 | page: params.page, 70 | limit: params.limit, 71 | isLastPage: params.page * params.limit + comments.length >= total, 72 | }, 73 | }); 74 | 75 | return result; 76 | }).pipe( 77 | Effect.orDie, 78 | Effect.withSpan('CommentRepo.findAllByPostIdWithView'), 79 | ); 80 | 81 | const findAllByPostIdWithView = (postId: PostId, params: FindManyUrlParams) => 82 | Effect.gen(function* () { 83 | const comments = yield* SqlSchema.findAll({ 84 | Request: PostId, 85 | Result: CommentView, 86 | execute: () => 87 | sql`select * from ${sql(LIKE_VIEW_NAME)} where 88 | ${sql.and([sql`post_id = ${postId}`, sql`is_deleted = false`])} 89 | order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} limit ${params.limit} offset ${(params.page - 1) * params.limit}`, 90 | })(postId); 91 | const { total } = yield* SqlSchema.single({ 92 | Request: FindManyUrlParams, 93 | Result: CommonCountSchema, 94 | execute: () => 95 | sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and([ 96 | sql`post_id = ${postId}`, 97 | sql`is_deleted = false`, 98 | ])}`, 99 | })(params); 100 | 101 | const ResultSchema = FindManyResultSchema(CommentView); 102 | 103 | const result = ResultSchema.make({ 104 | data: comments, 105 | meta: { 106 | total, 107 | page: params.page, 108 | limit: params.limit, 109 | isLastPage: params.page * params.limit + comments.length >= total, 110 | }, 111 | }); 112 | 113 | return result; 114 | }).pipe( 115 | Effect.orDie, 116 | Effect.withSpan('CommentRepo.findAllByPostIdWithView'), 117 | ); 118 | 119 | const with_ = ( 120 | id: CommentId, 121 | f: (post: Comment) => Effect.Effect, 122 | ): Effect.Effect => { 123 | return pipe( 124 | repo.findById(id), 125 | Effect.flatMap( 126 | Option.match({ 127 | onNone: () => new CommentNotFound({ id }), 128 | onSome: Effect.succeed, 129 | }), 130 | ), 131 | Effect.flatMap(f), 132 | sql.withTransaction, 133 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 134 | ); 135 | }; 136 | 137 | const withView_ = ( 138 | id: CommentId, 139 | f: (post: CommentView) => Effect.Effect, 140 | ): Effect.Effect => { 141 | return pipe( 142 | likeViewRepo.findById(id), 143 | Effect.flatMap( 144 | Option.match({ 145 | onNone: () => new CommentNotFound({ id }), 146 | onSome: Effect.succeed, 147 | }), 148 | ), 149 | Effect.flatMap(f), 150 | sql.withTransaction, 151 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 152 | ); 153 | }; 154 | 155 | return { 156 | ...repo, 157 | likeViewRepo: { 158 | ...likeViewRepo, 159 | findAllByPostId: findAllByPostIdWithView, 160 | findAllWithView, 161 | }, 162 | commentViewRepo, 163 | with: with_, 164 | withView: withView_, 165 | } as const; 166 | }); 167 | 168 | export class CommentRepo extends Effect.Tag('CommentRepo')< 169 | CommentRepo, 170 | Effect.Effect.Success 171 | >() { 172 | static Live = Layer.effect(CommentRepo, make).pipe(Layer.provide(SqlLive)); 173 | static Test = makeTestLayer(CommentRepo)({}); 174 | } 175 | -------------------------------------------------------------------------------- /src/comment/comment-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { PostId } from '@/post/post-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | 10 | export const CommentId = Schema.String.pipe(Schema.brand('CommentId')); 11 | 12 | export type CommentId = typeof CommentId.Type; 13 | 14 | const fields = { 15 | id: CommentId, 16 | postId: PostId, 17 | accountId: AccountId, 18 | content: Schema.String.pipe( 19 | Schema.annotations({ 20 | description: '댓글 내용', 21 | default: '안녕하세요', 22 | }), 23 | ), 24 | // parentCommentId: Schema.optionalWith( 25 | // CommentId.pipe( 26 | // Schema.annotations({ 27 | // description: '부모 댓글의 ID', 28 | // default: null, 29 | // }), 30 | // ), 31 | // { 32 | // description: '부모 댓글의 ID', 33 | // nullable: true, 34 | // onNoneEncoding: () => null, 35 | // }, 36 | // ), 37 | isDeleted: Schema.Boolean, 38 | createdAt: Schema.DateTimeUtcFromSelf, 39 | updatedAt: Schema.DateTimeUtcFromSelf, 40 | }; 41 | 42 | export class Comment extends Model.Class('Comment')({ 43 | ...fields, 44 | id: Model.Generated(fields.id), 45 | postId: Model.FieldExcept('jsonCreate', 'jsonUpdate')(fields.postId), 46 | accountId: Model.FieldExcept('jsonCreate', 'jsonUpdate')(fields.accountId), 47 | isDeleted: Model.FieldExcept('jsonCreate')(fields.isDeleted), 48 | createdAt: CustomDateTimeInsert, 49 | updatedAt: CustomDateTimeUpdate, 50 | }) {} 51 | 52 | const viewFields = { 53 | ...fields, 54 | likeCount: Schema.Number.pipe( 55 | Schema.int(), 56 | Schema.nonNegative(), 57 | Schema.annotations({ 58 | default: 0, 59 | description: '이 댓글에 달린 좋아요의 수', 60 | }), 61 | ), 62 | dislikeCount: Schema.Number.pipe( 63 | Schema.int(), 64 | Schema.nonNegative(), 65 | Schema.annotations({ 66 | default: 0, 67 | description: '이 댓글에 달린 싫어요의 수', 68 | }), 69 | ), 70 | pureLikeCount: Schema.Number.pipe( 71 | Schema.int(), 72 | Schema.nonNegative(), 73 | Schema.annotations({ 74 | default: 0, 75 | description: '이 댓글에 달린 댓글의 수', 76 | }), 77 | ), 78 | accountUsername: Schema.NullishOr( 79 | Schema.String.pipe( 80 | Schema.annotations({ 81 | description: '이 댓글을 쓴 유저의 username', 82 | }), 83 | ), 84 | ), 85 | }; 86 | 87 | export class CommentView extends Model.Class('Comment')({ 88 | ...viewFields, 89 | postTitle: Schema.String, 90 | id: Model.Generated(fields.id), 91 | createdAt: CustomDateTimeInsert, 92 | updatedAt: CustomDateTimeUpdate, 93 | }) {} 94 | 95 | interface CommentViewTreeEncoded 96 | extends Schema.Struct.Encoded { 97 | // Define `subcategories` using recursion 98 | readonly childrenComments: ReadonlyArray; 99 | } 100 | 101 | export class CommentViewTree extends Model.Class( 102 | 'CommentViewTree', 103 | )({ 104 | ...viewFields, 105 | childrenComments: Schema.Array( 106 | Schema.suspend( 107 | (): Schema.Schema => 108 | CommentViewTree, 109 | ), 110 | ), 111 | }) {} 112 | -------------------------------------------------------------------------------- /src/comment/comment-service.mts: -------------------------------------------------------------------------------- 1 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 2 | import { PostRepo } from '@/post/post-repo.mjs'; 3 | import { PostId } from '@/post/post-schema.mjs'; 4 | import { SqlTest } from '@/sql/sql-test.mjs'; 5 | import { Effect, Layer, Option, pipe } from 'effect'; 6 | import { CommentRepo } from './comment-repo.mjs'; 7 | import { Comment, CommentId } from './comment-schema.mjs'; 8 | import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; 9 | import { policyRequire } from '@/auth/authorization.mjs'; 10 | import { LikeService } from '@/like/like-service.mjs'; 11 | import { PostNotFound } from '@/post/post-error.mjs'; 12 | 13 | const make = Effect.gen(function* () { 14 | const likeService = yield* LikeService; 15 | const commentRepo = yield* CommentRepo; 16 | const postRepo = yield* PostRepo; 17 | 18 | const findAllPossiblyByAccountId = ( 19 | params: FindManyUrlParams, 20 | accountId?: AccountId, 21 | ) => commentRepo.likeViewRepo.findAllWithView(params, accountId); 22 | 23 | const findAllByPostId = (postId: PostId, params: FindManyUrlParams) => 24 | commentRepo.likeViewRepo.findAllByPostId(postId, params); 25 | 26 | const findByIdWithView = (commentId: CommentId) => 27 | commentRepo.withView(commentId, (comment) => 28 | pipe(Effect.succeed(comment), Effect.withSpan('CommentService.findById')), 29 | ); 30 | 31 | const getCommentCount = (postId: PostId) => 32 | commentRepo.commentViewRepo.findById(postId).pipe( 33 | Effect.flatMap( 34 | Option.match({ 35 | onNone: () => Effect.fail(new PostNotFound({ id: postId })), 36 | onSome: Effect.succeed, 37 | }), 38 | ), 39 | ); 40 | 41 | const create = (postId: PostId, toCreate: typeof Comment.jsonCreate.Type) => 42 | pipe( 43 | CurrentAccount, 44 | Effect.flatMap((account) => 45 | postRepo.with(postId, (post) => 46 | commentRepo.insert( 47 | Comment.insert.make({ 48 | ...toCreate, 49 | isDeleted: false, 50 | postId: post.id, 51 | accountId: account.id, 52 | }), 53 | ), 54 | ), 55 | ), 56 | Effect.flatMap((comment) => 57 | findByIdWithView(comment.id).pipe(Effect.orDie), 58 | ), 59 | Effect.withSpan('CommentService.create'), 60 | policyRequire('comment', 'create'), 61 | ); 62 | 63 | const update = ( 64 | postId: PostId, 65 | commentId: CommentId, 66 | toUpdate: Partial, 67 | ) => 68 | postRepo.with(postId, (post) => 69 | pipe( 70 | commentRepo.with(commentId, (comment) => 71 | pipe( 72 | commentRepo.update({ 73 | ...comment, 74 | ...toUpdate, 75 | updatedAt: undefined, 76 | }), 77 | Effect.withSpan('CommentService.update'), 78 | policyRequire('comment', 'update'), 79 | Effect.flatMap(() => findByIdWithView(comment.id)), 80 | ), 81 | ), 82 | ), 83 | ); 84 | 85 | const deleteById = (commentId: CommentId) => 86 | commentRepo.with(commentId, (comment) => 87 | pipe( 88 | commentRepo.delete(comment.id), 89 | Effect.withSpan('CommentService.delete'), 90 | policyRequire('comment', 'delete'), 91 | ), 92 | ); 93 | 94 | const findLikeStatus = (commentId: CommentId) => 95 | pipe(likeService.getLikeStatusByCommentId(commentId)); 96 | 97 | const addLikeCommentById = (commentId: CommentId) => 98 | commentRepo.with(commentId, (comment) => 99 | likeService.addLikeCommentById(comment.id).pipe( 100 | Effect.withSpan('CommentService.addLikeCommentById'), 101 | policyRequire('comment', 'like'), 102 | Effect.flatMap(() => findByIdWithView(comment.id)), 103 | ), 104 | ); 105 | 106 | const removeLikeCommentById = (commentId: CommentId) => 107 | commentRepo.with(commentId, (comment) => 108 | likeService.removeLikeCommentById(comment.id).pipe( 109 | Effect.withSpan('CommentService.removeLikeCommentById'), 110 | policyRequire('comment', 'like'), 111 | Effect.flatMap(() => findByIdWithView(comment.id)), 112 | ), 113 | ); 114 | 115 | const addDislikeCommentById = (commentId: CommentId) => 116 | commentRepo.with(commentId, (comment) => 117 | likeService.addDislikeCommentById(comment.id).pipe( 118 | Effect.withSpan('CommentService.addDislikeCommentById'), 119 | policyRequire('comment', 'dislike'), 120 | Effect.flatMap(() => findByIdWithView(comment.id)), 121 | ), 122 | ); 123 | 124 | const removeDislikeCommentById = (commentId: CommentId) => 125 | commentRepo.with(commentId, (comment) => 126 | likeService.removeDislikeCommentById(comment.id).pipe( 127 | Effect.withSpan('CommentService.removeDislikeCommentById'), 128 | policyRequire('comment', 'dislike'), 129 | Effect.flatMap(() => findByIdWithView(comment.id)), 130 | ), 131 | ); 132 | 133 | return { 134 | create, 135 | update, 136 | deleteById, 137 | findAllByPostId, 138 | findAllPossiblyByAccountId, 139 | findLikeStatus, 140 | findByIdWithView, 141 | getCommentCount, 142 | addLikeCommentById, 143 | removeLikeCommentById, 144 | addDislikeCommentById, 145 | removeDislikeCommentById, 146 | } as const; 147 | }); 148 | 149 | export class CommentService extends Effect.Tag('CommentService')< 150 | CommentService, 151 | Effect.Effect.Success 152 | >() { 153 | static layer = Layer.effect(CommentService, make); 154 | 155 | static Live = this.layer.pipe( 156 | Layer.provide(CommentRepo.Live), 157 | Layer.provide(PostRepo.Live), 158 | Layer.provide(LikeService.Live), 159 | ); 160 | 161 | static Test = this.layer.pipe(Layer.provideMerge(SqlTest)); 162 | } 163 | -------------------------------------------------------------------------------- /src/crypto/crypto-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | 4 | export class GeneratingSaltError extends Schema.TaggedError()( 5 | 'GeneratingSaltError', 6 | {}, 7 | HttpApiSchema.annotations({ status: 500 }), 8 | ) {} 9 | 10 | export class HashingPasswordError extends Schema.TaggedError()( 11 | 'HashingPasswordError', 12 | {}, 13 | HttpApiSchema.annotations({ status: 500 }), 14 | ) {} 15 | -------------------------------------------------------------------------------- /src/crypto/crypto-service.mts: -------------------------------------------------------------------------------- 1 | import { NodeContext } from '@effect/platform-node'; 2 | import { Effect, Layer } from 'effect'; 3 | import { GeneratingSaltError, HashingPasswordError } from './crypto-error.mjs'; 4 | 5 | const make = Effect.gen(function* () { 6 | const getRandomSalt = function (size = 16) { 7 | return Effect.gen(function* () { 8 | const salt = yield* Effect.try({ 9 | try: () => { 10 | const array = new Uint8Array(size); 11 | crypto.getRandomValues(array); 12 | return Buffer.from(array); 13 | }, 14 | catch: (error) => new GeneratingSaltError(), 15 | }); 16 | return salt.toString('hex'); 17 | }); 18 | }; 19 | 20 | const hashPassword = function (password: string, salt: string) { 21 | return Effect.gen(function* () { 22 | const enc = new TextEncoder(); 23 | const passwordKey = yield* Effect.promise(async () => 24 | crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, [ 25 | 'deriveBits', 26 | 'deriveKey', 27 | ]), 28 | ); 29 | 30 | const saltBuffer = Buffer.from(salt, 'hex'); 31 | 32 | const key = yield* Effect.promise(async () => 33 | crypto.subtle.deriveKey( 34 | { 35 | name: 'PBKDF2', 36 | salt: saltBuffer, 37 | iterations: 1000, 38 | hash: 'SHA-512', 39 | }, 40 | passwordKey, 41 | { name: 'HMAC', hash: 'SHA-512', length: 512 }, 42 | true, 43 | ['sign'], 44 | ), 45 | ); 46 | 47 | const derivedKey = yield* Effect.promise(async () => 48 | crypto.subtle.exportKey('raw', key), 49 | ); 50 | 51 | const buffer = Buffer.from(derivedKey); 52 | 53 | return yield* Effect.succeed(buffer); 54 | }).pipe( 55 | Effect.catchAll((error) => { 56 | return Effect.fail(new HashingPasswordError()); 57 | }), 58 | ); 59 | }; 60 | 61 | return { 62 | getRandomSalt, 63 | hashPassword, 64 | } as const; 65 | }); 66 | 67 | export class CryptoService extends Effect.Tag('CryptoService')< 68 | CryptoService, 69 | Effect.Effect.Success 70 | >() { 71 | static layer = Layer.effect(CryptoService, make); 72 | 73 | static Live = this.layer.pipe(Layer.provide(NodeContext.layer)); 74 | 75 | static Test = this.layer; 76 | } 77 | -------------------------------------------------------------------------------- /src/crypto/token-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | 4 | export class AccessTokenGenerationError extends Schema.TaggedError()( 5 | 'AccessTokenGenerationError', 6 | {}, 7 | HttpApiSchema.annotations({ status: 500 }), 8 | ) {} 9 | 10 | export class RefreshTokenGenerationError extends Schema.TaggedError()( 11 | 'RefreshTokenGenerationError', 12 | {}, 13 | HttpApiSchema.annotations({ status: 500 }), 14 | ) {} 15 | 16 | export class VerifyTokenError extends Schema.TaggedError()( 17 | 'VerifyTokenError', 18 | {}, 19 | HttpApiSchema.annotations({ status: 401 }), 20 | ) {} 21 | -------------------------------------------------------------------------------- /src/crypto/token-schema.mts: -------------------------------------------------------------------------------- 1 | import { Email } from '@/misc/email-schema.mjs'; 2 | import { Schema } from 'effect'; 3 | 4 | export const Token = Schema.Struct({ 5 | iss: Schema.String, 6 | type: Schema.Literal('access', 'refresh'), 7 | iat: Schema.Int, 8 | sub: Email, 9 | exp: Schema.Int, 10 | maxAge: Schema.Int, 11 | }); 12 | 13 | export type Token = typeof Token.Type; 14 | -------------------------------------------------------------------------------- /src/crypto/token-service.mts: -------------------------------------------------------------------------------- 1 | import { Account } from '@/account/account-schema.mjs'; 2 | import { ConfigService } from '@/misc/config-service.mjs'; 3 | import { Effect, Layer } from 'effect'; 4 | import { SignJWT, jwtVerify } from 'jose'; 5 | import { 6 | AccessTokenGenerationError, 7 | RefreshTokenGenerationError, 8 | VerifyTokenError, 9 | } from './token-error.mjs'; 10 | import { Token } from './token-schema.mjs'; 11 | 12 | const make = Effect.gen(function* () { 13 | const configService = yield* ConfigService; 14 | const { jwtSecret, host } = configService; 15 | const secret = new TextEncoder().encode(jwtSecret); 16 | 17 | const generateAccessToken = (target: Account) => { 18 | return Effect.gen(function* () { 19 | const accessToken = yield* Effect.tryPromise({ 20 | try: async () => 21 | await new SignJWT({ 22 | type: 'access', 23 | }) 24 | .setProtectedHeader({ alg: 'HS256' }) 25 | .setIssuedAt() 26 | .setIssuer(host) 27 | .setSubject(target.email) 28 | .setExpirationTime('7days') 29 | .sign(secret), 30 | 31 | catch: (error) => new AccessTokenGenerationError(), 32 | }); 33 | 34 | return accessToken; 35 | }); 36 | }; 37 | 38 | const generateRefreshToken = (target: Account) => { 39 | return Effect.gen(function* () { 40 | const refreshToken = yield* Effect.tryPromise({ 41 | try: async () => 42 | await new SignJWT({ 43 | type: 'refresh', 44 | }) 45 | .setProtectedHeader({ alg: 'HS256' }) 46 | .setIssuedAt() 47 | .setIssuer(host) 48 | .setSubject(target.email) 49 | .setExpirationTime('30days') 50 | .sign(secret), 51 | catch: (error) => new RefreshTokenGenerationError(), 52 | }); 53 | 54 | return refreshToken; 55 | }); 56 | }; 57 | 58 | const verifyToken = (serializedToken: string) => 59 | Effect.gen(function* () { 60 | const decoded = yield* Effect.tryPromise({ 61 | try: async () => { 62 | return ( 63 | await jwtVerify(serializedToken, secret, { 64 | issuer: host, 65 | }) 66 | ).payload as Token; 67 | }, 68 | catch: (error) => new VerifyTokenError(), 69 | }); 70 | 71 | return decoded; 72 | }); 73 | 74 | return { generateAccessToken, generateRefreshToken, verifyToken } as const; 75 | }); 76 | 77 | export class TokenService extends Effect.Tag('TokenService')< 78 | TokenService, 79 | Effect.Effect.Success 80 | >() { 81 | static layer = Layer.effect(TokenService, make); 82 | 83 | static Live = this.layer.pipe(Layer.provide(ConfigService.Live)); 84 | 85 | static Test = this.layer; 86 | } 87 | -------------------------------------------------------------------------------- /src/file/file-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 4 | import { ChallengeService } from '@/challenge/challenge-service.mjs'; 5 | import { PostId } from '@/post/post-schema.mjs'; 6 | import { PostService } from '@/post/post-service.mjs'; 7 | import { FileSystem, HttpApiBuilder } from '@effect/platform'; 8 | import { Effect, Layer, Option } from 'effect'; 9 | import { FileUploadError } from './file-error.mjs'; 10 | import { FileService } from './file-service.mjs'; 11 | import { AccountService } from '@/account/account-service.mjs'; 12 | import { AccountId } from '@/account/account-schema.mjs'; 13 | 14 | export const FileApiLive = HttpApiBuilder.group(Api, 'file', (handlers) => 15 | Effect.gen(function* () { 16 | const fileService = yield* FileService; 17 | const challengeService = yield* ChallengeService; 18 | const postService = yield* PostService; 19 | const accountService = yield* AccountService; 20 | const fs = yield* FileSystem.FileSystem; 21 | 22 | return handlers 23 | .handle('uploadImage', ({ payload: { file, ...target } }) => 24 | Effect.gen(function* () { 25 | const temp = yield* fs.readFile(file.path).pipe(Effect.orDie); 26 | 27 | const maybePost = yield* postService.findByIdFromRepo( 28 | PostId.make(target.id), 29 | ); 30 | if (Option.isSome(maybePost)) { 31 | const url = yield* fileService.uploadImage( 32 | new File([temp], target.filename, { 33 | type: `image/${target.extension}`, 34 | }), 35 | target, 36 | ); 37 | return yield* Effect.succeed({ 38 | url, 39 | }); 40 | } 41 | 42 | const maybeChallenge = yield* challengeService.findByIdFromRepo( 43 | ChallengeId.make(target.id), 44 | ); 45 | 46 | if (Option.isSome(maybeChallenge)) { 47 | const url = yield* fileService.uploadImage( 48 | new File([temp], target.filename, { 49 | type: `image/${target.extension}`, 50 | }), 51 | target, 52 | ); 53 | return yield* Effect.succeed({ 54 | url, 55 | }); 56 | } 57 | 58 | const maybeAccount = yield* accountService.findByIdFromRepo( 59 | AccountId.make(target.id), 60 | ); 61 | 62 | if (Option.isSome(maybeAccount)) { 63 | const url = yield* fileService.uploadImage( 64 | new File([temp], target.filename, { 65 | type: `image/${target.extension}`, 66 | }), 67 | target, 68 | ); 69 | return yield* Effect.succeed({ 70 | url, 71 | }); 72 | } 73 | 74 | return yield* Effect.fail( 75 | new FileUploadError({ 76 | name: 'FileUploadError', 77 | message: `ID ${target.id} not found in post or challenge`, 78 | }), 79 | ); 80 | }), 81 | ) 82 | .handle('getImageAllInfo', ({ payload }) => 83 | fileService.getImageAllInfo(payload), 84 | ); 85 | }), 86 | ).pipe( 87 | Layer.provide(AuthenticationLive), 88 | Layer.provide(FileService.Live), 89 | Layer.provide(ChallengeService.Live), 90 | Layer.provide(PostService.Live), 91 | Layer.provide(AccountService.Live), 92 | ); 93 | -------------------------------------------------------------------------------- /src/file/file-api.mts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpApiEndpoint, 3 | HttpApiGroup, 4 | HttpApiSchema, 5 | Multipart, 6 | OpenApi, 7 | } from '@effect/platform'; 8 | import { Schema } from 'effect'; 9 | import { FileInfoFetchingError, FileUploadError } from './file-error.mjs'; 10 | import { ImageInfoSchema } from './image-info-schema.mjs'; 11 | import { ImagePath } from './image-path-schema.mjs'; 12 | import { ImageUploadTargetSchema } from './image-target-schema.mjs'; 13 | import { Authentication } from '@/auth/authentication.mjs'; 14 | 15 | export class FileApi extends HttpApiGroup.make('file') 16 | .add( 17 | HttpApiEndpoint.post('uploadImage', '/upload') 18 | .middleware(Authentication) 19 | .setPayload( 20 | HttpApiSchema.Multipart( 21 | Schema.extend( 22 | Schema.Struct({ 23 | file: Multipart.SingleFileSchema.pipe( 24 | Schema.annotations({ 25 | description: 26 | '업로드할 파일명인데, 파일명에는 S3 safe 문자만 들어가야 합니다. 파일명에 한글이나 기타 non-ascii문자가 들어간다면 file is missing 에러가 뜰겁니다.', 27 | }), 28 | ), 29 | }), 30 | ImageUploadTargetSchema, 31 | ), 32 | ), 33 | ) 34 | .addError(FileInfoFetchingError) 35 | .addError(FileUploadError) 36 | .addSuccess( 37 | Schema.Struct({ 38 | url: Schema.String, 39 | }), 40 | ) 41 | .annotateContext( 42 | OpenApi.annotations({ 43 | description: '(사용가능) 파일을 업로드합니다.', 44 | override: { 45 | summary: '(사용가능)이미지 업로드', 46 | }, 47 | }), 48 | ), 49 | ) 50 | .add( 51 | HttpApiEndpoint.post('getImageAllInfo', '/image-info') 52 | .setPayload(ImagePath) 53 | .addError(FileInfoFetchingError) 54 | .addSuccess(ImageInfoSchema) 55 | .annotateContext( 56 | OpenApi.annotations({ 57 | description: '(사용가능) 경로로부터 이미지 정보를 가져옵니다.', 58 | override: { 59 | summary: '(사용가능)이미지 정보 가져오기', 60 | }, 61 | }), 62 | ), 63 | ) 64 | .prefix('/api/files') 65 | .annotateContext( 66 | OpenApi.annotations({ 67 | title: '(사용가능) 파일 API', 68 | }), 69 | ) {} 70 | -------------------------------------------------------------------------------- /src/file/file-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | 4 | export class FileUploadError extends Schema.TaggedError()( 5 | 'FileUploadError', 6 | // This is from StorageError in @supabase/storage-js 7 | /** 8 | * export class StorageError extends Error { 9 | * protected __isStorageError = true 10 | * constructor(message: string) { 11 | * super(message) 12 | * this.name = 'StorageError' 13 | * } 14 | * } 15 | */ 16 | { 17 | name: Schema.String, 18 | message: Schema.String, 19 | }, 20 | HttpApiSchema.annotations({ 21 | status: 409, 22 | title: 'Invalid File Upload', 23 | description: '파일 업로드가 실패했습니다.', 24 | }), 25 | ) {} 26 | 27 | export class FileInfoFetchingError extends Schema.TaggedError()( 28 | 'FileInfoFetchingError', 29 | { 30 | name: Schema.String, 31 | message: Schema.String, 32 | }, 33 | HttpApiSchema.annotations({ 34 | status: 404, 35 | title: 'File Not Found', 36 | description: '파일을 찾을 수 없습니다.', 37 | }), 38 | ) {} 39 | 40 | export class FileTypeMismatchError extends Schema.TaggedError()( 41 | 'FileTypeMismatchError', 42 | { 43 | name: Schema.String, 44 | message: Schema.String, 45 | }, 46 | HttpApiSchema.annotations({ 47 | status: 409, 48 | title: 'File Type Mismatch', 49 | description: '파일 타입이 맞지 않습니다.', 50 | }), 51 | ) {} 52 | -------------------------------------------------------------------------------- /src/file/file-service.mts: -------------------------------------------------------------------------------- 1 | import { SupabaseService } from '@/supabase/supabase-service.mjs'; 2 | import { Effect, Layer } from 'effect'; 3 | import { FileInfoFetchingError, FileUploadError } from './file-error.mjs'; 4 | import { ImagePath } from './image-path-schema.mjs'; 5 | import { ImageUploadTarget } from './image-target-schema.mjs'; 6 | import { ImageInfoSchema } from './image-info-schema.mjs'; 7 | 8 | const make = Effect.gen(function* () { 9 | const supabaseService = yield* SupabaseService; 10 | 11 | const getImageAllInfo = (imagePath: ImagePath) => 12 | Effect.gen(function* () { 13 | const path = `${imagePath.type}/${imagePath.id}/${imagePath.filename}.${imagePath.extension}`; 14 | const { 15 | data: { publicUrl }, 16 | } = yield* Effect.sync(() => 17 | supabaseService.storage.from('image').getPublicUrl(path), 18 | ); 19 | 20 | const infoPath = `${imagePath.type}/${imagePath.id}/${imagePath.filename}.${imagePath.extension}`; 21 | 22 | const result = yield* Effect.tryPromise(async () => 23 | supabaseService.storage.from('public/image').info(infoPath), 24 | ); 25 | 26 | if (result.error) { 27 | return yield* Effect.fail( 28 | new FileInfoFetchingError({ 29 | message: result.error.message, 30 | name: result.error.name, 31 | }), 32 | ); 33 | } 34 | 35 | return yield* Effect.succeed( 36 | // @ts-expect-error 37 | ImageInfoSchema.make({ 38 | ...result.data, 39 | url: publicUrl, 40 | }), 41 | ); 42 | }).pipe( 43 | Effect.catchAll((error) => { 44 | return Effect.fail( 45 | new FileInfoFetchingError({ 46 | message: error.message, 47 | name: error.name, 48 | }), 49 | ); 50 | }), 51 | Effect.withSpan('FileService.getImageAllInfo'), 52 | ); 53 | 54 | const uploadImage = (image: File, target: ImageUploadTarget) => 55 | Effect.gen(function* () { 56 | const uploaded = yield* Effect.tryPromise(async () => { 57 | const path = `${target.type}/${target.id}/${target.filename}.${target.extension}`; 58 | const result = await supabaseService.storage 59 | .from('image') 60 | .upload(path, image, { 61 | upsert: false, 62 | metadata: { 63 | ...target, 64 | }, 65 | contentType: `image/${target.extension}`, 66 | }); 67 | 68 | return result; 69 | }); 70 | 71 | if (uploaded.error) { 72 | return yield* Effect.fail( 73 | new FileUploadError({ 74 | message: uploaded.error.message, 75 | name: 'FileUploadError', 76 | }), 77 | ); 78 | } 79 | 80 | if (!uploaded.data.path) { 81 | return yield* Effect.fail( 82 | new FileInfoFetchingError({ 83 | message: `Upload was successful, but the file ${image.name} was not found in the response.`, 84 | name: `FileNotFound`, 85 | }), 86 | ); 87 | } 88 | 89 | const { 90 | data: { publicUrl: newPublicUrl }, 91 | } = supabaseService.storage 92 | .from('image') 93 | .getPublicUrl(uploaded.data.path); 94 | 95 | return newPublicUrl; 96 | }).pipe( 97 | Effect.catchAll((error) => { 98 | return Effect.fail( 99 | new FileUploadError({ 100 | message: error.message, 101 | name: error.name, 102 | }), 103 | ); 104 | }), 105 | Effect.withSpan('FileService.uploadImage'), 106 | ); 107 | 108 | return { 109 | uploadImage, 110 | getImageAllInfo, 111 | } as const; 112 | }); 113 | 114 | export class FileService extends Effect.Tag('FileService')< 115 | FileService, 116 | Effect.Effect.Success 117 | >() { 118 | static layer = Layer.effect(FileService, make); 119 | static Live = this.layer.pipe(Layer.provide(SupabaseService.Live)); 120 | } 121 | -------------------------------------------------------------------------------- /src/file/image-info-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const ImageInfoSchema = Schema.Struct({ 4 | id: Schema.String, 5 | name: Schema.String, 6 | version: Schema.String, 7 | size: Schema.Number, 8 | cacheControl: Schema.String, 9 | contentType: Schema.String, 10 | etag: Schema.String, 11 | metadata: Schema.NullishOr( 12 | Schema.Record({ 13 | key: Schema.String, 14 | value: Schema.Union(Schema.String, Schema.Number), 15 | }), 16 | ), 17 | createdAt: Schema.String, 18 | url: Schema.String, 19 | }); 20 | 21 | export type ImageInfo = typeof ImageInfoSchema.Type; 22 | -------------------------------------------------------------------------------- /src/file/image-path-schema.mts: -------------------------------------------------------------------------------- 1 | import { ImageUploadTargetSchema } from './image-target-schema.mjs'; 2 | 3 | export const ImagePath = ImageUploadTargetSchema.pick( 4 | 'filename', 5 | 'id', 6 | 'type', 7 | 'extension', 8 | ); 9 | 10 | export type ImagePath = typeof ImagePath.Type; 11 | -------------------------------------------------------------------------------- /src/file/image-target-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const ImageUploadTargetSchema = Schema.Struct({ 4 | type: Schema.Literal('post', 'challenge', 'account'), 5 | id: Schema.String.pipe( 6 | Schema.annotations({ 7 | description: 'challengeId / postId / accountId 셋 중 하나', 8 | }), 9 | ), 10 | filename: Schema.String.pipe( 11 | Schema.annotations({ 12 | description: 13 | '파일 이름, 업로드한 파일의 파일명과 똑같이 해주되 확장자는 따로 빼서 아래 extension에 넣어주세요. 예: jaerong.png -> jaerong 만', 14 | }), 15 | Schema.minLength(1), 16 | ), 17 | extension: Schema.Literal( 18 | 'jpg', 19 | 'jpeg', 20 | 'png', 21 | 'gif', 22 | 'webp', 23 | 'svg', 24 | 'bmp', 25 | ).pipe( 26 | Schema.annotations({ description: '파일 확장자 예: jaerong.png -> png' }), 27 | ), 28 | sizeInKb: Schema.NumberFromString.pipe( 29 | Schema.int(), 30 | Schema.positive(), 31 | Schema.annotations({ 32 | description: '파일 크기, 항상 0보다 큰 정수여야 함', 33 | default: 0, 34 | }), 35 | ), 36 | width: Schema.NumberFromString.pipe( 37 | Schema.int(), 38 | Schema.positive(), 39 | Schema.annotations({ 40 | description: '가로 픽셀, 항상 0보다 큰 정수여야 함', 41 | default: 0, 42 | }), 43 | ), 44 | height: Schema.NumberFromString.pipe( 45 | Schema.int(), 46 | Schema.positive(), 47 | Schema.annotations({ 48 | description: '세로 픽셀, 항상 0보다 큰 정수여야 함', 49 | default: 0, 50 | }), 51 | ), 52 | }); 53 | 54 | export type ImageUploadTarget = typeof ImageUploadTargetSchema.Type; 55 | -------------------------------------------------------------------------------- /src/index.mts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpApiBuilder, 3 | HttpApiSwagger, 4 | HttpMiddleware, 5 | HttpServer, 6 | Etag, 7 | } from '@effect/platform'; 8 | import { NodeHttpServer, NodeRuntime, NodeSocket } from '@effect/platform-node'; 9 | import { Console, Effect, Layer } from 'effect'; 10 | import { createServer } from 'node:http'; 11 | import { ApiLive } from './api-live.mjs'; 12 | import { ConfigService } from './misc/config-service.mjs'; 13 | import { DevTools } from '@effect/experimental'; 14 | 15 | const DevToolsLive = DevTools.layerWebSocket().pipe( 16 | Layer.provide(NodeSocket.layerWebSocketConstructor), 17 | ); 18 | 19 | HttpApiBuilder.serve(HttpMiddleware.logger).pipe( 20 | Layer.provide(HttpApiSwagger.layer()), 21 | Layer.provide(HttpApiBuilder.middlewareOpenApi()), 22 | Layer.provide(ApiLive), 23 | 24 | Layer.provide( 25 | HttpApiBuilder.middlewareCors({ 26 | allowedOrigins: ['*'], 27 | }), 28 | ), 29 | Layer.provide(Etag.layerWeak), 30 | HttpServer.withLogAddress, 31 | Layer.provide( 32 | NodeHttpServer.layer(createServer, { 33 | port: Effect.runSync( 34 | Effect.provide( 35 | Effect.gen(function* () { 36 | const config = yield* ConfigService; 37 | yield* Console.log(`Listening on http://localhost:${config.port}`); 38 | return config.port; 39 | }), 40 | ConfigService.Live, 41 | ), 42 | ), 43 | }), 44 | ), 45 | Layer.launch, 46 | Effect.provide(DevToolsLive), 47 | NodeRuntime.runMain, 48 | ); 49 | -------------------------------------------------------------------------------- /src/like/like-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { LikeId } from './like-schema.mjs'; 4 | import { PostId } from '@/post/post-schema.mjs'; 5 | import { CommentId } from '@/comment/comment-schema.mjs'; 6 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 7 | import { ChallengeEventId } from '@/challenge-event/challenge-event-schema.mjs'; 8 | 9 | export class LikeNotFound extends Schema.TaggedError()( 10 | 'LikeNotFound', 11 | { 12 | id: Schema.NullishOr(LikeId), 13 | postId: Schema.NullishOr(PostId), 14 | commentId: Schema.NullishOr(CommentId), 15 | challengeId: Schema.NullishOr(ChallengeId), 16 | challengeEventId: Schema.NullishOr(ChallengeEventId), 17 | }, 18 | HttpApiSchema.annotations({ 19 | status: 404, 20 | title: 'Like Not Found', 21 | description: 'ID에 해당하는 좋아요/싫어요가 존재하지 않습니다.', 22 | }), 23 | ) {} 24 | 25 | export class LikeConflict extends Schema.TaggedError()( 26 | 'LikeConflict', 27 | { 28 | id: Schema.NullishOr(LikeId), 29 | }, 30 | HttpApiSchema.annotations({ 31 | status: 409, 32 | title: 'Like Conflict', 33 | description: '이미 좋아요/싫어요가 존재합니다.', 34 | }), 35 | ) {} 36 | -------------------------------------------------------------------------------- /src/like/like-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { ChallengeEventId } from '@/challenge-event/challenge-event-schema.mjs'; 3 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 4 | import { CommentId } from '@/comment/comment-schema.mjs'; 5 | import { 6 | CustomDateTimeInsert, 7 | CustomDateTimeUpdate, 8 | } from '@/misc/date-schema.mjs'; 9 | import { PostId } from '@/post/post-schema.mjs'; 10 | import { Model } from '@effect/sql'; 11 | import { Schema } from 'effect'; 12 | 13 | export const LikeId = Schema.String.pipe(Schema.brand('LikeId')); 14 | 15 | export type LikeId = typeof LikeId.Type; 16 | 17 | export class Like extends Model.Class('Like')({ 18 | id: Model.Generated(LikeId), 19 | postId: Schema.NullishOr(PostId), 20 | accountId: AccountId, 21 | commentId: Schema.NullishOr(CommentId), 22 | challengeId: Schema.NullishOr(ChallengeId), 23 | challengeEventId: Schema.NullishOr(ChallengeEventId), 24 | type: Schema.Literal('like', 'dislike'), 25 | count: Schema.Number, 26 | createdAt: CustomDateTimeInsert, 27 | updatedAt: CustomDateTimeUpdate, 28 | }) {} 29 | 30 | export type LikeType = (typeof Like.Type)['type']; 31 | -------------------------------------------------------------------------------- /src/like/like-selector-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | import { Like } from './like-schema.mjs'; 3 | 4 | export const LikeSelector = Like.pipe( 5 | Schema.pick( 6 | 'accountId', 7 | 'postId', 8 | 'commentId', 9 | 'challengeId', 10 | 'challengeEventId', 11 | ), 12 | Schema.filter( 13 | (like) => 14 | Boolean(like.accountId) && 15 | Boolean( 16 | like.postId || 17 | like.commentId || 18 | like.challengeId || 19 | like.challengeEventId, 20 | ), 21 | { 22 | identifier: 'LikeSelectorSchema', 23 | jsonSchema: { 24 | type: 'object', 25 | properties: { 26 | accountId: { type: 'string' }, 27 | postId: { type: 'string' }, 28 | commentId: { type: 'string' }, 29 | challengeId: { type: 'string' }, 30 | challengeEventId: { type: 'string' }, 31 | }, 32 | required: ['accountId'], 33 | }, 34 | description: 35 | 'At least one of postId, commentId, challengeId, or challengeEventId must be provided.', 36 | }, 37 | ), 38 | ); 39 | 40 | export type LikeSelector = typeof LikeSelector.Type; 41 | 42 | type NullifyAll = { 43 | [P in keyof T]?: T[P] | null | undefined; 44 | }; 45 | 46 | export const likeSelectorsToWhere = ( 47 | selectors: NullifyAll, 48 | ) => { 49 | const entries = Object.entries(selectors).reduce( 50 | (acc, [key, value]) => { 51 | if (value) { 52 | const column = key.replace(/([A-Z])/g, '_$1').toLowerCase(); 53 | return [...acc, [column, value] as const]; 54 | } 55 | return acc; 56 | }, 57 | [] as (readonly [string, string])[], 58 | ); 59 | 60 | return entries; 61 | }; 62 | -------------------------------------------------------------------------------- /src/message/message-channel-member-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { MessageChannelId } from '@/message/message-channel-schema.mjs'; 3 | import { 4 | CustomDateTimeInsert, 5 | CustomDateTimeUpdate, 6 | } from '@/misc/date-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | 10 | export const MessageChannelMemberId = Schema.String.pipe( 11 | Schema.brand('MessageChannelMemberId'), 12 | ); 13 | 14 | export type MessageChannelMemberId = typeof MessageChannelMemberId.Type; 15 | 16 | export class MessageChannelMember extends Model.Class( 17 | 'MessageChannelMember', 18 | )({ 19 | id: Model.Generated(MessageChannelMemberId), 20 | accountId: AccountId, 21 | messageChannelId: MessageChannelId, 22 | createdAt: CustomDateTimeInsert, 23 | updatedAt: CustomDateTimeUpdate, 24 | isDeleted: Schema.Boolean.annotations({ 25 | default: false, 26 | }), 27 | isBlocked: Schema.Boolean.annotations({ 28 | default: false, 29 | }), 30 | isMuted: Schema.Boolean.annotations({ 31 | default: false, 32 | }), 33 | role: Schema.Literal('admin', 'member').annotations({ 34 | default: 'member', 35 | }), 36 | }) {} 37 | -------------------------------------------------------------------------------- /src/message/message-channel-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 3 | import { 4 | CustomDateTimeInsert, 5 | CustomDateTimeUpdate, 6 | } from '@/misc/date-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | 10 | export const MessageChannelId = Schema.String.pipe( 11 | Schema.brand('MessageChannelId'), 12 | ); 13 | 14 | export type MessageChannelId = typeof MessageChannelId.Type; 15 | 16 | export class MessageChannel extends Model.Class( 17 | 'MessageChannel', 18 | )({ 19 | id: Model.Generated(MessageChannelId), 20 | accountId: AccountId, 21 | createdAt: CustomDateTimeInsert, 22 | updatedAt: CustomDateTimeUpdate, 23 | isDeleted: Schema.Boolean.annotations({ 24 | default: false, 25 | }), 26 | name: Schema.String, 27 | description: Schema.String, 28 | imageUrl: Schema.String, 29 | isPrivate: Schema.Boolean.annotations({ 30 | default: false, 31 | }), 32 | challengeId: ChallengeId, 33 | }) {} 34 | -------------------------------------------------------------------------------- /src/message/message-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Schema } from 'effect'; 8 | import { MessageChannelId } from './message-channel-schema.mjs'; 9 | 10 | export const MessageId = Schema.String.pipe(Schema.brand('MessageId')); 11 | 12 | export type MessageId = typeof MessageId.Type; 13 | 14 | export class Message extends Model.Class('Message')({ 15 | id: Model.Generated(MessageId), 16 | senderAccountId: AccountId, 17 | receiverAccountId: AccountId, 18 | content: Schema.String, 19 | isDeleted: Schema.Boolean, 20 | isRead: Schema.Boolean, 21 | isSent: Schema.Boolean, 22 | isReceived: Schema.Boolean, 23 | isSystemMessage: Schema.Boolean, 24 | childMessageId: Schema.NullishOr(MessageId), 25 | messageChannelId: MessageChannelId, 26 | createdAt: CustomDateTimeInsert, 27 | updatedAt: CustomDateTimeUpdate, 28 | }) {} 29 | -------------------------------------------------------------------------------- /src/misc/common-count-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const CommonCountSchema = Schema.Struct({ 4 | total: Schema.NumberFromString.pipe(Schema.int(), Schema.nonNegative()), 5 | }); 6 | -------------------------------------------------------------------------------- /src/misc/common-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | 4 | export class ServerError extends Schema.TaggedError()( 5 | 'ServerError', 6 | { 7 | message: Schema.NullishOr(Schema.String), 8 | }, 9 | HttpApiSchema.annotations({ status: 500 }), 10 | ) {} 11 | -------------------------------------------------------------------------------- /src/misc/config-service.mts: -------------------------------------------------------------------------------- 1 | import { Config, Effect, Layer } from 'effect'; 2 | 3 | const make = Effect.gen(function* () { 4 | const port = yield* Config.number('PORT').pipe( 5 | Config.withDefault(3000), 6 | Config.validate({ 7 | message: 'PORT must be a number between 0 and 65535', 8 | validation: (port) => 9 | typeof port === 'number' && port >= 0 && port <= 65535, 10 | }), 11 | ); 12 | 13 | const jwtSecret = yield* Config.string('JWT_SECRET').pipe( 14 | Config.withDefault('jwt-secret'), 15 | ); 16 | 17 | const host = yield* Config.string('HOST').pipe( 18 | Config.withDefault('127.0.0.1'), 19 | ); 20 | 21 | const dbDirectUrl = yield* Config.string('DIRECT_URL').pipe( 22 | Config.validate({ 23 | message: 'DIRECT_URL must be a valid URL', 24 | validation: (url) => { 25 | try { 26 | new URL(url); 27 | return true; 28 | } catch { 29 | return false; 30 | } 31 | }, 32 | }), 33 | ); 34 | 35 | const supabaseAnon = yield* Config.string('SUPABASE_ANON').pipe( 36 | Config.withDefault('anon'), 37 | ); 38 | 39 | const supabaseServiceRole = yield* Config.string( 40 | 'SUPABASE_SERVICE_ROLE', 41 | ).pipe(Config.withDefault('supabase_service_role')); 42 | 43 | const supabaseUrl = yield* Config.string('SUPABASE_URL').pipe( 44 | Config.withDefault('supabase_url'), 45 | ); 46 | 47 | const supabaseId = yield* Config.string('SUPABASE_PROJECT_ID').pipe( 48 | Config.withDefault('id'), 49 | ); 50 | 51 | return { 52 | port, 53 | jwtSecret, 54 | host, 55 | dbDirectUrl, 56 | supabase: { 57 | anon: supabaseAnon, 58 | serviceRole: supabaseServiceRole, 59 | id: supabaseId, 60 | url: supabaseUrl, 61 | }, 62 | } as const; 63 | }); 64 | 65 | export class ConfigService extends Effect.Tag('ConfigService')< 66 | ConfigService, 67 | Effect.Effect.Success 68 | >() { 69 | static layer = Layer.effect(ConfigService, make); 70 | 71 | static Live = this.layer; 72 | } 73 | -------------------------------------------------------------------------------- /src/misc/date-schema.mts: -------------------------------------------------------------------------------- 1 | import { DateTimeWithNow, Field } from '@effect/sql/Model'; 2 | import { DateTime, Schema } from 'effect'; 3 | 4 | export const DateTimeFromDate = Schema.transform( 5 | Schema.ValidDateFromSelf.annotations({ identifier: 'DateTimeFromDate' }), 6 | Schema.DateTimeUtcFromSelf.annotations({ identifier: 'DateTimeUtc' }), 7 | { 8 | decode: DateTime.unsafeFromDate, 9 | encode: DateTime.toDateUtc, 10 | }, 11 | ).annotations({ 12 | identifier: 'DateTimeUtc', 13 | jsonSchema: { type: 'string' }, 14 | }); 15 | 16 | export const CustomDateTimeInsert = Field({ 17 | select: DateTimeFromDate, 18 | insert: DateTimeWithNow, 19 | json: DateTimeFromDate, 20 | }); 21 | 22 | export const CustomDateTimeUpdate = Field({ 23 | select: DateTimeFromDate, 24 | insert: DateTimeWithNow, 25 | update: DateTimeWithNow, 26 | json: DateTimeFromDate, 27 | }); 28 | -------------------------------------------------------------------------------- /src/misc/email-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const Email = Schema.String.pipe( 4 | Schema.trimmed(), 5 | Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/), 6 | Schema.annotations({ 7 | title: 'Email', 8 | description: 'An email address', 9 | default: 'email@email.com', 10 | }), 11 | Schema.brand('Email'), 12 | Schema.annotations({ title: 'Email' }), 13 | ); 14 | 15 | export type Email = typeof Email.Type; 16 | -------------------------------------------------------------------------------- /src/misc/empty-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const EmptySchema = Schema.Struct({}); 4 | -------------------------------------------------------------------------------- /src/misc/find-many-result-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const FindManyResultSchema = (s: Schema.Schema) => 4 | Schema.Struct({ 5 | data: Schema.Array(s), 6 | meta: Schema.Struct({ 7 | total: Schema.Number.pipe( 8 | Schema.annotations({ 9 | description: 'DB에 있는 전체 item 숫자', 10 | }), 11 | ), 12 | page: Schema.Number.pipe( 13 | Schema.annotations({ 14 | description: '현재 페이지', 15 | }), 16 | ), 17 | limit: Schema.Number.pipe( 18 | Schema.annotations({ 19 | description: '한 페이지에 보여지는 item 숫자', 20 | }), 21 | ), 22 | isLastPage: Schema.Boolean.pipe( 23 | Schema.annotations({ 24 | description: '마지막 페이지인지 여부', 25 | }), 26 | ), 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/misc/find-many-url-params-schema.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | 3 | export const FindManyUrlParams = Schema.Struct({ 4 | page: Schema.optionalWith( 5 | Schema.NumberFromString.pipe(Schema.int(), Schema.positive()), 6 | { 7 | default: () => 1, 8 | }, 9 | ), 10 | limit: Schema.optionalWith( 11 | Schema.NumberFromString.pipe(Schema.int(), Schema.positive()), 12 | { 13 | default: () => 20, 14 | }, 15 | ), 16 | sortBy: Schema.optionalWith(Schema.String.pipe(Schema.nonEmptyString()), { 17 | default: () => 'updatedAt', 18 | }), 19 | order: Schema.optionalWith(Schema.Literal('asc', 'desc'), { 20 | default: () => 'desc', 21 | }), 22 | }).annotations({ 23 | description: 'Find many items with pagination', 24 | }); 25 | 26 | export type FindManyUrlParams = typeof FindManyUrlParams.Type; 27 | -------------------------------------------------------------------------------- /src/misc/security-remove-cookie.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSecurity, HttpApp, HttpServerResponse } from '@effect/platform'; 2 | import { Effect } from 'effect'; 3 | 4 | export const securityRemoveCookie = ( 5 | self: HttpApiSecurity.ApiKey, 6 | ): Effect.Effect => { 7 | return HttpApp.appendPreResponseHandler((_req, response) => 8 | Effect.orDie( 9 | HttpServerResponse.setCookie(response, self.key, '', { 10 | secure: true, 11 | httpOnly: true, 12 | sameSite: 'strict', 13 | expires: new Date(0), 14 | path: '/', 15 | }), 16 | ), 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/misc/security.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSecurity } from '@effect/platform'; 2 | 3 | export const security = HttpApiSecurity.apiKey({ 4 | key: 'access-token', 5 | }); 6 | -------------------------------------------------------------------------------- /src/misc/test-layer.mts: -------------------------------------------------------------------------------- 1 | import { type Context, Effect, Layer } from 'effect'; 2 | 3 | const makeUnimplemented = (id: string, prop: PropertyKey) => { 4 | const dead = Effect.die(`${id}: Unimplemented method "${prop.toString()}"`); 5 | function unimplemented() { 6 | return dead; 7 | } 8 | Object.assign(unimplemented, dead); 9 | Object.setPrototypeOf(unimplemented, Object.getPrototypeOf(dead)); 10 | return unimplemented; 11 | }; 12 | const makeUnimplementedProxy = ( 13 | service: string, 14 | impl: Partial, 15 | ): A => 16 | new Proxy({ ...impl } as A, { 17 | get(target, prop, _receiver) { 18 | if (prop in target) { 19 | return target[prop as keyof A]; 20 | } 21 | return ((target as any)[prop] = makeUnimplemented(service, prop)); 22 | }, 23 | has: () => true, 24 | }); 25 | 26 | export const makeTestLayer = 27 | (tag: Context.Tag) => 28 | (service: Partial): Layer.Layer => 29 | Layer.succeed(tag, makeUnimplementedProxy(tag.key, service)); 30 | -------------------------------------------------------------------------------- /src/misc/uuid-context.mts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid'; 2 | import { Context, Effect, Layer } from 'effect'; 3 | 4 | const make = Effect.gen(function* () { 5 | const generate = Effect.sync(() => uuid.v7()); 6 | return { generate } as const; 7 | }); 8 | 9 | export class Uuid extends Context.Tag('Uuid')< 10 | Uuid, 11 | Effect.Effect.Success 12 | >() { 13 | static Live = Layer.effect(Uuid, make); 14 | static Test = Layer.succeed(Uuid, { 15 | generate: Effect.succeed('test-uuid'), 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/notification/notification-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { Model } from '@effect/sql'; 7 | import { Schema } from 'effect'; 8 | 9 | export const NotificationId = Schema.String.pipe( 10 | Schema.brand('NotificationId'), 11 | ); 12 | 13 | export type NotificationId = typeof NotificationId.Type; 14 | 15 | export class Notification extends Model.Class('Notification')({ 16 | id: Model.Generated(NotificationId), 17 | senderAccountId: AccountId, 18 | receiverAccountId: AccountId, 19 | type: Schema.Literal( 20 | 'like', 21 | 'comment', 22 | 'follow', 23 | 'mention', 24 | 'reply', 25 | 'share', 26 | 'system', 27 | 'message', 28 | 'advertisement', 29 | 'other', 30 | ), 31 | message: Schema.String, 32 | linkTo: Schema.NullishOr(Schema.String), 33 | isRead: Schema.Boolean, 34 | isDeleted: Schema.Boolean, 35 | createdAt: CustomDateTimeInsert, 36 | updatedAt: CustomDateTimeUpdate, 37 | }) {} 38 | -------------------------------------------------------------------------------- /src/post/post-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { policyUse, withSystemActor } from '@/auth/authorization.mjs'; 4 | import { HttpApiBuilder } from '@effect/platform'; 5 | import { Effect, Layer } from 'effect'; 6 | import { PostPolicy } from './post-policy.mjs'; 7 | import { PostService } from './post-service.mjs'; 8 | import { TagPolicy } from '@/tag/tag-policy.mjs'; 9 | 10 | export const PostApiLive = HttpApiBuilder.group(Api, 'post', (handlers) => 11 | Effect.gen(function* () { 12 | const postService = yield* PostService; 13 | const postPolicy = yield* PostPolicy; 14 | const tagPolicy = yield* TagPolicy; 15 | 16 | return handlers 17 | .handle('findAll', ({ urlParams }) => postService.findPosts(urlParams)) 18 | .handle('findById', ({ path }) => 19 | postService.increaseViewCountById(path.postId), 20 | ) 21 | .handle('findTags', ({ path }) => postService.findTags(path.postId)) 22 | .handle('addTags', ({ path, payload }) => 23 | postService 24 | .addTags({ postId: path.postId, names: payload.names }) 25 | .pipe(policyUse(tagPolicy.canConnectPost(path.postId))), 26 | ) 27 | .handle('deleteTag', ({ path }) => 28 | postService 29 | .deleteTag({ 30 | postId: path.postId, 31 | tagId: path.tagId, 32 | }) 33 | .pipe(policyUse(tagPolicy.canConnectPost(path.postId))), 34 | ) 35 | .handle('create', ({ payload }) => 36 | postService.create(payload).pipe(withSystemActor), 37 | ) 38 | .handle('updateById', ({ path, payload }) => 39 | postService 40 | .updateById(path.postId, payload) 41 | .pipe(policyUse(postPolicy.canUpdate(path.postId))), 42 | ) 43 | .handle('deleteById', ({ path }) => 44 | postService 45 | .deleteById(path.postId) 46 | .pipe(policyUse(postPolicy.canDelete(path.postId))), 47 | ) 48 | .handle('findLikeStatus', ({ path }) => 49 | postService.findLikeStatus(path.postId), 50 | ) 51 | .handle('likePostById', ({ path }) => 52 | postService 53 | .addLikePostById(path.postId) 54 | .pipe(policyUse(postPolicy.canLike(path.postId))), 55 | ) 56 | .handle('removeLikePostById', ({ path }) => 57 | postService 58 | .removePostLikeById(path.postId) 59 | .pipe(policyUse(postPolicy.canLike(path.postId))), 60 | ) 61 | .handle('addDislikePostById', ({ path }) => 62 | postService 63 | .addDislikePostById(path.postId) 64 | .pipe(policyUse(postPolicy.canDislike(path.postId))), 65 | ) 66 | .handle('removeDislikePostById', ({ path }) => 67 | postService 68 | .removePostDislikeById(path.postId) 69 | .pipe(policyUse(postPolicy.canDislike(path.postId))), 70 | ); 71 | }), 72 | ).pipe( 73 | Layer.provide(AuthenticationLive), 74 | Layer.provide(PostService.Live), 75 | Layer.provide(PostPolicy.Live), 76 | Layer.provide(TagPolicy.Live), 77 | ); 78 | -------------------------------------------------------------------------------- /src/post/post-error.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiSchema } from '@effect/platform'; 2 | import { Schema } from 'effect'; 3 | import { PostId } from './post-schema.mjs'; 4 | 5 | export class PostNotFound extends Schema.TaggedError()( 6 | 'PostNotFound', 7 | { id: PostId }, 8 | HttpApiSchema.annotations({ 9 | status: 404, 10 | title: 'Post Not Found', 11 | description: 'ID에 해당하는 포스트가 존재하지 않습니다.', 12 | }), 13 | ) {} 14 | -------------------------------------------------------------------------------- /src/post/post-policy.mts: -------------------------------------------------------------------------------- 1 | import { policy } from '@/auth/authorization.mjs'; 2 | import { Effect, Layer, pipe } from 'effect'; 3 | import { PostRepo } from './post-repo.mjs'; 4 | import { Post, PostId } from './post-schema.mjs'; 5 | 6 | const make = Effect.gen(function* () { 7 | const postRepo = yield* PostRepo; 8 | 9 | const canCreate = (_toCreate: typeof Post.jsonCreate.Type) => 10 | policy('post', 'create', (actor) => Effect.succeed(true)); 11 | 12 | const canRead = (id: PostId) => 13 | policy('post', 'read', (_actor) => Effect.succeed(true)); 14 | 15 | const canUpdate = (id: PostId) => 16 | policy( 17 | 'post', 18 | 'update', 19 | (actor) => 20 | pipe( 21 | postRepo.with(id, (post) => 22 | pipe( 23 | Effect.succeed( 24 | actor.id === post.accountId || actor.role === 'admin', 25 | ), 26 | ), 27 | ), 28 | ), 29 | '글 작성자나 관리자만 글을 수정할 수 있습니다.', 30 | ); 31 | 32 | const canDelete = (id: PostId) => 33 | policy( 34 | 'post', 35 | 'delete', 36 | (actor) => 37 | pipe( 38 | postRepo.with(id, (post) => 39 | pipe( 40 | Effect.succeed( 41 | actor.id === post.accountId || actor.role === 'admin', 42 | ), 43 | ), 44 | ), 45 | ), 46 | '글 작성자나 관리자만 글을 삭제할 수 있습니다.', 47 | ); 48 | 49 | const canLike = (toLike: PostId) => 50 | policy( 51 | 'post', 52 | 'like', 53 | (actor) => 54 | pipe( 55 | postRepo.with(toLike, (post) => 56 | pipe( 57 | Effect.succeed(actor.id !== post.accountId && post.isLikeAllowed), 58 | ), 59 | ), 60 | ), 61 | '글에 좋아요를 누를 수 없게 설정되어있거나, 글 작성자는 좋아요를 누를 수 없습니다.', 62 | ); 63 | 64 | const canDislike = (toDislike: PostId) => 65 | policy( 66 | 'post', 67 | 'dislike', 68 | (actor) => 69 | pipe( 70 | postRepo.with(toDislike, (post) => 71 | pipe( 72 | Effect.succeed(actor.id !== post.accountId && post.isLikeAllowed), 73 | ), 74 | ), 75 | ), 76 | '글에 싫어요를 누를 수 없게 설정되어있거나, 글 작성자는 싫어요를 누를 수 없습니다.', 77 | ); 78 | 79 | const canComment = (toComment: PostId) => 80 | policy( 81 | 'post', 82 | 'comment', 83 | (actor) => 84 | pipe( 85 | postRepo.with(toComment, (post) => 86 | pipe(Effect.succeed(post.isCommentAllowed)), 87 | ), 88 | ), 89 | '글에 댓글을 달 수 없게 설정되어있습니다.', 90 | ); 91 | 92 | return { 93 | canCreate, 94 | canRead, 95 | canUpdate, 96 | canDelete, 97 | canLike, 98 | canDislike, 99 | canComment, 100 | } as const; 101 | }); 102 | 103 | // NOTE: PostPolicy는 Service의 일종으로 여김으로써, PostService가 아닌 PostRepo를 제공받아야 합니다. 104 | 105 | export class PostPolicy extends Effect.Tag('Post/PostPolicy')< 106 | PostPolicy, 107 | Effect.Effect.Success 108 | >() { 109 | static layer = Layer.effect(PostPolicy, make); 110 | 111 | static Live = this.layer.pipe(Layer.provide(PostRepo.Live)); 112 | } 113 | -------------------------------------------------------------------------------- /src/post/post-repo.mts: -------------------------------------------------------------------------------- 1 | import { CommonCountSchema } from '@/misc/common-count-schema.mjs'; 2 | import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; 3 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 4 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 5 | import { CREATED_AT, DESC } from '@/sql/order-by.mjs'; 6 | import { SqlLive } from '@/sql/sql-live.mjs'; 7 | import { Model, SqlClient, SqlSchema } from '@effect/sql'; 8 | import { Effect, Layer, Option, pipe } from 'effect'; 9 | import { PostNotFound } from './post-error.mjs'; 10 | import { Post, PostId, PostView } from './post-schema.mjs'; 11 | import { Tag } from '@/tag/tag-schema.mjs'; 12 | import { AccountId } from '@/account/account-schema.mjs'; 13 | 14 | const TABLE_NAME = 'post'; 15 | const VIEW_NAME = 'post_like_counts'; 16 | 17 | const make = Effect.gen(function* () { 18 | const sql = yield* SqlClient.SqlClient; 19 | const repo = yield* Model.makeRepository(Post, { 20 | tableName: TABLE_NAME, 21 | spanPrefix: 'PostRepo', 22 | idColumn: 'id', 23 | }); 24 | 25 | const viewRepo = yield* Model.makeRepository(PostView, { 26 | tableName: VIEW_NAME, 27 | spanPrefix: 'PostViewRepo', 28 | idColumn: 'id', 29 | }); 30 | 31 | const findTags = (postId: PostId) => 32 | SqlSchema.findAll({ 33 | Request: PostId, 34 | Result: Tag, 35 | execute: (req) => sql` 36 | SELECT DISTINCT t.* 37 | FROM tag t 38 | left join tag_target tt on tt.tag_id = t.id 39 | LEFT JOIN post p ON tt.post_id = p.id 40 | WHERE p.id = ${req};`, 41 | })(postId).pipe(Effect.orDie, Effect.withSpan('PostRepo.findTags')); 42 | 43 | const findAllWithView = (params: FindManyUrlParams, accountId?: AccountId) => 44 | Effect.gen(function* () { 45 | const posts = yield* SqlSchema.findAll({ 46 | Request: FindManyUrlParams, 47 | Result: PostView, 48 | execute: () => 49 | sql`select * from ${sql(VIEW_NAME)} where 50 | ${sql.and( 51 | accountId 52 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 53 | : [sql`is_deleted = false`], 54 | )} 55 | order by ${sql(CREATED_AT)} ${sql.unsafe(DESC)} 56 | limit ${params.limit} 57 | offset ${(params.page - 1) * params.limit}`, 58 | })(params); 59 | const { total } = yield* SqlSchema.single({ 60 | Request: FindManyUrlParams, 61 | Result: CommonCountSchema, 62 | execute: () => 63 | sql`select count(*) as total from ${sql(TABLE_NAME)} where ${sql.and( 64 | accountId 65 | ? [sql`account_id = ${accountId}`, sql`is_deleted = false`] 66 | : [sql`is_deleted = false`], 67 | )}`, 68 | })(params); 69 | 70 | const ResultSchema = FindManyResultSchema(PostView); 71 | 72 | const result = ResultSchema.make({ 73 | data: posts, 74 | meta: { 75 | total, 76 | page: params.page, 77 | limit: params.limit, 78 | isLastPage: params.page * params.limit + posts.length >= total, 79 | }, 80 | }); 81 | 82 | return result; 83 | }).pipe(Effect.orDie, Effect.withSpan('PostRepo.findAll')); 84 | 85 | const with_ = ( 86 | id: PostId, 87 | f: (post: Post) => Effect.Effect, 88 | ): Effect.Effect => { 89 | return pipe( 90 | repo.findById(id), 91 | Effect.flatMap( 92 | Option.match({ 93 | onNone: () => new PostNotFound({ id }), 94 | onSome: Effect.succeed, 95 | }), 96 | ), 97 | Effect.flatMap(f), 98 | sql.withTransaction, 99 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 100 | ); 101 | }; 102 | 103 | const withView_ = ( 104 | id: PostId, 105 | f: (post: PostView) => Effect.Effect, 106 | ): Effect.Effect => { 107 | return pipe( 108 | viewRepo.findById(id), 109 | Effect.flatMap( 110 | Option.match({ 111 | onNone: () => new PostNotFound({ id }), 112 | onSome: Effect.succeed, 113 | }), 114 | ), 115 | Effect.flatMap(f), 116 | sql.withTransaction, 117 | Effect.catchTag('SqlError', (err) => Effect.die(err)), 118 | ); 119 | }; 120 | 121 | return { 122 | ...repo, 123 | viewRepo, 124 | findAllWithView, 125 | findTags, 126 | with: with_, 127 | withView: withView_, 128 | } as const; 129 | }); 130 | 131 | export class PostRepo extends Effect.Tag('PostRepo')< 132 | PostRepo, 133 | Effect.Effect.Success 134 | >() { 135 | static Live = Layer.effect(PostRepo, make).pipe(Layer.provide(SqlLive)); 136 | static Test = makeTestLayer(PostRepo)({}); 137 | } 138 | -------------------------------------------------------------------------------- /src/post/post-schema.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 3 | import { 4 | CustomDateTimeInsert, 5 | CustomDateTimeUpdate, 6 | } from '@/misc/date-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | 10 | export const PostId = Schema.String.pipe(Schema.brand('PostId')); 11 | 12 | export type PostId = typeof PostId.Type; 13 | 14 | export class Post extends Model.Class('Post')({ 15 | id: Model.Generated(PostId), 16 | title: Schema.String.pipe( 17 | Schema.annotations({ 18 | description: 19 | '게시글의 제목입니다. 프론트엔드에서 적절히 짤라주세요. 최대길이는 200자로 우선 해두었습니다만, DB에서는 제한이 없습니다.', 20 | default: '제목입니다', 21 | }), 22 | Schema.maxLength(200), 23 | Schema.nonEmptyString(), 24 | Schema.trimmed(), 25 | ), 26 | content: Schema.String.pipe( 27 | Schema.annotations({ 28 | description: 29 | 'contentType에 따라 작성된 게시글의 내용입니다. 지금은 markdown만 있습니다. 내용에 따라 렌더링을 다르게 해주셔야합니다.', 30 | default: ` 31 | # 제목입니다 32 | 33 | 내용입니다 34 | 35 | * 리스트 1 36 | * 리스트 2 37 | * 리스트 3 38 | `, 39 | }), 40 | Schema.nonEmptyString(), 41 | ), 42 | contentType: Schema.NullishOr( 43 | Schema.String.pipe( 44 | Schema.annotations({ 45 | description: 46 | '게시글의 내용의 타입; 현재는 markdown 고정입니다. 나중에 plain_text (그냥 텍스트)등의 다른 타입이 추가될 수 있습니다.', 47 | default: 'markdown', 48 | examples: ['markdown'], 49 | }), 50 | ), 51 | ), 52 | externalLink: Schema.NullishOr( 53 | Schema.String.pipe( 54 | Schema.annotations({ 55 | description: 56 | '게시글의 외부 링크입니다. 없어도 됩니다. 필요할 때 사용하세요.', 57 | default: 'https://google.com', 58 | }), 59 | ), 60 | ), 61 | isDeleted: Schema.Boolean.pipe( 62 | Schema.annotations({ 63 | default: false, 64 | description: '이 게시글이 삭제되었는지 여부', 65 | }), 66 | ), 67 | type: Schema.String.pipe( 68 | Schema.annotations({ 69 | description: 70 | '게시글의 타입; 수많은 타입이 있을 수 있지만 대표적으로 post, challenge, notice가 있습니다.', 71 | default: 'post', 72 | examples: ['post', 'challenge', 'notice'], 73 | }), 74 | ), 75 | isCommentAllowed: Schema.Boolean.pipe( 76 | Schema.annotations({ 77 | default: true, 78 | description: '이 게시글에 댓글을 달 수 있는지 여부', 79 | }), 80 | ), 81 | isLikeAllowed: Schema.Boolean.pipe( 82 | Schema.annotations({ 83 | default: true, 84 | description: '이 게시글에 좋아요를 누를 수 있는지 여부', 85 | }), 86 | ), 87 | challengeId: Schema.NullishOr(ChallengeId), 88 | viewCount: Model.FieldExcept( 89 | 'insert', 90 | 'jsonCreate', 91 | 'jsonUpdate', 92 | )( 93 | Schema.Number.pipe( 94 | Schema.int(), 95 | Schema.nonNegative(), 96 | Schema.annotations({ 97 | default: 0, 98 | description: '이 게시글이 조회된 횟수', 99 | }), 100 | ), 101 | ), 102 | accountId: Model.Sensitive(AccountId), 103 | createdAt: CustomDateTimeInsert, 104 | updatedAt: CustomDateTimeUpdate, 105 | }) {} 106 | 107 | export class PostView extends Model.Class('PostView')({ 108 | ...Post.fields, 109 | accountUsername: Model.FieldExcept( 110 | 'update', 111 | 'insert', 112 | 'jsonUpdate', 113 | 'jsonCreate', 114 | )( 115 | Schema.NullishOr( 116 | Schema.String.pipe( 117 | Schema.annotations({ 118 | description: '이 게시글을 쓴 유저의 username', 119 | }), 120 | ), 121 | ), 122 | ), 123 | likeCount: Model.FieldExcept( 124 | 'update', 125 | 'insert', 126 | 'jsonUpdate', 127 | 'jsonCreate', 128 | )( 129 | Schema.Number.pipe( 130 | Schema.int(), 131 | Schema.nonNegative(), 132 | Schema.annotations({ 133 | default: 0, 134 | description: '이 게시글에 달린 좋아요의 수', 135 | }), 136 | ), 137 | ), 138 | dislikeCount: Model.FieldExcept( 139 | 'update', 140 | 'insert', 141 | 'jsonUpdate', 142 | 'jsonCreate', 143 | )( 144 | Schema.Number.pipe( 145 | Schema.int(), 146 | Schema.nonNegative(), 147 | Schema.annotations({ 148 | default: 0, 149 | description: '이 게시글에 달린 싫어요의 수', 150 | }), 151 | ), 152 | ), 153 | commentCount: Model.FieldExcept( 154 | 'update', 155 | 'insert', 156 | 'jsonUpdate', 157 | 'jsonCreate', 158 | )( 159 | Schema.Number.pipe( 160 | Schema.int(), 161 | Schema.nonNegative(), 162 | Schema.annotations({ 163 | default: 0, 164 | description: '이 게시글에 달린 댓글의 수', 165 | }), 166 | ), 167 | ), 168 | pureLikeCount: Model.FieldExcept( 169 | 'update', 170 | 'insert', 171 | 'jsonUpdate', 172 | 'jsonCreate', 173 | )( 174 | Schema.Number.pipe( 175 | Schema.int(), 176 | Schema.nonNegative(), 177 | Schema.annotations({ 178 | default: 0, 179 | description: '이 게시글에 달린 댓글의 수', 180 | }), 181 | ), 182 | ), 183 | }) {} 184 | 185 | export class PostCommentView extends Model.Class( 186 | 'PostCommentView', 187 | )({ 188 | id: Post.fields.id, 189 | commentCount: Model.FieldExcept( 190 | 'update', 191 | 'insert', 192 | 'jsonUpdate', 193 | 'jsonCreate', 194 | )( 195 | Schema.Number.pipe( 196 | Schema.int(), 197 | Schema.nonNegative(), 198 | Schema.annotations({ 199 | default: 0, 200 | description: '이 게시글에 달린 댓글의 수', 201 | }), 202 | ), 203 | ), 204 | }) {} 205 | -------------------------------------------------------------------------------- /src/post/post-service.mts: -------------------------------------------------------------------------------- 1 | import { AccountId, CurrentAccount } from '@/account/account-schema.mjs'; 2 | import { policyRequire } from '@/auth/authorization.mjs'; 3 | import { LikeService } from '@/like/like-service.mjs'; 4 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 5 | import { SqlTest } from '@/sql/sql-test.mjs'; 6 | import { TagService } from '@/tag/tag-service.mjs'; 7 | import { Effect, Layer, pipe } from 'effect'; 8 | import { PostRepo } from './post-repo.mjs'; 9 | import { Post, PostId } from './post-schema.mjs'; 10 | import { TagId } from '@/tag/tag-schema.mjs'; 11 | 12 | const make = Effect.gen(function* () { 13 | const postRepo = yield* PostRepo; 14 | const likeService = yield* LikeService; 15 | const tagService = yield* TagService; 16 | 17 | const findByIdFromRepo = (postId: PostId) => postRepo.findById(postId); 18 | 19 | const findPosts = (params: FindManyUrlParams, accountId?: AccountId) => 20 | postRepo 21 | .findAllWithView(params, accountId) 22 | .pipe(Effect.withSpan('PostService.findPosts')); 23 | 24 | const findByIdWithView = (postId: PostId) => 25 | postRepo.withView(postId, (post) => 26 | pipe(Effect.succeed(post), Effect.withSpan('PostService.findById')), 27 | ); 28 | 29 | const findTags = (postId: PostId) => postRepo.findTags(postId); 30 | 31 | const addTags = (payload: { postId: PostId; names: readonly string[] }) => 32 | tagService.connectPostByNames(payload); 33 | 34 | const deleteTag = (payload: { postId: PostId; tagId: TagId }) => 35 | tagService.deletePostTagConnection(payload); 36 | 37 | const create = (post: typeof Post.jsonCreate.Type) => 38 | pipe( 39 | CurrentAccount, 40 | Effect.flatMap(({ id: accountId }) => 41 | postRepo.insert( 42 | Post.insert.make({ 43 | ...post, 44 | accountId, 45 | }), 46 | ), 47 | ), 48 | Effect.withSpan('PostService.createPost'), 49 | policyRequire('post', 'create'), 50 | Effect.flatMap((post) => findByIdWithView(post.id)), 51 | ); 52 | 53 | const updateById = ( 54 | postId: PostId, 55 | post: Partial, 56 | ) => 57 | postRepo.with(postId, (existing) => 58 | pipe( 59 | postRepo.update({ 60 | ...existing, 61 | ...post, 62 | updatedAt: undefined, 63 | }), 64 | Effect.withSpan('PostService.updatePost'), 65 | policyRequire('post', 'update'), 66 | Effect.flatMap((post) => findByIdWithView(post.id)), 67 | ), 68 | ); 69 | 70 | const deleteById = (postId: PostId) => 71 | postRepo.with(postId, (post) => 72 | pipe( 73 | postRepo.update({ 74 | ...post, 75 | isDeleted: true, 76 | updatedAt: undefined, 77 | }), 78 | Effect.withSpan('PostService.deleteById'), 79 | policyRequire('post', 'delete'), 80 | ), 81 | ); 82 | 83 | const findLikeStatus = (postId: PostId) => 84 | pipe(likeService.getLikeStatusByPostId(postId)); 85 | 86 | const addLikePostById = (postId: PostId) => 87 | postRepo.with(postId, (post) => 88 | likeService 89 | .addLikePostById(post.id) 90 | .pipe( 91 | Effect.withSpan('PostService.addLikePostById'), 92 | policyRequire('post', 'like'), 93 | ) 94 | .pipe(Effect.flatMap(() => findByIdWithView(post.id))), 95 | ); 96 | 97 | const removePostLikeById = (postId: PostId) => 98 | postRepo.with(postId, (post) => 99 | likeService 100 | .removeLikePostById(post.id) 101 | .pipe( 102 | Effect.withSpan('PostService.addDislikePostById'), 103 | policyRequire('post', 'like'), 104 | ) 105 | .pipe(Effect.flatMap(() => findByIdWithView(post.id))), 106 | ); 107 | 108 | const addDislikePostById = (postId: PostId) => 109 | postRepo.with(postId, (post) => 110 | likeService 111 | .addDislikePostById(post.id) 112 | .pipe( 113 | Effect.withSpan('PostService.addDislikePostById'), 114 | policyRequire('post', 'dislike'), 115 | ) 116 | .pipe(Effect.flatMap(() => findByIdWithView(post.id))), 117 | ); 118 | 119 | const removePostDislikeById = (postId: PostId) => 120 | postRepo.with(postId, (post) => 121 | likeService 122 | .removeDislikePostById(post.id) 123 | .pipe( 124 | Effect.withSpan('PostService.removePostDislikeById'), 125 | policyRequire('post', 'dislike'), 126 | ) 127 | .pipe(Effect.flatMap(() => findByIdWithView(post.id))), 128 | ); 129 | 130 | const increaseViewCountById = (postId: PostId) => 131 | postRepo.with(postId, (post) => 132 | pipe( 133 | postRepo.update({ 134 | ...post, 135 | viewCount: post.viewCount + 1, 136 | updatedAt: undefined, 137 | }), 138 | Effect.withSpan('PostService.increaseViewCountById'), 139 | Effect.flatMap((post) => findByIdWithView(post.id)), 140 | ), 141 | ); 142 | 143 | return { 144 | findByIdFromRepo, 145 | findPosts, 146 | findByIdWithView, 147 | findLikeStatus, 148 | findTags, 149 | addTags, 150 | deleteTag, 151 | increaseViewCountById, 152 | addLikePostById, 153 | removePostLikeById, 154 | addDislikePostById, 155 | removePostDislikeById, 156 | create, 157 | updateById, 158 | deleteById, 159 | } as const; 160 | }); 161 | 162 | export class PostService extends Effect.Tag('PostService')< 163 | PostService, 164 | Effect.Effect.Success 165 | >() { 166 | static layer = Layer.effect(PostService, make); 167 | 168 | static Live = this.layer.pipe( 169 | Layer.provide(PostRepo.Live), 170 | Layer.provide(LikeService.Live), 171 | Layer.provide(TagService.Live), 172 | ); 173 | 174 | static Test = this.layer.pipe(Layer.provideMerge(SqlTest)); 175 | } 176 | -------------------------------------------------------------------------------- /src/root-api-live.mts: -------------------------------------------------------------------------------- 1 | import { HttpApiBuilder } from '@effect/platform'; 2 | import { Effect, Layer } from 'effect'; 3 | import { Api } from './api.mjs'; 4 | import { RootService } from './root-service.mjs'; 5 | 6 | export const RootApiLive = HttpApiBuilder.group(Api, 'root', (handlers) => 7 | Effect.gen(function* () { 8 | const service = yield* RootService; 9 | return handlers 10 | .handle('health', () => service.getHealth()) 11 | .handle('home', () => service.getHome()); 12 | }), 13 | ).pipe(Layer.provide(RootService.Live)); 14 | -------------------------------------------------------------------------------- /src/root-api.mts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpApiGroup, 3 | HttpApiEndpoint, 4 | HttpApiSchema, 5 | OpenApi, 6 | } from '@effect/platform'; 7 | import { Schema } from 'effect'; 8 | 9 | export class RootApi extends HttpApiGroup.make('root') 10 | .add( 11 | HttpApiEndpoint.get('health', '/api/health') 12 | .addSuccess( 13 | Schema.Struct({ 14 | status: Schema.String, 15 | db: Schema.Struct({ 16 | status: Schema.String, 17 | }), 18 | }), 19 | ) 20 | .addError( 21 | Schema.String.pipe( 22 | HttpApiSchema.asEmpty({ status: 500, decode: () => 'boom' }), 23 | ), 24 | ) 25 | .annotateContext( 26 | OpenApi.annotations({ 27 | title: 'Health Check', 28 | description: '서비스가 정상인지 체크하는 API', 29 | }), 30 | ), 31 | ) 32 | .add( 33 | HttpApiEndpoint.get('home', '/') 34 | .addSuccess( 35 | Schema.String.pipe( 36 | HttpApiSchema.withEncoding({ 37 | kind: 'Text', 38 | contentType: 'text/html', 39 | }), 40 | ), 41 | ) 42 | .addError( 43 | Schema.String.pipe( 44 | HttpApiSchema.asEmpty({ status: 404, decode: () => 'Not Found' }), 45 | ), 46 | ) 47 | .annotateContext( 48 | OpenApi.annotations({ 49 | title: '메인 화면', 50 | description: 51 | '편의상 /로 해둔 메인화면입니다. 통상 Swagger에 이걸 표시하진 않지만, 어떻게 숨기는지 모르겠습니다. Effect의 HttpApiEndpoint에서는 아직 지원하지 않는 기능으로 보입니다.', 52 | }), 53 | ), 54 | ) {} 55 | -------------------------------------------------------------------------------- /src/root-service.mts: -------------------------------------------------------------------------------- 1 | import { FileSystem } from '@effect/platform'; 2 | import { NodeContext } from '@effect/platform-node'; 3 | import { SqlClient } from '@effect/sql'; 4 | import { Effect, Layer } from 'effect'; 5 | import { makeTestLayer } from './misc/test-layer.mjs'; 6 | import { SqlLive } from './sql/sql-live.mjs'; 7 | 8 | const make = Effect.gen(function* () { 9 | const sql = yield* SqlClient.SqlClient; 10 | const getHealth = () => 11 | Effect.gen(function* () { 12 | const checkDb = yield* sql`SELECT 1`; 13 | const dbStatus = checkDb.length > 0 ? 'ok' : 'error'; 14 | 15 | // 다른 서비스가 정상인지 체크하는 로직을 추가할 수 있습니다. 16 | 17 | return { 18 | status: dbStatus, 19 | db: { 20 | status: dbStatus, 21 | }, 22 | }; 23 | }).pipe(Effect.orDie); 24 | 25 | const getHome = () => 26 | Effect.gen(function* () { 27 | const fs = yield* FileSystem.FileSystem; 28 | const content = yield* fs.readFileString('./package.json', 'utf8'); 29 | const packageJson = JSON.parse(content); 30 | 31 | return yield* Effect.succeed(` 32 | 33 | 34 | 35 | 36 | Advanced Class Server 37 | 38 | 39 |

Advanced Class Server, version: ${packageJson.version as string}

40 |
API 문서로 바로가기 41 | 42 | 43 | 44 | `); 45 | }).pipe(Effect.orDie); 46 | 47 | return { 48 | getHome, 49 | getHealth, 50 | } as const; 51 | }); 52 | 53 | export class RootService extends Effect.Tag('RootApiService')< 54 | RootService, 55 | Effect.Effect.Success 56 | >() { 57 | static layer = Layer.effect(RootService, make); 58 | 59 | static Test = makeTestLayer(RootService)({}); 60 | 61 | static Live = this.layer.pipe( 62 | Layer.provide(SqlLive), 63 | Layer.provide(NodeContext.layer), 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/sql/migrations/00002_add_properties_for_account.ts: -------------------------------------------------------------------------------- 1 | import { SqlClient } from '@effect/sql'; 2 | import { Effect } from 'effect'; 3 | 4 | const program = Effect.gen(function* () { 5 | const sql = yield* SqlClient.SqlClient; 6 | yield* sql.onDialectOrElse({ 7 | pg: () => 8 | sql` 9 | ALTER TABLE account 10 | ADD COLUMN username TEXT DEFAULT NULL, 11 | ADD COLUMN birthday DATE DEFAULT NULL; 12 | `, 13 | orElse: () => sql``, 14 | }); 15 | }); 16 | 17 | export default program; 18 | -------------------------------------------------------------------------------- /src/sql/migrations/README.md: -------------------------------------------------------------------------------- 1 | In this directory, the migrations files should have '_.ts' extension, not '_.mts'. 2 | -------------------------------------------------------------------------------- /src/sql/order-by.mts: -------------------------------------------------------------------------------- 1 | export const ORDER_BY = ['created_at', 'updated_at'] as const; 2 | export const SORT_ORDER = ['ASC', 'DESC'] as const; 3 | 4 | export type OrderBy = (typeof ORDER_BY)[number]; 5 | export type SortOrder = (typeof SORT_ORDER)[number]; 6 | 7 | export const CREATED_AT = ORDER_BY[0]; 8 | export const UPDATED_AT = ORDER_BY[1]; 9 | 10 | export const ASC = SORT_ORDER[0]; 11 | export const DESC = SORT_ORDER[1]; 12 | -------------------------------------------------------------------------------- /src/sql/postgres-client-live.mts: -------------------------------------------------------------------------------- 1 | import { PgClient } from '@effect/sql-pg'; 2 | import { String, Config } from 'effect'; 3 | 4 | export const PostgresClientLive = PgClient.layer({ 5 | url: Config.redacted('DATABASE_URL'), 6 | ssl: Config.succeed(false), 7 | transformQueryNames: Config.succeed(String.camelToSnake), 8 | transformResultNames: Config.succeed(String.snakeToCamel), 9 | }); 10 | -------------------------------------------------------------------------------- /src/sql/postgres-migrator-live.mts: -------------------------------------------------------------------------------- 1 | import { NodeContext } from '@effect/platform-node'; 2 | import { PgMigrator } from '@effect/sql-pg'; 3 | import { Layer } from 'effect'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | export const PostgresMigratorLive = PgMigrator.layer({ 7 | loader: PgMigrator.fromFileSystem( 8 | fileURLToPath(new URL('./migrations', import.meta.url)), 9 | ), 10 | }).pipe(Layer.provide(NodeContext.layer)); 11 | -------------------------------------------------------------------------------- /src/sql/sql-live.mts: -------------------------------------------------------------------------------- 1 | import { Layer } from 'effect'; 2 | import { PostgresClientLive } from './postgres-client-live.mjs'; 3 | import { PostgresMigratorLive } from './postgres-migrator-live.mjs'; 4 | 5 | export const SqlLive = PostgresMigratorLive.pipe( 6 | Layer.provideMerge(PostgresClientLive), 7 | ); 8 | -------------------------------------------------------------------------------- /src/sql/sql-test.mts: -------------------------------------------------------------------------------- 1 | import { makeTestLayer } from '@/misc/test-layer.mjs'; 2 | import { SqlClient } from '@effect/sql'; 3 | import { identity } from 'effect'; 4 | 5 | export const SqlTest = makeTestLayer(SqlClient.SqlClient)({ 6 | withTransaction: identity, 7 | }); 8 | -------------------------------------------------------------------------------- /src/supabase/supabase-service.mts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@/misc/config-service.mjs'; 2 | import { Effect, Layer } from 'effect'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | 5 | const make = Effect.gen(function* () { 6 | const configService = yield* ConfigService; 7 | const { supabase } = configService; 8 | 9 | const supabaseClient = createClient( 10 | `https://${supabase.id}.supabase.co`, 11 | supabase.anon, 12 | ); 13 | 14 | return supabaseClient; 15 | }); 16 | 17 | export class SupabaseService extends Effect.Tag('SupabaseService')< 18 | SupabaseService, 19 | Effect.Effect.Success 20 | >() { 21 | static layer = Layer.effect(SupabaseService, make); 22 | static Live = this.layer.pipe(Layer.provide(ConfigService.Live)); 23 | } 24 | -------------------------------------------------------------------------------- /src/tag/account-interest-tag.mts: -------------------------------------------------------------------------------- 1 | import { AccountId } from '@/account/account-schema.mjs'; 2 | import { Model } from '@effect/sql'; 3 | import { Schema } from 'effect'; 4 | import { TagId } from './tag-schema.mjs'; 5 | import { 6 | CustomDateTimeInsert, 7 | CustomDateTimeUpdate, 8 | } from '@/misc/date-schema.mjs'; 9 | 10 | export const AccountInterestTagId = Schema.String.pipe( 11 | Schema.brand('AccountInterestTagId'), 12 | ); 13 | 14 | export type AccountInterestTagId = typeof AccountInterestTagId.Type; 15 | 16 | export class AccountInterestTag extends Model.Class( 17 | 'AccountInterestTag', 18 | )({ 19 | id: Model.Generated(AccountInterestTagId), 20 | accountId: AccountId, 21 | tagId: TagId, 22 | createdAt: CustomDateTimeInsert, 23 | updatedAt: CustomDateTimeUpdate, 24 | }) {} 25 | -------------------------------------------------------------------------------- /src/tag/tag-api-live.mts: -------------------------------------------------------------------------------- 1 | import { Api } from '@/api.mjs'; 2 | import { AuthenticationLive } from '@/auth/authentication.mjs'; 3 | import { HttpApiBuilder } from '@effect/platform'; 4 | import { Effect, Layer } from 'effect'; 5 | import { TagService } from './tag-service.mjs'; 6 | import { TagPolicy } from './tag-policy.mjs'; 7 | import { policyUse } from '@/auth/authorization.mjs'; 8 | 9 | export const TagApiLive = HttpApiBuilder.group(Api, 'tag', (handlers) => 10 | Effect.gen(function* () { 11 | const tagService = yield* TagService; 12 | const tagPolicy = yield* TagPolicy; 13 | 14 | return handlers 15 | .handle('findAll', ({ urlParams }) => tagService.findAll(urlParams)) 16 | .handle('findById', ({ path }) => tagService.findById(path.tagId)) 17 | .handle('findByName', ({ path }) => tagService.findByName(path.tagName)) 18 | .handle('create', ({ payload }) => 19 | tagService.getOrInsert(payload).pipe(policyUse(tagPolicy.canCreate())), 20 | ) 21 | .handle('update', ({ path, payload }) => 22 | tagService 23 | .update(path.tagId, payload) 24 | .pipe(policyUse(tagPolicy.canUpdate())), 25 | ) 26 | .handle('delete', ({ path }) => 27 | tagService 28 | .deleteById(path.tagId) 29 | .pipe(policyUse(tagPolicy.canDelete())), 30 | ); 31 | }), 32 | ).pipe( 33 | Layer.provide(AuthenticationLive), 34 | Layer.provide(TagService.Live), 35 | Layer.provide(TagPolicy.Live), 36 | ); 37 | -------------------------------------------------------------------------------- /src/tag/tag-api.mts: -------------------------------------------------------------------------------- 1 | import { Authentication } from '@/auth/authentication.mjs'; 2 | import { Unauthorized } from '@/auth/error-403.mjs'; 3 | import { FindManyResultSchema } from '@/misc/find-many-result-schema.mjs'; 4 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 5 | import { HttpApiEndpoint, HttpApiGroup, OpenApi } from '@effect/platform'; 6 | import { Schema } from 'effect'; 7 | import { TagNotFound } from './tag-error.mjs'; 8 | import { Tag, TagId } from './tag-schema.mjs'; 9 | 10 | export class TagApi extends HttpApiGroup.make('tag') 11 | .add( 12 | HttpApiEndpoint.get('findAll', '/') 13 | .setUrlParams(FindManyUrlParams) 14 | .addSuccess(FindManyResultSchema(Tag.json)) 15 | .annotateContext( 16 | OpenApi.annotations({ 17 | description: 18 | '태그 목록을 조회합니다. 페이지와 한 페이지당 태그 수를 지정할 수 있습니다.', 19 | override: { 20 | summary: '(사용가능) 태그 목록 조회', 21 | }, 22 | }), 23 | ), 24 | ) 25 | .add( 26 | HttpApiEndpoint.get('findById', '/:tagId') 27 | .setPath( 28 | Schema.Struct({ 29 | tagId: TagId, 30 | }), 31 | ) 32 | .addError(TagNotFound) 33 | .addSuccess(Tag.json) 34 | .annotateContext( 35 | OpenApi.annotations({ 36 | description: 37 | '태그를 조회합니다. 태그가 존재하지 않는 경우 404를 반환합니다.', 38 | override: { 39 | summary: '(사용가능) 태그 ID 조회', 40 | }, 41 | }), 42 | ), 43 | ) 44 | .add( 45 | HttpApiEndpoint.get('findByName', '/by-name/:tagName') 46 | .setPath( 47 | Schema.Struct({ 48 | tagName: Schema.String, 49 | }), 50 | ) 51 | .addError(TagNotFound) 52 | .addSuccess(Tag.json) 53 | .annotateContext( 54 | OpenApi.annotations({ 55 | description: 56 | '태그를 이름으로 조회합니다. 태그가 존재하지 않는 경우 404를 반환합니다.', 57 | override: { 58 | summary: '(사용가능) 태그 이름 조회', 59 | }, 60 | }), 61 | ), 62 | ) 63 | .add( 64 | HttpApiEndpoint.post('create', '/') 65 | .middleware(Authentication) 66 | .setPayload(Tag.jsonCreate) 67 | .addError(Unauthorized) 68 | .addSuccess(Tag.json) 69 | .annotateContext( 70 | OpenApi.annotations({ 71 | description: '(누구나 가능) 태그를 생성합니다.', 72 | override: { 73 | summary: '(사용가능) 태그 생성', 74 | }, 75 | }), 76 | ), 77 | ) 78 | .add( 79 | HttpApiEndpoint.patch('update', '/:tagId') 80 | .middleware(Authentication) 81 | .setPath( 82 | Schema.Struct({ 83 | tagId: TagId, 84 | }), 85 | ) 86 | .setPayload(Schema.partialWith(Tag.jsonUpdate, { exact: true })) 87 | .addError(Unauthorized) 88 | .addError(TagNotFound) 89 | .addSuccess(Tag.json) 90 | .annotateContext( 91 | OpenApi.annotations({ 92 | description: '관리자만 가능 태그를 수정합니다.', 93 | override: { 94 | summary: '(사용가능)(관리자 전용) 태그 수정', 95 | }, 96 | }), 97 | ), 98 | ) 99 | .add( 100 | HttpApiEndpoint.del('delete', '/:tagId') 101 | .middleware(Authentication) 102 | .setPath( 103 | Schema.Struct({ 104 | tagId: TagId, 105 | }), 106 | ) 107 | .addError(Unauthorized) 108 | .addError(TagNotFound) 109 | .annotateContext( 110 | OpenApi.annotations({ 111 | description: '관리자만 가능 태그를 삭제합니다.', 112 | override: { 113 | summary: '(사용가능)(관리자 전용) 태그 삭제', 114 | }, 115 | }), 116 | ), 117 | ) 118 | .prefix('/api/tags') 119 | .annotateContext( 120 | OpenApi.annotations({ 121 | title: '(사용가능) 태그 API', 122 | }), 123 | ) {} 124 | -------------------------------------------------------------------------------- /src/tag/tag-error.mts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'effect'; 2 | import { TagId } from './tag-schema.mjs'; 3 | import { HttpApiSchema } from '@effect/platform'; 4 | import { PostId } from '@/post/post-schema.mjs'; 5 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 6 | 7 | export class TagNotFound extends Schema.TaggedError()( 8 | 'TagNotFound', 9 | { id: TagId }, 10 | HttpApiSchema.annotations({ 11 | status: 404, 12 | title: 'Tag Not Found', 13 | description: 'ID에 해당하는 태그가 존재하지 않습니다.', 14 | }), 15 | ) {} 16 | 17 | export class TagTargetNotFound extends Schema.TaggedError()( 18 | 'TagTargetNotFound', 19 | { 20 | tagId: TagId, 21 | postId: Schema.NullishOr(PostId), 22 | challengeId: Schema.NullishOr(ChallengeId), 23 | }, 24 | HttpApiSchema.annotations({ 25 | status: 404, 26 | title: 'Tag Target Not Found', 27 | description: 'ID에 해당하는 태그 연결이 존재하지 않습니다.', 28 | }), 29 | ) {} 30 | -------------------------------------------------------------------------------- /src/tag/tag-policy.mts: -------------------------------------------------------------------------------- 1 | import { policy } from '@/auth/authorization.mjs'; 2 | import { ChallengeNotFound } from '@/challenge/challenge-error.mjs'; 3 | import { ChallengeRepo } from '@/challenge/challenge-repo.mjs'; 4 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 5 | import { PostNotFound } from '@/post/post-error.mjs'; 6 | import { PostRepo } from '@/post/post-repo.mjs'; 7 | import { PostId } from '@/post/post-schema.mjs'; 8 | import { Option, Effect, Layer } from 'effect'; 9 | 10 | const make = Effect.gen(function* () { 11 | const postRepo = yield* PostRepo; 12 | const challengeRepo = yield* ChallengeRepo; 13 | 14 | const canCreate = () => 15 | policy('tag', 'create', (actor) => Effect.succeed(true)); 16 | 17 | const canRead = () => policy('tag', 'read', (_actor) => Effect.succeed(true)); 18 | 19 | const canUpdate = () => 20 | policy('tag', 'update', (actor) => Effect.succeed(actor.role === 'admin')); 21 | 22 | const canDelete = () => 23 | policy('tag', 'delete', (actor) => Effect.succeed(actor.role === 'admin')); 24 | 25 | const canConnectPost = (postId: PostId) => 26 | policy( 27 | 'tag', 28 | 'connectPost', 29 | (actor) => 30 | Effect.gen(function* () { 31 | const maybePost = yield* postRepo.findById(postId); 32 | const post = yield* Option.match(maybePost, { 33 | onSome: Effect.succeed, 34 | onNone: () => 35 | new PostNotFound({ 36 | id: postId, 37 | }), 38 | }); 39 | 40 | return yield* Effect.succeed( 41 | actor.role === 'admin' || post.accountId === actor.id, 42 | ); 43 | }), 44 | 'Post 작성자 또는 관리자만 태그를 연결하거나 삭제할 수 있습니다.', 45 | ); 46 | 47 | const canConnectChallenge = (challengeId: ChallengeId) => 48 | policy( 49 | 'tag', 50 | 'connectChallenge', 51 | (actor) => 52 | Effect.gen(function* () { 53 | const maybeChallenge = yield* challengeRepo.findById(challengeId); 54 | const challenge = yield* Option.match(maybeChallenge, { 55 | onSome: Effect.succeed, 56 | onNone: () => new ChallengeNotFound({ id: challengeId }), 57 | }); 58 | return yield* Effect.succeed( 59 | actor.role === 'admin' || challenge.accountId === actor.id, 60 | ); 61 | }), 62 | 'Challenge 작성자 또는 관리자만 태그를 연결하거나 삭제할 수 있습니다.', 63 | ); 64 | 65 | return { 66 | canCreate, 67 | canRead, 68 | canUpdate, 69 | canDelete, 70 | canConnectPost, 71 | canConnectChallenge, 72 | } as const; 73 | }); 74 | 75 | export class TagPolicy extends Effect.Tag('TagPolicy')< 76 | TagPolicy, 77 | Effect.Effect.Success 78 | >() { 79 | static layer = Layer.effect(TagPolicy, make); 80 | 81 | static Live = this.layer.pipe( 82 | Layer.provide(PostRepo.Live), 83 | Layer.provide(ChallengeRepo.Live), 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/tag/tag-schema.mts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomDateTimeInsert, 3 | CustomDateTimeUpdate, 4 | } from '@/misc/date-schema.mjs'; 5 | import { Model } from '@effect/sql'; 6 | import { Schema } from 'effect'; 7 | 8 | export const TagId = Schema.String.pipe(Schema.brand('TagId')); 9 | 10 | export type TagId = typeof TagId.Type; 11 | 12 | export class Tag extends Model.Class('Tag')({ 13 | id: Model.Generated(TagId), 14 | name: Model.FieldExcept('jsonUpdate')(Schema.String.pipe(Schema.trimmed())), 15 | hslColor: Schema.NullishOr( 16 | Schema.String.pipe( 17 | Schema.filter((colorStr) => { 18 | // colorStr is hsl string 19 | const regex = /^(\d+)\s+(\d+)%\s+(\d+)%$/; 20 | 21 | if (!regex.test(colorStr)) { 22 | return false; 23 | } 24 | 25 | const [_, h, s, l] = regex.exec(colorStr)!; 26 | 27 | if (parseInt(h, 10) < 0 || parseInt(h, 10) >= 360) { 28 | return false; 29 | } 30 | 31 | if (parseInt(s, 10) < 0 || parseInt(s, 10) > 100) { 32 | return false; 33 | } 34 | 35 | if (parseInt(l, 10) < 0 || parseInt(l, 10) > 100) { 36 | return false; 37 | } 38 | 39 | return true; 40 | }), 41 | Schema.annotations({ 42 | description: 43 | '색상을 hsl로 표현한 문자열입니다. hsl(<문자열값>) 에 해당하는 문자열값을 입력으로 넣어주세요. 그래야 프론트엔드에서 색상을 원활하게 표현할 수 있습니다.', 44 | examples: ['0 100% 50%', '120 100% 50%', '240 100% 50%'], 45 | default: '0 100% 50%', 46 | jsonSchema: { 47 | type: 'string', 48 | example: '0 100% 50%', 49 | }, 50 | }), 51 | ), 52 | ), 53 | description: Schema.String, 54 | createdAt: CustomDateTimeInsert, 55 | updatedAt: CustomDateTimeUpdate, 56 | }) {} 57 | -------------------------------------------------------------------------------- /src/tag/tag-service.mts: -------------------------------------------------------------------------------- 1 | import { policyRequire } from '@/auth/authorization.mjs'; 2 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 3 | import { FindManyUrlParams } from '@/misc/find-many-url-params-schema.mjs'; 4 | import { PostId } from '@/post/post-schema.mjs'; 5 | import { Effect, Layer, Option, pipe } from 'effect'; 6 | import { TagNotFound } from './tag-error.mjs'; 7 | import { TagRepo } from './tag-repo.mjs'; 8 | import { Tag, TagId } from './tag-schema.mjs'; 9 | 10 | const make = Effect.gen(function* () { 11 | const repo = yield* TagRepo; 12 | 13 | const findAll = (parmas: FindManyUrlParams) => 14 | repo.findAll(parmas).pipe(Effect.withSpan('TagService.findAll')); 15 | 16 | const findById = (id: TagId) => 17 | repo.findById(id).pipe( 18 | Effect.flatMap( 19 | Option.match({ 20 | onSome: Effect.succeed, 21 | onNone: () => Effect.fail(new TagNotFound({ id })), 22 | }), 23 | ), 24 | ); 25 | 26 | const findByName = (name: string) => 27 | repo.findOne(name).pipe( 28 | Effect.flatMap( 29 | Option.match({ 30 | onSome: Effect.succeed, 31 | onNone: () => Effect.fail(new TagNotFound({ id: TagId.make('') })), 32 | }), 33 | ), 34 | ); 35 | 36 | const deletePostTagConnection = (payload: { postId: PostId; tagId: TagId }) => 37 | repo.withPostTarget(payload.postId, payload.tagId, (target) => 38 | pipe( 39 | repo.targetRepo.delete(target.id), 40 | Effect.withSpan('TagService.deletePostTagConnection'), 41 | policyRequire('tag', 'connectPost'), 42 | ), 43 | ); 44 | 45 | const connectPostByNames = (payload: { 46 | postId: PostId; 47 | names: readonly string[]; 48 | }) => 49 | repo.getManyOrInsertMany(payload.names).pipe( 50 | Effect.flatMap((tags) => 51 | repo.connectTagsToPost({ 52 | postId: payload.postId, 53 | tagIds: tags.map((v) => v.id), 54 | }), 55 | ), 56 | Effect.flatMap((targets) => 57 | repo.findManyByIds(targets.map((v) => v.tagId)), 58 | ), 59 | policyRequire('tag', 'connectPost'), 60 | ); 61 | 62 | const deleteChallengeTagConnection = (payload: { 63 | challengeId: ChallengeId; 64 | tagId: TagId; 65 | }) => 66 | repo.withChallengeTarget(payload.challengeId, payload.tagId, (target) => 67 | pipe( 68 | repo.targetRepo.delete(target.id), 69 | Effect.withSpan('TagService.deleteChallengeTagConnection'), 70 | policyRequire('tag', 'connectChallenge'), 71 | ), 72 | ); 73 | 74 | const connectChallengeByNames = (payload: { 75 | challengeId: ChallengeId; 76 | names: readonly string[]; 77 | }) => 78 | repo.getManyOrInsertMany(payload.names).pipe( 79 | Effect.flatMap((tags) => 80 | repo.connectTagsToChallenge({ 81 | challengeId: payload.challengeId, 82 | tagIds: tags.map((v) => v.id), 83 | }), 84 | ), 85 | Effect.flatMap((targets) => 86 | repo.findManyByIds(targets.map((v) => v.tagId)), 87 | ), 88 | policyRequire('tag', 'connectChallenge'), 89 | ); 90 | 91 | const getOrInsert = (payload: typeof Tag.jsonCreate.Type) => 92 | repo.getOrInsert(payload).pipe(policyRequire('tag', 'create')); 93 | 94 | const update = (id: TagId, payload: Partial) => 95 | repo 96 | .with(id, (tag) => 97 | repo.update({ 98 | ...tag, 99 | ...(payload?.description && { description: payload.description }), 100 | updatedAt: undefined, 101 | }), 102 | ) 103 | .pipe(policyRequire('tag', 'update')); 104 | 105 | const deleteById = (id: TagId) => 106 | repo.delete(id).pipe(policyRequire('tag', 'delete')); 107 | 108 | return { 109 | findAll, 110 | findById, 111 | findByName, 112 | getOrInsert, 113 | deletePostTagConnection, 114 | connectPostByNames, 115 | deleteChallengeTagConnection, 116 | connectChallengeByNames, 117 | update, 118 | deleteById, 119 | } as const; 120 | }); 121 | 122 | export class TagService extends Effect.Tag('TagService')< 123 | TagService, 124 | Effect.Effect.Success 125 | >() { 126 | static layer = Layer.effect(TagService, make); 127 | 128 | static Live = this.layer.pipe(Layer.provide(TagRepo.Live)); 129 | } 130 | -------------------------------------------------------------------------------- /src/tag/tag-target-schema.mts: -------------------------------------------------------------------------------- 1 | import { ChallengeId } from '@/challenge/challenge-schema.mjs'; 2 | import { 3 | CustomDateTimeInsert, 4 | CustomDateTimeUpdate, 5 | } from '@/misc/date-schema.mjs'; 6 | import { PostId } from '@/post/post-schema.mjs'; 7 | import { Model } from '@effect/sql'; 8 | import { Schema } from 'effect'; 9 | import { TagId } from './tag-schema.mjs'; 10 | 11 | export const TagTargetId = Schema.String.pipe(Schema.brand('TagTargetId')); 12 | 13 | export type TagTargetId = typeof TagTargetId.Type; 14 | 15 | export class TagTarget extends Model.Class('TagTarget')({ 16 | id: Model.Generated(TagTargetId), 17 | tagId: TagId, 18 | postId: Model.FieldOption(PostId), 19 | challengeId: Model.FieldOption(ChallengeId), 20 | createdAt: CustomDateTimeInsert, 21 | updatedAt: CustomDateTimeUpdate, 22 | }) {} 23 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "exactOptionalPropertyTypes": true, 5 | "moduleDetection": "force", 6 | "composite": true, 7 | "downlevelIteration": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "skipLibCheck": true, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "NodeNext", 15 | "lib": [ 16 | "ES2022", 17 | "DOM", 18 | "DOM.Iterable" 19 | ], 20 | "types": [], 21 | "isolatedModules": true, 22 | "sourceMap": true, 23 | "declarationMap": true, 24 | "noImplicitReturns": false, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": false, 27 | "noFallthroughCasesInSwitch": true, 28 | "noEmitOnError": false, 29 | "noErrorTruncation": false, 30 | "allowJs": false, 31 | "checkJs": false, 32 | "forceConsistentCasingInFileNames": true, 33 | "noImplicitAny": true, 34 | "noImplicitThis": true, 35 | "noUncheckedIndexedAccess": false, 36 | "strictNullChecks": true, 37 | "baseUrl": ".", 38 | "target": "ES2022", 39 | "module": "NodeNext", 40 | "incremental": true, 41 | "removeComments": false, 42 | "plugins": [ 43 | { 44 | "name": "@effect/language-service" 45 | } 46 | ], 47 | "paths": { 48 | "@/*": [ 49 | "src/*" 50 | ] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [], 4 | "references": [ 5 | { 6 | "path": "tsconfig.src.json" 7 | }, 8 | { 9 | "path": "tsconfig.test.json" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "src" 5 | ], 6 | "compilerOptions": { 7 | "types": [ 8 | "node" 9 | ], 10 | "outDir": "dist", 11 | "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", 12 | "rootDir": "src" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "test" 5 | ], 6 | "references": [ 7 | { 8 | "path": "tsconfig.src.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "types": [ 13 | "node" 14 | ], 15 | "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", 16 | "rootDir": "test", 17 | "noEmit": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "node:path" 2 | import { defineConfig } from "vitest/config" 3 | 4 | export default defineConfig({ 5 | test: { 6 | alias: { 7 | app: Path.join(__dirname, "src") 8 | } 9 | } 10 | }) 11 | --------------------------------------------------------------------------------